mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 01:49:51 +08:00
fix: missing session fields
This commit is contained in:
@@ -71,6 +71,8 @@ const DROP_MAPPED_PARAMS = new Set(['doc_id', 'workspace_id', 'server_id']);
|
|||||||
const PRIORITY_KEYS = new Set([
|
const PRIORITY_KEYS = new Set([
|
||||||
'event_id',
|
'event_id',
|
||||||
'session_id',
|
'session_id',
|
||||||
|
'session_number',
|
||||||
|
'engagement_time_msec',
|
||||||
'ui_page',
|
'ui_page',
|
||||||
'ui_segment',
|
'ui_segment',
|
||||||
'ui_module',
|
'ui_module',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type TelemetryEvent = {
|
|||||||
userProperties?: Record<string, unknown>;
|
userProperties?: Record<string, unknown>;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
sessionId?: string;
|
sessionId?: string | number;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
timestampMicros?: number;
|
timestampMicros?: number;
|
||||||
context?: {
|
context?: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type TelemetryEvent = {
|
|||||||
userProperties?: Record<string, unknown>;
|
userProperties?: Record<string, unknown>;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
sessionId?: string;
|
sessionId?: string | number;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
timestampMicros?: number;
|
timestampMicros?: number;
|
||||||
context?: {
|
context?: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ export type TelemetryEvent = {
|
|||||||
userProperties?: Record<string, unknown>;
|
userProperties?: Record<string, unknown>;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
sessionId?: string;
|
sessionId?: string | number;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
timestampMicros?: number;
|
timestampMicros?: number;
|
||||||
context?: {
|
context?: {
|
||||||
|
|||||||
@@ -16,10 +16,23 @@ type Middleware = (
|
|||||||
|
|
||||||
const CLIENT_ID_KEY = 'affine_telemetry_client_id';
|
const CLIENT_ID_KEY = 'affine_telemetry_client_id';
|
||||||
const SESSION_ID_KEY = 'affine_telemetry_session_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 enabled = true;
|
||||||
let clientId = readPersistentId(CLIENT_ID_KEY, localStorageSafe());
|
const clientStorage = localStorageSafe();
|
||||||
let sessionId = readPersistentId(SESSION_ID_KEY, sessionStorageSafe());
|
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 userId: string | undefined;
|
||||||
let userProperties: Record<string, unknown> = {};
|
let userProperties: Record<string, unknown> = {};
|
||||||
@@ -48,7 +61,7 @@ export const tracker = {
|
|||||||
reset() {
|
reset() {
|
||||||
userId = undefined;
|
userId = undefined;
|
||||||
userProperties = {};
|
userProperties = {};
|
||||||
sessionId = readPersistentId(SESSION_ID_KEY, sessionStorageSafe(), true);
|
startNewSession(Date.now(), sessionStorageSafe());
|
||||||
setTelemetryContext(
|
setTelemetryContext(
|
||||||
{ userId, userProperties },
|
{ userId, userProperties },
|
||||||
{ replaceUserProperties: true }
|
{ replaceUserProperties: true }
|
||||||
@@ -67,10 +80,7 @@ export const tracker = {
|
|||||||
normalizeProperties(properties)
|
normalizeProperties(properties)
|
||||||
);
|
);
|
||||||
logger.debug('track', eventName, middlewareProperties);
|
logger.debug('track', eventName, middlewareProperties);
|
||||||
const event = buildEvent(eventName, middlewareProperties);
|
dispatchEvents(buildQueuedEvents(eventName, middlewareProperties));
|
||||||
void sendTelemetryEvent(event).catch(error => {
|
|
||||||
logger.error('failed to send telemetry event', error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
track_pageview(properties?: { location?: string; [key: string]: unknown }) {
|
track_pageview(properties?: { location?: string; [key: string]: unknown }) {
|
||||||
@@ -94,10 +104,7 @@ export const tracker = {
|
|||||||
pageTitle: pageTitle ?? middlewareProperties?.pageTitle,
|
pageTitle: pageTitle ?? middlewareProperties?.pageTitle,
|
||||||
};
|
};
|
||||||
logger.debug('track_pageview', params);
|
logger.debug('track_pageview', params);
|
||||||
const event = buildEvent('track_pageview', params);
|
dispatchEvents(buildQueuedEvents('track_pageview', params));
|
||||||
void sendTelemetryEvent(event).catch(error => {
|
|
||||||
logger.error('failed to send telemetry pageview', error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
middleware(cb: Middleware): () => void {
|
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<string, unknown>,
|
||||||
|
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<string, unknown> | undefined,
|
||||||
|
nextSessionId: number,
|
||||||
|
nextSessionNumber: number,
|
||||||
|
engagementMs: number
|
||||||
|
) {
|
||||||
|
const merged: Record<string, unknown> = {
|
||||||
|
...(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(
|
function buildEvent(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
|
|||||||
Reference in New Issue
Block a user