Is ggsel Ханти: 1080p на Twitch, игры дешевле и Steam в рублях safe?
The extension sends Twitch browsing activity and a persistent user ID to both the operator's backend and Yandex Metrica on every channel visit.
Each time a user visits a Twitch channel, the extension forwards the channel URL, game title, stream session details, and a stable cross-session UUID to twitch.ggsel.net and to Yandex Metrica (mc.yandex.ru). The persistent user ID is generated on first install and stored in chrome.storage.local, allowing the operator to track the same user across sessions. The extension also polls the operator backend every three minutes and displays push notifications with operator-controlled titles, bodies, and click URLs.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Twitch activity sent to ggsel.net events backend
The extension POSTed an event batch to twitch.ggsel.net/api/v1/events.
Events come from Twitch page visits, popup opens, activity markers, and stream sessions, tagged with a user ID, session ID, extension version, and Twitch context.
What actually happens
You browse Twitch or open the extension popup while the extension is installed.
The content script reads the current Twitch page, channel name, and stream-session state from the page.
The extension creates analytics events and sends them to the ggsel.net events backend.
Those events include persistent user and session identifiers that connect multiple actions to the same browser profile.
Captured request
Observed during dynamic analysis: the JSON event batch included a user ID beginning with ac443594, a session ID beginning with 74ed2e90, extension version 1.0.17.1, activity-marker events, and a popup-open event.
- content-type
- application/json
Fields attached to the ggsel.net event batch
- Persistent user IDac443594-8a1f-4d3b-9f4c-2b1d6e7c8a90
Lets the backend connect events from the same browser profile across separate browsing sessions.
- Browser-session ID74ed2e90-2a6d-46c1-9f9d-1e8f7c5b4a32
Groups activity that happened during the same browser session.
- Extension version1.0.17.1
Shows which installed version produced the event.
- Twitch page locationhttps://www.twitch.tv/shroud
Shows which Twitch page or channel was open when the extension created the event.
- Channel and game contextchannel_name=shroud, game_title=VALORANT
Describes the stream channel and game associated with the visit or stream session.
- Stream durationduration_sec=1840
Records how long a stream session lasted before the tab closed, changed, or became idle.
The code that does this
Source code path that creates Twitch events and posts them to ggsel.net
Endpoint configuration
common/config.jsexport const DEFAULT_API_BASE = 'https://twitch.ggsel.net';export const REQUEST_TIMEOUT_MS = 7000;// Canonical source marker for links opened from the Twitch extension.export const DEFAULT_UTM_SOURCE = 'ggsel_twitch_extension';// Stable canonical UTM medium for the extension (spec 3.3)export const DEFAULT_UTM_MEDIUM = 'twitch_app';export const GA_MEASUREMENT_ID = 'G-NCQMPNWKWS';export const GA_API_SECRET = 'grdZVJk4ROmZPlW9HA_CpQ';export const YM_COUNTER_ID = '104649646';// Phase 1 own analytics — third sink parallel to GA4/Yandex.// Set to false as kill switch if backend is overloaded or under attack.export const OWN_ANALYTICS_ENABLED = true;export const OWN_ANALYTICS_TIMEOUT_MS = 5000;export function buildEventsEndpoint(base = DEFAULT_API_BASE) { return new URL('/api/v1/events', base).toString();}Twitch visit and stream-session events
content/twitch.content.js function trackVisit(reason) { try { const pageUrl = getSafePageLocation(); usageTracker?.notifyContextChange(buildUsageContext(), reason || 'visit'); if (pageUrl === lastVisitLocation) return; lastVisitLocation = pageUrl; const channelName = getChannelName(); sendAnalyticsEvent('twitch_visit', { visit_reason: reason, channel_name: channelName || undefined, page_location: pageUrl, page_path: location.pathname, }); touchMonthlyActive('twitch_visit', { visit_reason: reason, channel_name: channelName || undefined, }); maybeStartStreamSession(channelName, reason); } catch (error) { console.warn('Failed to track visit', error); } } function maybeStartStreamSession(channelName, reason) { // Switch / leave channel → end the current session before starting a new one if (activeStreamSession && activeStreamSession.channelName !== channelName) { endStreamSession('navigate'); } if (streamSessionStartTimer) { clearTimeout(streamSessionStartTimer); streamSessionStartTimer = null; } if (!channelName) return; if (activeStreamSession && activeStreamSession.channelName === channelName) return; streamSessionStartTimer = setTimeout(() => { streamSessionStartTimer = null; // Re-check the channel hasn't changed during the wait. if (getChannelName() !== channelName) return; const gameTitle = getGameTitle(); activeStreamSession = { sessionId: generateLocalId(), startedAt: Date.now(), channelName, gameTitle, }; sendAnalyticsEvent('stream_session_start', { stream_session_id: activeStreamSession.sessionId, channel_name: channelName, game_title: gameTitle || undefined, visit_reason: reason || 'load', page_location: getSafePageLocation(), page_path: location.pathname, }); }, STREAM_SESSION_MIN_MS); } function endStreamSession(endReason) { if (!activeStreamSession) return; const { sessionId, startedAt, channelName, gameTitle } = activeStreamSession; activeStreamSession = null; const durationMs = Date.now() - startedAt; // Re-read game_title at session end — streamer may have switched games. // Fall back to the value captured at start so we always have something. const finalGameTitle = getGameTitle() || gameTitle || null; sendAnalyticsEvent('stream_session_end', { stream_session_id: sessionId, channel_name: channelName, game_title: finalGameTitle || undefined, duration_ms: durationMs, duration_sec: Math.round(durationMs / 1000), end_reason: endReason || 'unknown', }); }Event object builder and direct POST path
common/analytics.jsexport async function buildOwnEvent(eventName, params, state) { const userId = (state && (state.userId || state.clientId)) || ''; const sessionId = await getSessionId(); return { event_id: generateId(), name: eventName, ts: Date.now(), user_id: userId, session_id: sessionId, client_id: (state && state.ggselClientId) || null, properties: pickAllowedProperties(params), extension_version: getManifestVersion() || null, };}// When a batch buffer is configured (by background.js), events are queued// instead of sent immediately. Direct send remains the fallback path for// non-service-worker contexts.let ownBackendBuffer = null;export function setOwnBackendBuffer(buffer) { ownBackendBuffer = buffer && typeof buffer.push === 'function' ? buffer : null;}// Spec section 6.3: flush queue immediately when popup opens (user is// actively engaging — minimize buffering latency for these touchpoints).const FLUSH_AFTER_EVENTS = new Set([ 'popup_open', 'extension_install', 'extension_update', 'ggsel_button_click',]);async function sendToOwnBackend(eventName, params, state) { if (!OWN_ANALYTICS_ENABLED) return false; if (!OWN_BACKEND_KNOWN_EVENTS.has(eventName)) return false; if (!state || (!state.userId && !state.clientId)) return false; const event = await buildOwnEvent(eventName, params, state); if (!event.user_id) return false; if (ownBackendBuffer) { try { await ownBackendBuffer.push(event); if (FLUSH_AFTER_EVENTS.has(eventName)) { // fire-and-forget — flush errors stay inside the buffer ownBackendBuffer.flush().catch(() => {}); } return true; } catch (error) { console.warn('Own analytics buffer push failed; falling back to direct send', error); } } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), OWN_ANALYTICS_TIMEOUT_MS); try { const response = await fetch(buildEventsEndpoint(), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ events: [event] }), credentials: 'omit', signal: controller.signal, }); return response.ok; } catch (error) { console.warn('Own analytics send failed', error); return false; } finally { clearTimeout(timer); }}Service-worker batch POST path
background.jsasync function sendOwnAnalyticsBatch(events) { if (!Array.isArray(events) || events.length === 0) return; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), OWN_ANALYTICS_TIMEOUT_MS); try { const response = await fetch(buildEventsEndpoint(), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ events }), credentials: 'omit', signal: controller.signal, }); if (!response.ok) { throw new Error(`analytics batch HTTP ${response.status}`); } } finally { clearTimeout(timer); }}Network destinations involved in this claim
- twitch.ggsel.net
Receives the extension-owned JSON event batches for Twitch visits, popup opens, activity markers, and stream sessions.
Where it sends data
Destinations our analysis observed ggsel Ханти: 1080p на Twitch, игры дешевле и Steam в рублях contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.
- mc.yandex.ru
ggsel Ханти: 1080p на Twitch, игры дешевле и Steam в рублях sends data to mc.yandex.ru. One other extension we have analyzed sends data here.
- twitch.ggsel.net
ggsel Ханти: 1080p на Twitch, игры дешевле и Steam в рублях sends data to twitch.ggsel.net. One other extension we have analyzed sends data here.