diff --git a/code/core/src/core-server/server-channel/preview-initialized-channel.test.ts b/code/core/src/core-server/server-channel/preview-initialized-channel.test.ts new file mode 100644 index 000000000000..0055849979c0 --- /dev/null +++ b/code/core/src/core-server/server-channel/preview-initialized-channel.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { makePayload } from './preview-initialized-channel'; + +describe('makePayload', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('new user init session', () => { + const userAgent = 'Mozilla/5.0'; + const sessionId = 'session-123'; + const lastInit = { + timestamp: Date.now() - 3000, + body: { + sessionId, + payload: { newUser: true }, + }, + }; + + expect(makePayload(userAgent, lastInit as any, sessionId)).toMatchInlineSnapshot(` + { + "isNewUser": true, + "timeSinceInit": 3000, + "userAgent": "Mozilla/5.0", + } + `); + }); + + it('existing user init session', () => { + const userAgent = 'Mozilla/5.0'; + const sessionId = 'session-123'; + const lastInit = { + timestamp: Date.now() - 3000, + body: { + sessionId, + payload: {}, + }, + }; + + expect(makePayload(userAgent, lastInit as any, sessionId)).toMatchInlineSnapshot(` + { + "isNewUser": false, + "timeSinceInit": 3000, + "userAgent": "Mozilla/5.0", + } + `); + }); + + it('no init session', () => { + const userAgent = 'Mozilla/5.0'; + const sessionId = 'session-123'; + const lastInit = undefined; + + expect(makePayload(userAgent, lastInit, sessionId)).toMatchInlineSnapshot(` + { + "isNewUser": false, + "timeSinceInit": undefined, + "userAgent": "Mozilla/5.0", + } + `); + }); + + it('init session with different sessionId', () => { + const userAgent = 'Mozilla/5.0'; + const sessionId = 'session-123'; + const lastInit = { + timestamp: Date.now() - 3000, + body: { + sessionId: 'session-456', + }, + }; + + expect(makePayload(userAgent, lastInit as any, sessionId)).toMatchInlineSnapshot(` + { + "isNewUser": false, + "timeSinceInit": undefined, + "userAgent": "Mozilla/5.0", + } + `); + }); +}); diff --git a/code/core/src/core-server/server-channel/preview-initialized-channel.ts b/code/core/src/core-server/server-channel/preview-initialized-channel.ts index 1a0ad5e595da..0f689435aa1d 100644 --- a/code/core/src/core-server/server-channel/preview-initialized-channel.ts +++ b/code/core/src/core-server/server-channel/preview-initialized-channel.ts @@ -1,11 +1,30 @@ import type { Channel } from 'storybook/internal/channels'; import { PREVIEW_INITIALIZED } from 'storybook/internal/core-events'; -import { telemetry } from 'storybook/internal/telemetry'; +import { type InitPayload, telemetry } from 'storybook/internal/telemetry'; import type { CoreConfig, Options } from 'storybook/internal/types'; -import { getLastEvents } from '../../telemetry/event-cache'; +import { type CacheEntry, getLastEvents } from '../../telemetry/event-cache'; import { getSessionId } from '../../telemetry/session-id'; +export const makePayload = ( + userAgent: string, + lastInit: CacheEntry | undefined, + sessionId: string +) => { + let timeSinceInit: number | undefined; + const payload = { + userAgent, + isNewUser: false, + timeSinceInit, + }; + + if (sessionId && lastInit?.body?.sessionId === sessionId) { + payload.timeSinceInit = Date.now() - lastInit.timestamp; + payload.isNewUser = !!(lastInit.body.payload as InitPayload).newUser; + } + return payload; +}; + export function initPreviewInitializedChannel( channel: Channel, options: Options, @@ -19,9 +38,8 @@ export function initPreviewInitializedChannel( const lastInit = lastEvents.init; const lastPreviewFirstLoad = lastEvents['preview-first-load']; if (!lastPreviewFirstLoad) { - const isInitSession = lastInit?.body.sessionId === sessionId; - const timeSinceInit = lastInit ? Date.now() - lastInit.body.timestamp : undefined; - telemetry('preview-first-load', { timeSinceInit, isInitSession, userAgent }); + const payload = makePayload(userAgent, lastInit, sessionId); + telemetry('preview-first-load', payload); } } catch (e) { // do nothing diff --git a/code/core/src/telemetry/event-cache.ts b/code/core/src/telemetry/event-cache.ts index 0f25b1c14579..97c4e60da254 100644 --- a/code/core/src/telemetry/event-cache.ts +++ b/code/core/src/telemetry/event-cache.ts @@ -1,6 +1,6 @@ import { cache } from 'storybook/internal/common'; -import type { EventType } from './types'; +import type { EventType, TelemetryEvent } from './types'; interface UpgradeSummary { timestamp: number; @@ -9,9 +9,14 @@ interface UpgradeSummary { sessionId?: string; } +export interface CacheEntry { + timestamp: number; + body: TelemetryEvent; +} + let operation: Promise = Promise.resolve(); -const setHelper = async (eventType: EventType, body: any) => { +const setHelper = async (eventType: EventType, body: TelemetryEvent) => { const lastEvents = (await cache.get('lastEvents')) || {}; lastEvents[eventType] = { body, timestamp: Date.now() }; await cache.set('lastEvents', lastEvents); @@ -23,16 +28,16 @@ export const set = async (eventType: EventType, body: any) => { return operation; }; -export const get = async (eventType: EventType) => { +export const get = async (eventType: EventType): Promise => { const lastEvents = await getLastEvents(); return lastEvents[eventType]; }; -export const getLastEvents = async () => { +export const getLastEvents = async (): Promise> => { return (await cache.get('lastEvents')) || {}; }; -const upgradeFields = (event: any): UpgradeSummary => { +const upgradeFields = (event: CacheEntry): UpgradeSummary => { const { body, timestamp } = event; return { timestamp, diff --git a/code/core/src/telemetry/index.ts b/code/core/src/telemetry/index.ts index cf2f4ac084a0..617b2db2b7e6 100644 --- a/code/core/src/telemetry/index.ts +++ b/code/core/src/telemetry/index.ts @@ -43,21 +43,21 @@ export const telemetry = async ( telemetryData.metadata = await getStorybookMetadata(options?.configDir); } } catch (error: any) { - telemetryData.payload.metadataErrorMessage = sanitizeError(error).message; + payload.metadataErrorMessage = sanitizeError(error).message; if (options?.enableCrashReports) { - telemetryData.payload.metadataError = sanitizeError(error); + payload.metadataError = sanitizeError(error); } } finally { - const { error } = telemetryData.payload; + const { error } = payload; // make sure to anonymise possible paths from error messages // make sure to anonymise possible paths from error messages if (error) { - telemetryData.payload.error = sanitizeError(error); + payload.error = sanitizeError(error); } - if (!telemetryData.payload.error || options?.enableCrashReports) { + if (!payload.error || options?.enableCrashReports) { if (process.env?.STORYBOOK_TELEMETRY_DEBUG) { logger.info('\n[telemetry]'); logger.info(JSON.stringify(telemetryData, null, 2)); diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index e6f221da277e..69e27f4f794d 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -34,7 +34,6 @@ export type EventType = | 'onboarding-survey' | 'mocking' | 'preview-first-load'; - export interface Dependency { version: string | undefined; versionSpecifier?: string; @@ -90,6 +89,10 @@ export interface Payload { [key: string]: any; } +export interface Context { + [key: string]: any; +} + export interface Options { retryDelay: number; immediate: boolean; @@ -104,3 +107,17 @@ export interface TelemetryData { payload: Payload; metadata?: StorybookMetadata; } + +export interface TelemetryEvent extends TelemetryData { + eventId: string; + sessionId: string; + context: Context; +} + +export interface InitPayload { + projectType: string; + features: { dev: boolean; docs: boolean; test: boolean; onboarding: boolean }; + newUser: boolean; + versionSpecifier: string | undefined; + cliIntegration: string | undefined; +}