diff --git a/packages/backend/server/src/core/telemetry/cleaner.ts b/packages/backend/server/src/core/telemetry/cleaner.ts index 5da9ca5759..0000e349d6 100644 --- a/packages/backend/server/src/core/telemetry/cleaner.ts +++ b/packages/backend/server/src/core/telemetry/cleaner.ts @@ -71,6 +71,8 @@ const DROP_MAPPED_PARAMS = new Set(['doc_id', 'workspace_id', 'server_id']); const PRIORITY_KEYS = new Set([ 'event_id', 'session_id', + 'session_number', + 'engagement_time_msec', 'ui_page', 'ui_segment', 'ui_module', diff --git a/packages/backend/server/src/core/telemetry/types.ts b/packages/backend/server/src/core/telemetry/types.ts index 0e6773a0b0..f5eb2aea4d 100644 --- a/packages/backend/server/src/core/telemetry/types.ts +++ b/packages/backend/server/src/core/telemetry/types.ts @@ -5,7 +5,7 @@ export type TelemetryEvent = { userProperties?: Record; userId?: string; clientId: string; - sessionId?: string; + sessionId?: string | number; eventId: string; timestampMicros?: number; context?: { diff --git a/packages/common/nbstore/src/telemetry/types.ts b/packages/common/nbstore/src/telemetry/types.ts index acb9d2fe51..73046bef8d 100644 --- a/packages/common/nbstore/src/telemetry/types.ts +++ b/packages/common/nbstore/src/telemetry/types.ts @@ -5,7 +5,7 @@ export type TelemetryEvent = { userProperties?: Record; userId?: string; clientId: string; - sessionId?: string; + sessionId?: string | number; eventId: string; timestampMicros?: number; context?: { diff --git a/packages/frontend/track/src/__tests__/tracker.spec.ts b/packages/frontend/track/src/__tests__/tracker.spec.ts new file mode 100644 index 0000000000..10ff7ba72a --- /dev/null +++ b/packages/frontend/track/src/__tests__/tracker.spec.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const sendTelemetryEvent = vi.fn().mockResolvedValue({ queued: true }); +const setTelemetryContext = vi.fn(); + +vi.mock('../telemetry', () => ({ + sendTelemetryEvent, + setTelemetryContext, +})); + +const buildConfig = { + appVersion: '0.0.0', + appBuildType: 'stable', + editorVersion: '0.0.0', + isElectron: false, + isMobileEdition: false, + distribution: 'test', +}; + +beforeEach(() => { + (globalThis as any).BUILD_CONFIG = buildConfig; + localStorage.clear(); + sessionStorage.clear(); + sendTelemetryEvent.mockClear(); + setTelemetryContext.mockClear(); + vi.useRealTimers(); + vi.resetModules(); +}); + +async function loadTracker() { + return await import('../tracker'); +} + +describe('tracker session signals', () => { + test('sends first_visit and session_start on first event', async () => { + const { tracker } = await loadTracker(); + + tracker.track('test_event'); + + const events = sendTelemetryEvent.mock.calls.map(call => call[0]); + expect(events.map(event => event.eventName)).toEqual([ + 'first_visit', + 'session_start', + 'test_event', + ]); + + const firstVisit = events[0]; + expect(typeof (firstVisit.params as any).session_id).toBe('number'); + expect((firstVisit.params as any).session_number).toBe(1); + expect((firstVisit.params as any).engagement_time_msec).toBe(1); + }); + + test('does not repeat first_visit for later events', async () => { + const { tracker } = await loadTracker(); + + tracker.track('event_a'); + tracker.track('event_b'); + + const names = sendTelemetryEvent.mock.calls.map(call => call[0].eventName); + expect(names.filter(name => name === 'first_visit')).toHaveLength(1); + expect(names.filter(name => name === 'session_start')).toHaveLength(1); + }); + + test('increments session_number after idle timeout', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + const { tracker } = await loadTracker(); + + tracker.track('event_a'); + sendTelemetryEvent.mockClear(); + + vi.setSystemTime(new Date('2024-01-01T01:00:00Z')); + tracker.track('event_b'); + + const events = sendTelemetryEvent.mock.calls.map(call => call[0]); + const sessionStart = events.find( + event => event.eventName === 'session_start' + ); + expect((sessionStart?.params as any).session_number).toBe(2); + }); +}); diff --git a/packages/frontend/track/src/telemetry.ts b/packages/frontend/track/src/telemetry.ts index 2904c69b15..61a764c573 100644 --- a/packages/frontend/track/src/telemetry.ts +++ b/packages/frontend/track/src/telemetry.ts @@ -7,7 +7,7 @@ export type TelemetryEvent = { userProperties?: Record; userId?: string; clientId: string; - sessionId?: string; + sessionId?: string | number; eventId: string; timestampMicros?: number; context?: { diff --git a/packages/frontend/track/src/tracker.ts b/packages/frontend/track/src/tracker.ts index 0731f1dd8f..5f32c4458b 100644 --- a/packages/frontend/track/src/tracker.ts +++ b/packages/frontend/track/src/tracker.ts @@ -16,10 +16,23 @@ type Middleware = ( const CLIENT_ID_KEY = 'affine_telemetry_client_id'; const SESSION_ID_KEY = 'affine_telemetry_session_id'; +const SESSION_NUMBER_KEY = 'affine_telemetry_session_number'; +const SESSION_NUMBER_CURRENT_KEY = 'affine_telemetry_session_number_current'; +const LAST_ACTIVITY_KEY = 'affine_telemetry_last_activity_ms'; +const SESSION_TIMEOUT_MS = 30 * 60 * 1000; let enabled = true; -let clientId = readPersistentId(CLIENT_ID_KEY, localStorageSafe()); -let sessionId = readPersistentId(SESSION_ID_KEY, sessionStorageSafe()); +const clientStorage = localStorageSafe(); +const hasClientId = clientStorage?.getItem(CLIENT_ID_KEY); +let clientId = readPersistentId(CLIENT_ID_KEY, clientStorage); +let pendingFirstVisit = !hasClientId; +let sessionId = 0; +let sessionNumber = 0; +let lastActivityMs = 0; +let sessionStartSent = false; +let engagementTrackingEnabled = false; +let visibleSinceMs: number | null = null; +let pendingEngagementMs = 0; let userId: string | undefined; let userProperties: Record = {}; @@ -48,7 +61,7 @@ export const tracker = { reset() { userId = undefined; userProperties = {}; - sessionId = readPersistentId(SESSION_ID_KEY, sessionStorageSafe(), true); + startNewSession(Date.now(), sessionStorageSafe()); setTelemetryContext( { userId, userProperties }, { replaceUserProperties: true } @@ -67,10 +80,7 @@ export const tracker = { normalizeProperties(properties) ); logger.debug('track', eventName, middlewareProperties); - const event = buildEvent(eventName, middlewareProperties); - void sendTelemetryEvent(event).catch(error => { - logger.error('failed to send telemetry event', error); - }); + dispatchEvents(buildQueuedEvents(eventName, middlewareProperties)); }, track_pageview(properties?: { location?: string; [key: string]: unknown }) { @@ -94,10 +104,7 @@ export const tracker = { pageTitle: pageTitle ?? middlewareProperties?.pageTitle, }; logger.debug('track_pageview', params); - const event = buildEvent('track_pageview', params); - void sendTelemetryEvent(event).catch(error => { - logger.error('failed to send telemetry pageview', error); - }); + dispatchEvents(buildQueuedEvents('track_pageview', params)); }, middleware(cb: Middleware): () => void { @@ -141,6 +148,260 @@ export const tracker = { }, }; +function dispatchEvents(events: TelemetryEvent[]) { + for (const event of events) { + void sendTelemetryEvent(event).catch(error => { + logger.error(`failed to send telemetry event ${event.eventName}`, error); + }); + } +} + +function buildQueuedEvents( + eventName: string, + params?: Record, + options: { now?: number; engagementMs?: number } = {} +) { + const now = options.now ?? Date.now(); + const { + sessionId: nextSessionId, + sessionNumber: nextSessionNumber, + preEvents, + } = prepareSession(now); + const engagementMs = options.engagementMs ?? consumeEngagementTime(now); + const eventParams = mergeSessionParams( + params, + nextSessionId, + nextSessionNumber, + engagementMs + ); + return [...preEvents, buildEvent(eventName, eventParams)]; +} + +function prepareSession(now: number) { + const sessionStorage = sessionStorageSafe(); + if (sessionStorage) { + const storedSessionId = readPositiveNumber(sessionStorage, SESSION_ID_KEY); + const storedLastActivity = readPositiveNumber( + sessionStorage, + LAST_ACTIVITY_KEY + ); + const expired = + !storedSessionId || + !storedLastActivity || + now - storedLastActivity > SESSION_TIMEOUT_MS; + + if (expired) { + startNewSession(now, sessionStorage); + } else { + sessionId = storedSessionId; + sessionNumber = readCurrentSessionNumber(sessionStorage, clientStorage); + updateLastActivity(now, sessionStorage); + } + } else { + const expired = + !sessionId || + !lastActivityMs || + now - lastActivityMs > SESSION_TIMEOUT_MS; + if (expired) { + startNewSession(now, null); + } else { + lastActivityMs = now; + if (!sessionNumber) { + sessionNumber = 1; + } + } + } + + const preEvents: TelemetryEvent[] = []; + if (pendingFirstVisit) { + pendingFirstVisit = false; + preEvents.push( + buildEvent( + 'first_visit', + mergeSessionParams({}, sessionId, sessionNumber, 1) + ) + ); + } + if (!sessionStartSent) { + sessionStartSent = true; + preEvents.push( + buildEvent( + 'session_start', + mergeSessionParams({}, sessionId, sessionNumber, 1) + ) + ); + } + return { sessionId, sessionNumber, preEvents }; +} + +function mergeSessionParams( + params: Record | undefined, + nextSessionId: number, + nextSessionNumber: number, + engagementMs: number +) { + const merged: Record = { + ...(params ?? {}), + }; + if (Number.isFinite(nextSessionId) && nextSessionId > 0) { + merged.session_id = nextSessionId; + } + if (Number.isFinite(nextSessionNumber) && nextSessionNumber > 0) { + merged.session_number = nextSessionNumber; + } + if (Number.isFinite(engagementMs)) { + merged.engagement_time_msec = engagementMs; + } + return merged; +} + +function startNewSession(now: number, sessionStorage: Storage | null) { + sessionId = Math.floor(now / 1000); + sessionNumber = incrementSessionNumber(clientStorage, sessionStorage); + updateLastActivity(now, sessionStorage); + writeNumber(sessionStorage, SESSION_ID_KEY, sessionId); + sessionStartSent = false; + resetEngagementState(now); +} + +function updateLastActivity(now: number, sessionStorage: Storage | null) { + lastActivityMs = now; + writeNumber(sessionStorage, LAST_ACTIVITY_KEY, now); +} + +function consumeEngagementTime(now: number) { + initEngagementTracking(now); + if (visibleSinceMs !== null) { + pendingEngagementMs += now - visibleSinceMs; + visibleSinceMs = now; + } + const engagementMs = Math.max(0, Math.round(pendingEngagementMs)); + pendingEngagementMs = 0; + return engagementMs; +} + +function resetEngagementState(now: number) { + pendingEngagementMs = 0; + visibleSinceMs = isDocumentVisible() ? now : null; +} + +function initEngagementTracking(now: number) { + if (engagementTrackingEnabled || typeof document === 'undefined') { + return; + } + engagementTrackingEnabled = true; + resetEngagementState(now); + + document.addEventListener('visibilitychange', () => { + const now = Date.now(); + if (visibleSinceMs !== null) { + pendingEngagementMs += now - visibleSinceMs; + } + visibleSinceMs = isDocumentVisible() ? now : null; + if (!isDocumentVisible()) { + dispatchUserEngagement(now); + } + }); + + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', () => { + dispatchUserEngagement(Date.now()); + }); + } +} + +function dispatchUserEngagement(now: number) { + if (!enabled) { + return; + } + const engagementMs = consumeEngagementTime(now); + if (engagementMs <= 0) { + return; + } + dispatchEvents( + buildQueuedEvents( + 'user_engagement', + { engagement_time_msec: engagementMs }, + { now, engagementMs } + ) + ); +} + +function isDocumentVisible() { + try { + return ( + typeof document !== 'undefined' && document.visibilityState !== 'hidden' + ); + } catch { + return true; + } +} + +function readPositiveNumber(storage: Storage | null, key: string) { + if (!storage) { + return undefined; + } + const raw = storage.getItem(key); + if (!raw) { + return undefined; + } + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + return undefined; + } + return value; +} + +function writeNumber(storage: Storage | null, key: string, value: number) { + if (!storage) { + return; + } + try { + storage.setItem(key, String(value)); + } catch { + return; + } +} + +function readCurrentSessionNumber( + sessionStorage: Storage, + localStorage: Storage | null +) { + const current = readPositiveNumber( + sessionStorage, + SESSION_NUMBER_CURRENT_KEY + ); + if (current) { + return current; + } + + const fallback = localStorage + ? (readPositiveNumber(localStorage, SESSION_NUMBER_KEY) ?? 1) + : sessionNumber || 1; + + writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, fallback); + if (localStorage && !readPositiveNumber(localStorage, SESSION_NUMBER_KEY)) { + writeNumber(localStorage, SESSION_NUMBER_KEY, fallback); + } + return fallback; +} + +function incrementSessionNumber( + localStorage: Storage | null, + sessionStorage: Storage | null +) { + if (!localStorage) { + const next = (sessionNumber || 0) + 1; + writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, next); + return next; + } + const current = readPositiveNumber(localStorage, SESSION_NUMBER_KEY) ?? 0; + const next = current + 1; + writeNumber(localStorage, SESSION_NUMBER_KEY, next); + writeNumber(sessionStorage, SESSION_NUMBER_CURRENT_KEY, next); + return next; +} + function buildEvent( eventName: string, params?: Record