diff --git a/e2e/app/src/main.ts b/e2e/app/src/main.ts index bbe705ec..ed47a1ef 100644 --- a/e2e/app/src/main.ts +++ b/e2e/app/src/main.ts @@ -66,6 +66,7 @@ import { const isDebugMode = process.env.PWDEBUG === '1'; let mainWindow: BrowserWindow | null = null; +let testRendererWindow: BrowserWindow | null = null; let rendererHttpServer: http.Server | null = null; const noop = () => undefined; @@ -247,6 +248,27 @@ void app.whenReady().then(async () => { ipcMain.handle('ping', () => 'pong'); + ipcMain.handle('openRendererProcess', () => { + testRendererWindow = new BrowserWindow({ + width: 400, + height: 300, + show: isDebugMode, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + void testRendererWindow.loadURL('about:blank'); + testRendererWindow.on('closed', () => { + testRendererWindow = null; + }); + }); + + ipcMain.handle('closeRendererProcess', () => { + testRendererWindow?.close(); + testRendererWindow = null; + }); + ipcMain.on('mainFireAndForget', (event) => { event.sender.send('mainFireAndForgetAck'); }); diff --git a/e2e/app/src/preload.ts b/e2e/app/src/preload.ts index 29305631..8fca70d6 100644 --- a/e2e/app/src/preload.ts +++ b/e2e/app/src/preload.ts @@ -50,4 +50,6 @@ contextBridge.exposeInMainWorld('electronAPI', { openBridgeFileWindowNoIsolation: () => ipcRenderer.invoke('openBridgeFileWindowNoIsolation'), openBridgeHttpWindow: () => ipcRenderer.invoke('openBridgeHttpWindow'), openBridgeAppProtocolWindow: () => ipcRenderer.invoke('openBridgeAppProtocolWindow'), + openRendererProcess: () => ipcRenderer.invoke('openRendererProcess'), + closeRendererProcess: () => ipcRenderer.invoke('closeRendererProcess'), }); diff --git a/e2e/lib/mainPage.ts b/e2e/lib/mainPage.ts index 00a66f76..70dd0e27 100644 --- a/e2e/lib/mainPage.ts +++ b/e2e/lib/mainPage.ts @@ -39,6 +39,8 @@ interface ElectronAppWindow { openBridgeFileWindowNoIsolation: () => Promise; openBridgeHttpWindow: () => Promise; openBridgeAppProtocolWindow: () => Promise; + openRendererProcess: () => Promise; + closeRendererProcess: () => Promise; }; } @@ -242,4 +244,14 @@ export class MainPage { ); return await BridgeWindowPage.waitForReady(electronApp); } + + async openRendererProcess(): Promise { + await this.page.evaluate(() => (globalThis as unknown as ElectronAppWindow).electronAPI.openRendererProcess()); + await this.waitForIpcPropagation(); + } + + async closeRendererProcess(): Promise { + await this.page.evaluate(() => (globalThis as unknown as ElectronAppWindow).electronAPI.closeRendererProcess()); + await this.waitForIpcPropagation(); + } } diff --git a/e2e/scenarios/process.scenario.ts b/e2e/scenarios/process.scenario.ts new file mode 100644 index 00000000..80ed96ac --- /dev/null +++ b/e2e/scenarios/process.scenario.ts @@ -0,0 +1,69 @@ +import { test, expect } from '../lib/helpers'; + +interface ProcessEvent { + type: 'process'; + process: { + id: string; + role: 'main' | 'renderer'; + pid: number; + name?: string; + duration?: number; + exit_reason?: string; + }; + _dd: { document_version: number }; +} + +test('emits a main process start event on SDK init', async ({ mainPage, intake }) => { + await mainPage.flushTransport(); + const events = await intake.getEventsByType('process'); + + expect(events.length).toBeGreaterThanOrEqual(1); + const mainEvent = events.find((e) => (e.body as ProcessEvent).process.role === 'main'); + expect(mainEvent).toBeDefined(); + + const body = mainEvent!.body as ProcessEvent; + expect(body.process.role).toBe('main'); + expect(body.process.pid).toBeGreaterThan(0); + expect(body._dd.document_version).toBe(1); + expect(body.process.duration).toBeUndefined(); + expect(body.process.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); +}); + +test('all main-process events carry process.id and process.role', async ({ mainPage, intake }) => { + await mainPage.flushTransport(); + const viewEvents = await intake.getEventsByType('view'); + expect(viewEvents.length).toBeGreaterThanOrEqual(1); + + const view = viewEvents[0].body as Record; + const processCtx = view['process'] as { id: string; role: string } | undefined; + expect(processCtx).toBeDefined(); + expect(processCtx!.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(processCtx!.role).toBe('main'); +}); + +test('emits start and end process events for a renderer window lifecycle', async ({ mainPage, intake }) => { + await mainPage.flushTransport(); + const before = (await intake.getEventsByType('process')).length; + + await mainPage.openRendererProcess(); + await mainPage.flushTransport(); + + const afterOpen = await intake.getEventsByType('process'); + const rendererStart = afterOpen.slice(before).find((e) => (e.body as ProcessEvent).process.role === 'renderer'); + expect(rendererStart).toBeDefined(); + + const body = rendererStart!.body as ProcessEvent; + expect(body._dd.document_version).toBe(1); + expect(body.process.duration).toBeUndefined(); + const rendererId = body.process.id; + + await mainPage.closeRendererProcess(); + await mainPage.flushTransport(); + + const afterClose = await intake.getEventsByType('process'); + const rendererEnd = afterClose + .slice(afterOpen.length) + .find((e) => (e.body as ProcessEvent).process.id === rendererId); + expect(rendererEnd).toBeDefined(); + expect((rendererEnd!.body as ProcessEvent)._dd.document_version).toBeGreaterThan(1); +}); diff --git a/e2e/scenarios/view.scenario.ts b/e2e/scenarios/view.scenario.ts index 885b485b..d7fc6b5d 100644 --- a/e2e/scenarios/view.scenario.ts +++ b/e2e/scenarios/view.scenario.ts @@ -1,8 +1,7 @@ import { test, expect } from '../lib/helpers'; import type { RumViewEvent } from '@datadog/electron-sdk'; -const isMainProcessView = (event: { body: unknown }) => - (event.body as RumViewEvent).view.url === 'electron://main-process'; +const isMainProcessView = (event: { body: unknown }) => (event.body as RumViewEvent).view.url === 'electron://fake'; test('emits an initial active view event on SDK init', async ({ mainPage, intake }) => { await mainPage.flushTransport(); @@ -30,12 +29,10 @@ test('emits an initial active view event on SDK init', async ({ mainPage, intake expect(headers['dd-evp-origin']).toBe('electron'); expect(headers['dd-evp-origin-version']).toMatch(/^\d+\.\d+\.\d+$/); expect(headers['dd-request-id']).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - expect(view.view.name).toBe('main process'); - expect(view.view.url).toBe('electron://main-process'); + expect(view.view.url).toBe('electron://fake'); + expect((view.view as unknown as { is_fake?: boolean }).is_fake).toBe(true); + expect((view.view as unknown as { name?: string }).name).toBeUndefined(); expect(view.view.is_active).toBe(true); - expect(view.view.action.count).toBe(0); - expect(view.view.error.count).toBe(0); - expect(view.view.resource.count).toBe(0); expect(view._dd.document_version).toBe(1); expect(view.view.id).toBeDefined(); expect(view.view.time_spent).toBeGreaterThanOrEqual(0); @@ -73,15 +70,3 @@ test.describe('session renewal via user activity', () => { expect(newView._dd.document_version).toBe(1); }); }); - -test('increments view error count after an uncaught exception', async ({ mainPage, intake }) => { - await mainPage.generateUncaughtException(); - await mainPage.flushTransport(); - - await intake.getEventsByType('error'); - const viewEvents = await intake.waitForEventCount('view', 2); - const updatedView = viewEvents[1].body as RumViewEvent; - - expect(updatedView.view.error.count).toBe(1); - expect(updatedView._dd.document_version).toBeGreaterThan(1); -}); diff --git a/playground/package.json b/playground/package.json index 6fab8ead..952b5a97 100644 --- a/playground/package.json +++ b/playground/package.json @@ -6,9 +6,10 @@ "description": "Playground for testing @datadog/electron-sdk", "main": "./dist/main.js", "scripts": { - "build": "tsc && yarn build:renderer && cp src/index.html dist/index.html", + "build": "tsc && yarn build:renderer && yarn build:secondary-renderer && cp src/index.html dist/index.html && cp src/secondary.html dist/secondary.html", "build:renderer": "esbuild src/renderer.ts --bundle --format=esm --outfile=dist/renderer.js --sourcemap", - "build:watch": "concurrently \"tsc --watch\" \"yarn build:renderer --watch\"", + "build:secondary-renderer": "esbuild src/secondary-renderer.ts --bundle --format=esm --outfile=dist/secondary-renderer.js --sourcemap", + "build:watch": "concurrently \"tsc --watch\" \"yarn build:renderer --watch\" \"yarn build:secondary-renderer --watch\"", "start": "yarn build && electron .", "dev": "yarn build && concurrently \"yarn build:watch\" \"electron .\"", "test": "yarn build && playwright test -c test/playwright.config.ts" diff --git a/playground/src/index.html b/playground/src/index.html index 3f0b0d33..8cd9d136 100644 --- a/playground/src/index.html +++ b/playground/src/index.html @@ -227,6 +227,11 @@

Session ID:

+ +
+ +
+

IPC Activity Log:

diff --git a/playground/src/main.ts b/playground/src/main.ts index 57e2ed4c..f74605e5 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -35,6 +35,7 @@ import { buildRumExplorerUrl } from './main/utils'; const isTestMode = process.env.DD_TEST_MODE === '1'; let mainWindow: BrowserWindow | null = null; +let secondaryWindow: BrowserWindow | null = null; // Serving the renderer over a custom scheme (instead of file://) lets us attach the `Document-Policy: js-profiling` // response header, which is required to enable the JS Self-Profiling API. The scheme must be registered as @@ -232,6 +233,23 @@ ipcMain.handle('open-rum-explorer', () => { void shell.openExternal(buildRumExplorerUrl(CONF[ACTIVE_ENV], ctx.session_id)); }); +ipcMain.handle('main:open-secondary-window', () => { + if (secondaryWindow) return; + secondaryWindow = new BrowserWindow({ + width: 500, + height: 400, + title: 'Secondary Renderer Process', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + void secondaryWindow.loadURL('app://app/secondary.html'); + secondaryWindow.on('closed', () => { + secondaryWindow = null; + }); +}); + void app.whenReady().then(async () => { // Initialize SDK on app ready (before window creation) console.log('Initializing SDK from main process...'); diff --git a/playground/src/preload.ts b/playground/src/preload.ts index c9178d3f..c3dad265 100644 --- a/playground/src/preload.ts +++ b/playground/src/preload.ts @@ -37,6 +37,7 @@ contextBridge.exposeInMainWorld('electronAPI', { mainFetchApiNet: () => ipcRenderer.invoke('main:fetch-api-net'), openRumExplorer: () => ipcRenderer.invoke('open-rum-explorer'), flushTransport: () => ipcRenderer.invoke('flush-transport'), + openSecondaryWindow: () => ipcRenderer.invoke('main:open-secondary-window'), setUserInfo: () => ipcRenderer.invoke('main:set-user-info'), addUserExtraInfo: () => ipcRenderer.invoke('main:add-user-extra-info'), clearUserInfo: () => ipcRenderer.invoke('main:clear-user-info'), diff --git a/playground/src/renderer.ts b/playground/src/renderer.ts index 356fa057..60bcf29d 100644 --- a/playground/src/renderer.ts +++ b/playground/src/renderer.ts @@ -50,6 +50,7 @@ interface ElectronAPI { mainFetchApiNet: () => Promise; openRumExplorer: () => Promise; flushTransport: () => Promise; + openSecondaryWindow: () => Promise; setUserInfo: () => Promise; addUserExtraInfo: () => Promise; clearUserInfo: () => Promise; @@ -348,3 +349,5 @@ if (parallelBtn) { }); }); } + +setupDemoButton('open-secondary-window', 'main:open-secondary-window', () => window.electronAPI.openSecondaryWindow()); diff --git a/playground/src/secondary-renderer.ts b/playground/src/secondary-renderer.ts new file mode 100644 index 00000000..2663ece7 --- /dev/null +++ b/playground/src/secondary-renderer.ts @@ -0,0 +1,38 @@ +import { datadogRum } from '@datadog/browser-rum'; + +datadogRum.init({ + applicationId: '6efd3722-af0a-4070-994c-0e87076d4814', + clientToken: 'pub2a7307cdec74934cacb411a193f632f8', + site: 'datad0g.com', + service: 'electron-playground', + env: 'dev', + sessionSampleRate: 100, + trackResources: true, + trackLongTasks: true, + trackUserInteractions: true, +}); + +const status = document.getElementById('status') as HTMLElement; + +function setStatus(msg: string) { + status.textContent = msg; +} + +const fetchBtn = document.getElementById('fetch-btn') as HTMLButtonElement; +fetchBtn.addEventListener('click', () => { + fetchBtn.disabled = true; + setStatus('Fetching…'); + fetch('https://httpbin.org/json') + .then((res) => res.json()) + .then(() => setStatus('Fetch done')) + .catch((err) => setStatus(`Fetch error: ${String(err)}`)) + .finally(() => { + fetchBtn.disabled = false; + }); +}); + +const errorBtn = document.getElementById('error-btn') as HTMLButtonElement; +errorBtn.addEventListener('click', () => { + setStatus('Error thrown'); + throw new Error('test error from secondary renderer'); +}); diff --git a/playground/src/secondary.html b/playground/src/secondary.html new file mode 100644 index 00000000..0c3283a3 --- /dev/null +++ b/playground/src/secondary.html @@ -0,0 +1,108 @@ + + + + + + + Secondary Renderer Process + + + +
+

Secondary Renderer

+

Events from this window carry a distinct process.id

+ + +
+ + +
+ +
+
+ + + + diff --git a/rum-events-format b/rum-events-format index 8dc61166..a6d3d9b5 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 8dc61166ee818608892d13b6565ff04a3f2a7fe9 +Subproject commit a6d3d9b52cf075b6c6199f1fcff364d2408ac6de diff --git a/src/assembly/MainAssembly.spec.ts b/src/assembly/MainAssembly.spec.ts index 70648772..775dea6c 100644 --- a/src/assembly/MainAssembly.spec.ts +++ b/src/assembly/MainAssembly.spec.ts @@ -14,9 +14,20 @@ import { type ServerRumEvent, type ServerTelemetryEvent, } from '../event'; -import type { RumEvent, RawRumData } from '../domain/rum'; +import type { RumEvent, RawRumData, RawRumProcess } from '../domain/rum'; import type { RawTelemetryData } from '../domain/telemetry'; +const RAW_PROCESS_DATA: RawRumProcess = { + type: 'process', + date: 0 as TimeStamp, + process: { + id: 'proc-1', + role: 'main', + pid: 1234, + }, + _dd: { document_version: 1 }, +}; + const RAW_ERROR_DATA: RawRumData = { type: 'error', error: { id: '1', message: 'test', source: 'custom', handling: 'handled' }, @@ -111,6 +122,48 @@ describe('MainAssembly', () => { expect((serverEvents[0] as ServerRumEvent).source).toBe(EventSource.MAIN); }); + describe('PROCESS events', () => { + it('emits a ServerRumEvent for a process event', () => { + hooks.registerRum(() => ({ session: { id: 'session-1' } })); + + eventManager.notify({ + kind: EventKind.RAW, + format: EventFormat.RUM, + data: RAW_PROCESS_DATA, + }); + + expect(serverEvents).toHaveLength(1); + expect((serverEvents[0] as ServerRumEvent).source).toBe(EventSource.MAIN); + expect((serverEvents[0].data as RawRumProcess).type).toBe('process'); + expect((serverEvents[0].data as RawRumProcess).process.role).toBe('main'); + }); + + it('discards process events when the rum hook returns DISCARDED', () => { + hooks.registerRum(() => DISCARDED); + + eventManager.notify({ + kind: EventKind.RAW, + format: EventFormat.RUM, + data: RAW_PROCESS_DATA, + }); + + expect(serverEvents).toHaveLength(0); + }); + + it('enriches process events with hook attributes', () => { + hooks.registerRum(() => ({ session: { id: 'hook-session' } })); + + eventManager.notify({ + kind: EventKind.RAW, + format: EventFormat.RUM, + data: RAW_PROCESS_DATA, + }); + + expect(serverEvents).toHaveLength(1); + expect((serverEvents[0].data as { session?: { id: string } }).session?.id).toBe('hook-session'); + }); + }); + describe('TELEMETRY events', () => { it('emits ServerTelemetryEvent with source MAIN', () => { hooks.registerTelemetry(() => ({})); diff --git a/src/assembly/MainAssembly.ts b/src/assembly/MainAssembly.ts index e9a866a1..696d3f3b 100644 --- a/src/assembly/MainAssembly.ts +++ b/src/assembly/MainAssembly.ts @@ -11,7 +11,7 @@ import { type RawProfileEvent, type ServerEvent, } from '../event'; -import type { FormatHooks } from './hooks'; +import type { FormatHooks, RumEventType } from './hooks'; import { RumEvent } from '../domain/rum'; import { TelemetryEvent } from '../domain/telemetry'; @@ -43,7 +43,7 @@ export class MainAssembly { const startTime = event.startTime ?? timeStampNow(); const source = EventSource.MAIN; - if (event.format === EventFormat.RUM) { + if (event.format === EventFormat.RUM && (isRumEventType(event.data.type) || event.data.type === 'process')) { const hookResult = this.hooks.triggerRum({ eventType: event.data.type, startTime, @@ -78,3 +78,24 @@ export class MainAssembly { function assembleData(rawData: unknown, hookResult: RecursivePartial | undefined): T { return (hookResult ? combine(hookResult, rawData) : rawData) as T; } + +// The `satisfies` constraint has two roles: +// - `Record` key exhaustiveness: every RumEventType variant must be present (compile error if one is missing) +// - values typed as `RumEventType[]`: no string outside the union can be added +// Together they keep the type guard sound when RumEvent schema changes. +const RUM_EVENT_TYPES = new Set( + Object.keys({ + action: 1, + error: 1, + long_task: 1, + resource: 1, + transition: 1, + view: 1, + view_update: 1, + vital: 1, + } satisfies Record) as RumEventType[] +); + +function isRumEventType(type: string): type is RumEventType { + return RUM_EVENT_TYPES.has(type as RumEventType); +} diff --git a/src/assembly/RendererPipeline.spec.ts b/src/assembly/RendererPipeline.spec.ts index 0b5e0aa6..5b45ed37 100644 --- a/src/assembly/RendererPipeline.spec.ts +++ b/src/assembly/RendererPipeline.spec.ts @@ -89,7 +89,7 @@ describe('RendererPipeline', () => { mockIpcMainOn.mockImplementation((channel: string, callback: (_event: unknown, msg: string) => void) => { if (channel === BRIDGE_CHANNEL) { - simulateIpcMessage = (msg: string) => callback({}, msg); + simulateIpcMessage = (msg: string) => callback({ sender: { id: 7 } }, msg); } }); @@ -203,6 +203,18 @@ describe('RendererPipeline', () => { expect(capturedStartTime).toBe(12345); }); + it('passes the sender webContentsId to triggerRum', () => { + let capturedWebContentsId: number | undefined; + hooks.registerRum(({ webContentsId }) => { + capturedWebContentsId = webContentsId; + return {}; + }); + + simulateIpcMessage(JSON.stringify({ eventType: 'rum', event: RENDERER_RUM_DATA })); + + expect(capturedWebContentsId).toBe(7); + }); + it('discards the event when triggerRum returns DISCARDED', () => { hooks.registerRum(() => DISCARDED); diff --git a/src/assembly/RendererPipeline.ts b/src/assembly/RendererPipeline.ts index 6145d7fb..dbebc7c0 100644 --- a/src/assembly/RendererPipeline.ts +++ b/src/assembly/RendererPipeline.ts @@ -47,8 +47,8 @@ export class RendererPipeline { ipcMain.on( BRIDGE_CHANNEL, - monitor((_ipcEvent: unknown, msg: string) => { - this.onBridgeMessage(msg); + monitor((ipcEvent: Electron.IpcMainEvent, msg: string) => { + this.onBridgeMessage(ipcEvent.sender.id, msg); }) ); @@ -57,7 +57,7 @@ export class RendererPipeline { setBridgeConfig(this.bridgeOptions); } - private onBridgeMessage(msg: string): void { + private onBridgeMessage(webContentsId: number, msg: string): void { let bridgeEvent: BridgeEvent; try { bridgeEvent = JSON.parse(msg) as BridgeEvent; @@ -68,7 +68,7 @@ export class RendererPipeline { switch (bridgeEvent.eventType) { case 'rum': - this.handleRumEvent(bridgeEvent.event); + this.handleRumEvent(webContentsId, bridgeEvent.event); break; case 'log': // TODO(RUM-15047): when Logs are implemented, enrich them with user/account context @@ -99,7 +99,7 @@ export class RendererPipeline { } } - private handleRumEvent(eventData: unknown): void { + private handleRumEvent(webContentsId: number, eventData: unknown): void { const data = eventData as RumEvent; // Emit activity before the session check: a click after session expiry must still @@ -113,6 +113,7 @@ export class RendererPipeline { eventType: data.type, startTime: data.date as TimeStamp, source: EventSource.RENDERER, + webContentsId, }); if (hookResult === DISCARDED) { diff --git a/src/assembly/commonContext.spec.ts b/src/assembly/commonContext.spec.ts index e2b2ad73..4b0a54e0 100644 --- a/src/assembly/commonContext.spec.ts +++ b/src/assembly/commonContext.spec.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; import type { TimeStamp } from '@datadog/js-core/time'; -import { registerCommonContext } from './commonContext'; +import { registerCommonContext, registerProcessContext } from './commonContext'; import { createFormatHooks } from './hooks'; import { EventSource } from '../event'; import type { Configuration } from '../config'; import { display } from '../tools/display'; +import { ProcessContext } from '../domain/rum/process'; const T0 = 0 as TimeStamp; @@ -168,3 +169,44 @@ describe('registerCommonContext', () => { }); }); }); + +describe('registerProcessContext', () => { + it('enriches main-process events with main process context', () => { + const hooks = createFormatHooks(); + const processContext = new ProcessContext({ id: 'main-uuid', name: undefined }); + registerProcessContext(processContext, hooks); + + const result = hooks.triggerRum({ eventType: 'view', startTime: 0 as TimeStamp, source: EventSource.MAIN }); + expect(result).toMatchObject({ process: { id: 'main-uuid', role: 'main' } }); + }); + + it('enriches renderer events with renderer process context when webContentsId is known', () => { + const hooks = createFormatHooks(); + const processContext = new ProcessContext({ id: 'main-uuid', name: undefined }); + processContext.setRendererProcess(42, { id: 'renderer-uuid', name: undefined }); + registerProcessContext(processContext, hooks); + + const result = hooks.triggerRum({ + eventType: 'error', + startTime: 0 as TimeStamp, + source: EventSource.RENDERER, + webContentsId: 42, + }); + expect(result).toMatchObject({ process: { id: 'renderer-uuid', role: 'renderer' } }); + }); + + it('skips renderer events when webContentsId is unknown', () => { + const hooks = createFormatHooks(); + const processContext = new ProcessContext({ id: 'main-uuid', name: undefined }); + registerProcessContext(processContext, hooks); + + const result = hooks.triggerRum({ + eventType: 'error', + startTime: 0 as TimeStamp, + source: EventSource.RENDERER, + webContentsId: 99, + }); + // SKIPPED means result has no process field + expect((result as Record | null)?.process).toBeUndefined(); + }); +}); diff --git a/src/assembly/commonContext.ts b/src/assembly/commonContext.ts index b6c627bc..cb7c5b8e 100644 --- a/src/assembly/commonContext.ts +++ b/src/assembly/commonContext.ts @@ -2,6 +2,8 @@ import type { Configuration } from '../config'; import { EventSource } from '../event'; import type { FormatHooks } from './hooks'; import { display } from '../tools/display'; +import { SKIPPED } from '@datadog/js-core/assembly'; +import { ProcessContext } from '../domain/rum/process'; /** * Define the common attributes for the events of each format @@ -85,3 +87,18 @@ function hasForbiddenTagCharacters(tag: string): boolean { // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape#unicode_property_escapes_vs._character_classes return /[^\p{Ll}\p{Lo}0-9_:./-]/u.test(tag); } + +export function registerProcessContext(processContext: ProcessContext, hooks: FormatHooks) { + hooks.registerRum(({ source, webContentsId }) => { + if (source === EventSource.MAIN) { + const ctx = processContext.getMainProcessContext(); + return { process: { id: ctx.id, role: ctx.role, name: ctx.name } }; + } + if (source === EventSource.RENDERER && webContentsId !== undefined) { + const ctx = processContext.getRendererProcessContext(webContentsId); + if (ctx === undefined) return SKIPPED; + return { process: { id: ctx.id, role: ctx.role, name: ctx.name } }; + } + return SKIPPED; + }); +} diff --git a/src/assembly/hooks.ts b/src/assembly/hooks.ts index c2717774..b82d0c1e 100644 --- a/src/assembly/hooks.ts +++ b/src/assembly/hooks.ts @@ -9,9 +9,10 @@ import { EventSource } from '../event'; export type RumEventType = RumEvent['type']; export interface RumAssembleParams { - eventType: RumEventType; + eventType: string; startTime: TimeStamp; source: EventSource; + webContentsId?: number; } export interface TelemetryAssembleParams { diff --git a/src/assembly/index.ts b/src/assembly/index.ts index 0e388734..d0ff8143 100644 --- a/src/assembly/index.ts +++ b/src/assembly/index.ts @@ -1,7 +1,7 @@ export { MainAssembly } from './MainAssembly'; export { RendererPipeline } from './RendererPipeline'; export type { BridgeOptions } from '../common'; -export { registerCommonContext } from './commonContext'; +export { registerCommonContext, registerProcessContext } from './commonContext'; export { createFormatHooks } from './hooks'; export type { FormatHooks, diff --git a/src/domain/profiling/ProfilingCollection.ts b/src/domain/profiling/ProfilingCollection.ts index c451a2a4..b9a58d9a 100644 --- a/src/domain/profiling/ProfilingCollection.ts +++ b/src/domain/profiling/ProfilingCollection.ts @@ -35,7 +35,7 @@ export class ProfilingCollection { // quota_ko, mirroring the browser SDK) and suppresses the context for sessions it sampled out. See the // Profiling / Error Reporting notes in docs/ARCHITECTURE.md. hooks.registerRum(({ source, eventType, startTime }) => { - if (source !== EventSource.RENDERER || !PROFILING_EVENT_TYPES.includes(eventType)) { + if (source !== EventSource.RENDERER || !PROFILING_EVENT_TYPES.includes(eventType as RumEventType)) { return SKIPPED; } // Resolve the session that produced the event from its start time (as SessionContext does for diff --git a/src/domain/profiling/profilingEvent.types.ts b/src/domain/profiling/profilingEvent.types.ts index fbf791e7..74ec56a8 100644 --- a/src/domain/profiling/profilingEvent.types.ts +++ b/src/domain/profiling/profilingEvent.types.ts @@ -148,6 +148,10 @@ export interface BrowserProfilerTrace { * An array of profiler resources. */ readonly resources: string[]; + /** + * Mapping of profiler resources to their debug IDs for source map deobfuscation. + */ + readonly debugIds?: ResourceDebugId[]; /** * An array of profiler frames. */ @@ -185,6 +189,20 @@ export interface BrowserProfilerTrace { readonly views: RumViewEntry[]; [k: string]: unknown; } +/** + * Association between a profiler resource and its debug ID. + */ +export interface ResourceDebugId { + /** + * Index in the trace.resources array. + */ + readonly resourceId: number; + /** + * Debug ID (UUID) for the resource, used for source map deobfuscation. + */ + readonly debugId: string; + [k: string]: unknown; +} /** * Schema of a profiler frame from the JS Self-Profiling API. */ diff --git a/src/domain/rum/RumCollection.ts b/src/domain/rum/RumCollection.ts index 2406f5be..f4bfdf6b 100644 --- a/src/domain/rum/RumCollection.ts +++ b/src/domain/rum/RumCollection.ts @@ -4,6 +4,7 @@ import { ErrorCollection, CrashCollection } from './error'; import { VitalCollection } from './vital'; import { OperationCollection } from './operation'; import { ViewCollection } from './view'; +import { SessionManager } from '../session'; export class RumCollection { private constructor( @@ -13,8 +14,12 @@ export class RumCollection { private readonly operationCollection: OperationCollection ) {} - static async start(eventManager: EventManager, hooks: FormatHooks): Promise { - const viewCollection = await ViewCollection.start(eventManager, hooks); + static async start( + eventManager: EventManager, + hooks: FormatHooks, + sessionManager: SessionManager + ): Promise { + const viewCollection = await ViewCollection.start(eventManager, hooks, sessionManager); const errorCollection = new ErrorCollection(eventManager); const vitalCollection = new VitalCollection(eventManager); const operationCollection = new OperationCollection(eventManager); diff --git a/src/domain/rum/process/ProcessCollection.spec.ts b/src/domain/rum/process/ProcessCollection.spec.ts new file mode 100644 index 00000000..166d2be9 --- /dev/null +++ b/src/domain/rum/process/ProcessCollection.spec.ts @@ -0,0 +1,134 @@ +vi.mock('electron', () => ({ + app: { + on: vi.fn(), + getAppMetrics: vi.fn(() => []), + }, +})); + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { app } from 'electron'; +import { ProcessCollection, PROCESS_UPDATE_INTERVAL } from './ProcessCollection'; +import { EventManager, EventKind, EventFormat, LifecycleKind, type RawRumEvent } from '../../../event'; +import { RawRumProcess } from '../rawRumData.types'; + +describe('ProcessCollection', () => { + let eventManager: EventManager; + let rawRumEvents: RawRumEvent[]; + let processCollection: ProcessCollection; + let webContentsCreatedHandler: (event: unknown, webContents: unknown) => void; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + eventManager = new EventManager(); + rawRumEvents = []; + eventManager.registerHandler({ + canHandle: (e): e is RawRumEvent => e.kind === EventKind.RAW && e.format === EventFormat.RUM, + handle: (e) => rawRumEvents.push(e), + }); + + // Capture the web-contents-created handler registered by ProcessCollection + vi.mocked(app).on.mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'web-contents-created') { + webContentsCreatedHandler = handler; + } + return app; + }); + + processCollection = ProcessCollection.start(eventManager); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('main process', () => { + it('emits a start event on init', () => { + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumProcess; + expect(data.type).toBe('process'); + expect(data.process.role).toBe('main'); + expect(data.process.pid).toBe(process.pid); + expect(data._dd.document_version).toBe(1); + expect(data.process.duration).toBeUndefined(); + }); + + it('emits a periodic update every minute with incremented document_version', () => { + vi.advanceTimersByTime(PROCESS_UPDATE_INTERVAL); + expect(rawRumEvents).toHaveLength(2); + const update = rawRumEvents[1].data as RawRumProcess; + expect(update._dd.document_version).toBe(2); + expect(update.process.duration).toBeGreaterThanOrEqual(0); + }); + + it('emits a final update with is_active false on SESSION_EXPIRED', () => { + eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_EXPIRED }); + const last = rawRumEvents[rawRumEvents.length - 1].data as RawRumProcess; + expect(last.process.exit_reason).toBeUndefined(); + }); + + it('stops emitting updates after SESSION_EXPIRED', () => { + eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_EXPIRED }); + const countAfterExpiry = rawRumEvents.length; + vi.advanceTimersByTime(PROCESS_UPDATE_INTERVAL * 3); + expect(rawRumEvents).toHaveLength(countAfterExpiry); // no new events from timer + }); + }); + + describe('renderer processes', () => { + function makeWebContents(id: number) { + const listeners: Record void> = {}; + return { + id, + getProcessId: vi.fn(() => 1000 + id), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners[event] = handler; + }), + _emit: (event: string, ...args: unknown[]) => listeners[event]?.(...args), + }; + } + + it('emits a start event when web-contents-created fires', () => { + const wc = makeWebContents(1); + webContentsCreatedHandler({}, wc); + expect(rawRumEvents).toHaveLength(2); // main start + renderer start + const rendererStart = rawRumEvents[1].data as RawRumProcess; + expect(rendererStart.process.role).toBe('renderer'); + expect(rendererStart.process.pid).toBe(1001); + expect(rendererStart._dd.document_version).toBe(1); + }); + + it('registers the renderer in ProcessContext', () => { + const wc = makeWebContents(1); + webContentsCreatedHandler({}, wc); + const ctx = processCollection.processContext.getRendererProcessContext(1); + expect(ctx).toBeDefined(); + expect(ctx?.role).toBe('renderer'); + }); + + it('emits an end event on webContents destroyed', () => { + const wc = makeWebContents(2); + webContentsCreatedHandler({}, wc); + wc._emit('destroyed'); + const last = rawRumEvents[rawRumEvents.length - 1].data as RawRumProcess; + expect(last.process.exit_reason).toBeUndefined(); + expect(processCollection.processContext.getRendererProcessContext(2)).toBeUndefined(); + }); + + it('emits an end event with exit_reason on render-process-gone', () => { + const wc = makeWebContents(3); + webContentsCreatedHandler({}, wc); + wc._emit('render-process-gone', {}, { reason: 'crashed' }); + const last = rawRumEvents[rawRumEvents.length - 1].data as RawRumProcess; + expect(last.process.exit_reason).toBe('crashed'); + }); + + it('removes renderer from ProcessContext after end', () => { + const wc = makeWebContents(4); + webContentsCreatedHandler({}, wc); + wc._emit('destroyed'); + expect(processCollection.processContext.getRendererProcessContext(4)).toBeUndefined(); + }); + }); +}); diff --git a/src/domain/rum/process/ProcessCollection.ts b/src/domain/rum/process/ProcessCollection.ts new file mode 100644 index 00000000..9fd0933d --- /dev/null +++ b/src/domain/rum/process/ProcessCollection.ts @@ -0,0 +1,164 @@ +import { app } from 'electron'; +import { elapsed, ONE_MINUTE, timeStampNow, toServerDuration, type TimeStamp } from '@datadog/js-core/time'; +import { generateUUID } from '@datadog/browser-core'; +import { EventFormat, EventKind, type EventManager, type LifecycleEvent, LifecycleKind } from '../../../event'; +import { setInterval } from '../../telemetry'; +import { ProcessContext } from './ProcessContext'; +import type { RawRumProcess } from '../rawRumData.types'; + +export const PROCESS_UPDATE_INTERVAL = ONE_MINUTE; + +interface ProcessState { + id: string; + startTime: TimeStamp; + documentVersion: number; + pid: number; + name?: string; + timerId: ReturnType; +} + +export class ProcessCollection { + readonly processContext: ProcessContext; + private mainState!: ProcessState; + private readonly rendererStates = new Map(); + + private constructor(private readonly eventManager: EventManager) { + const mainId = generateUUID(); + this.processContext = new ProcessContext({ id: mainId, name: undefined }); + } + + static start(eventManager: EventManager): ProcessCollection { + const collection = new ProcessCollection(eventManager); + collection.initMain(); + collection.initRendererTracking(); + return collection; + } + + private initMain(): void { + const mainId = this.processContext.getMainProcessContext().id; + const startTime = timeStampNow(); + const timerId = setInterval(() => { + this.mainState.documentVersion++; + this.emitProcessEvent({ + id: mainId, + role: 'main', + pid: process.pid, + name: undefined, + startTime: this.mainState.startTime, + documentVersion: this.mainState.documentVersion, + }); + }, PROCESS_UPDATE_INTERVAL); + + this.mainState = { id: mainId, startTime, documentVersion: 1, pid: process.pid, name: undefined, timerId }; + + this.emitProcessEvent({ + id: mainId, + role: 'main', + pid: process.pid, + name: undefined, + startTime, + documentVersion: 1, + }); + + this.eventManager.registerHandler({ + canHandle: (event): event is LifecycleEvent => event.kind === EventKind.LIFECYCLE, + handle: (event) => { + if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) { + clearInterval(this.mainState.timerId); + this.mainState.documentVersion++; + this.emitProcessEvent({ + id: this.mainState.id, + role: 'main', + pid: this.mainState.pid, + name: undefined, + startTime: this.mainState.startTime, + documentVersion: this.mainState.documentVersion, + }); + } + }, + }); + } + + private initRendererTracking(): void { + app.on('web-contents-created', (_event, webContents) => { + const webContentsId = webContents.id; + const pid = webContents.getProcessId(); + const id = generateUUID(); + const startTime = timeStampNow(); + + this.processContext.setRendererProcess(webContentsId, { id, name: undefined }); + + const timerId = setInterval(() => { + const state = this.rendererStates.get(webContentsId); + if (!state) return; + state.documentVersion++; + this.emitProcessEvent({ + id, + role: 'renderer', + pid, + name: undefined, + startTime: state.startTime, + documentVersion: state.documentVersion, + }); + }, PROCESS_UPDATE_INTERVAL); + + const state: ProcessState = { id, startTime, documentVersion: 1, pid, name: undefined, timerId }; + this.rendererStates.set(webContentsId, state); + + this.emitProcessEvent({ id, role: 'renderer', pid, name: undefined, startTime, documentVersion: 1 }); + + const endRenderer = (exitReason?: string) => { + const s = this.rendererStates.get(webContentsId); + if (!s) return; + clearInterval(s.timerId); + s.documentVersion++; + this.emitProcessEvent({ + id, + role: 'renderer', + pid, + name: undefined, + startTime: s.startTime, + documentVersion: s.documentVersion, + exitReason, + }); + this.rendererStates.delete(webContentsId); + this.processContext.deleteRendererProcess(webContentsId); + }; + + webContents.on('destroyed', () => endRenderer(undefined)); + webContents.on('render-process-gone', (_e, details) => endRenderer(details.reason)); + }); + } + + private emitProcessEvent(params: { + id: string; + role: 'main' | 'renderer'; + pid: number; + name?: string; + startTime: TimeStamp; + documentVersion: number; + exitReason?: string; + }): void { + const isStart = params.documentVersion === 1; + const data: RawRumProcess = { + type: 'process', + date: params.startTime, + process: { + id: params.id, + role: params.role, + pid: params.pid, + name: params.name, + ...(!isStart && { duration: toServerDuration(elapsed(params.startTime, timeStampNow())) }), + ...(params.exitReason !== undefined && { exit_reason: params.exitReason }), + }, + _dd: { document_version: params.documentVersion }, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + format: EventFormat.RUM, + data, + startTime: params.startTime, + }); + } +} diff --git a/src/domain/rum/process/ProcessContext.spec.ts b/src/domain/rum/process/ProcessContext.spec.ts new file mode 100644 index 00000000..58897d01 --- /dev/null +++ b/src/domain/rum/process/ProcessContext.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ProcessContext } from './ProcessContext'; + +describe('ProcessContext', () => { + let ctx: ProcessContext; + + beforeEach(() => { + ctx = new ProcessContext({ id: 'main-id', name: undefined }); + }); + + describe('getMainProcessContext', () => { + it('returns main process context with role main', () => { + expect(ctx.getMainProcessContext()).toEqual({ id: 'main-id', role: 'main', name: undefined }); + }); + }); + + describe('getRendererProcessContext', () => { + it('returns undefined when no renderer registered for webContentsId', () => { + expect(ctx.getRendererProcessContext(1)).toBeUndefined(); + }); + + it('returns renderer context after setRendererProcess', () => { + ctx.setRendererProcess(42, { id: 'renderer-uuid', name: undefined }); + expect(ctx.getRendererProcessContext(42)).toEqual({ + id: 'renderer-uuid', + role: 'renderer', + name: undefined, + }); + }); + + it('returns undefined after deleteRendererProcess', () => { + ctx.setRendererProcess(42, { id: 'renderer-uuid', name: undefined }); + ctx.deleteRendererProcess(42); + expect(ctx.getRendererProcessContext(42)).toBeUndefined(); + }); + }); +}); diff --git a/src/domain/rum/process/ProcessContext.ts b/src/domain/rum/process/ProcessContext.ts new file mode 100644 index 00000000..a9a30669 --- /dev/null +++ b/src/domain/rum/process/ProcessContext.ts @@ -0,0 +1,37 @@ +export interface ProcessInfo { + id: string; + name?: string; +} + +export interface ProcessContextEntry { + id: string; + role: 'main' | 'renderer'; + name?: string; +} + +export class ProcessContext { + private readonly mainInfo: ProcessInfo; + private readonly renderers = new Map(); + + constructor(mainInfo: ProcessInfo) { + this.mainInfo = mainInfo; + } + + getMainProcessContext(): ProcessContextEntry { + return { id: this.mainInfo.id, role: 'main', name: this.mainInfo.name }; + } + + getRendererProcessContext(webContentsId: number): ProcessContextEntry | undefined { + const info = this.renderers.get(webContentsId); + if (info === undefined) return undefined; + return { id: info.id, role: 'renderer', name: info.name }; + } + + setRendererProcess(webContentsId: number, state: ProcessInfo): void { + this.renderers.set(webContentsId, state); + } + + deleteRendererProcess(webContentsId: number): void { + this.renderers.delete(webContentsId); + } +} diff --git a/src/domain/rum/process/index.ts b/src/domain/rum/process/index.ts new file mode 100644 index 00000000..a6d18c3e --- /dev/null +++ b/src/domain/rum/process/index.ts @@ -0,0 +1,3 @@ +export { ProcessContext } from './ProcessContext'; +export type { ProcessContextEntry, ProcessInfo } from './ProcessContext'; +export { ProcessCollection, PROCESS_UPDATE_INTERVAL } from './ProcessCollection'; diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index 8a9ee918..77b78996 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -8,7 +8,13 @@ import { RumVitalOperationStepEvent, } from './rumEvent.types'; -export type RawRumData = RawRumView | RawRumError | RawRumOperationStepVital | RawRumDurationVital | RawRumResource; +export type RawRumData = + | RawRumView + | RawRumError + | RawRumOperationStepVital + | RawRumDurationVital + | RawRumResource + | RawRumProcess; export interface RawRumView extends RecursivePartial { type: 'view'; @@ -16,9 +22,10 @@ export interface RawRumView extends RecursivePartial { id: string; time_spent: ServerDuration; is_active: boolean; - action: { count: number }; - error: { count: number }; - resource: { count: number }; + // Required by the view schema; aggregation is owned by the backend reducer + action: { count: 0 }; + error: { count: 0 }; + resource: { count: 0 }; }; _dd: { document_version: number }; } @@ -108,3 +115,17 @@ export interface RawRumResource extends RecursivePartial { format_version: 2; }; } + +export interface RawRumProcess { + type: 'process'; + date: TimeStamp; + process: { + id: string; + role: 'main' | 'renderer'; + pid: number; + name?: string; + duration?: ServerDuration; + exit_reason?: string; + }; + _dd: { document_version: number }; +} diff --git a/src/domain/rum/rumEvent.types.ts b/src/domain/rum/rumEvent.types.ts index c7cc58e4..9359ed39 100644 --- a/src/domain/rum/rumEvent.types.ts +++ b/src/domain/rum/rumEvent.types.ts @@ -1156,6 +1156,10 @@ export interface CommonProperties { * User defined name of the view */ name?: string; + /** + * Whether this view was synthetically created to carry view-less events + */ + readonly is_fake?: boolean; [k: string]: unknown; }; /** @@ -1455,6 +1459,24 @@ export interface CommonProperties { readonly id: string; [k: string]: unknown; }; + /** + * Process properties + */ + readonly process?: { + /** + * UUID of the process + */ + readonly id?: string; + /** + * Role of the process + */ + readonly role?: 'main' | 'renderer' | 'utility'; + /** + * Process name + */ + readonly name?: string; + [k: string]: unknown; + }; [k: string]: unknown; } /** diff --git a/src/domain/rum/view/ViewCollection.spec.ts b/src/domain/rum/view/ViewCollection.spec.ts index 9fba4fa2..76829a64 100644 --- a/src/domain/rum/view/ViewCollection.spec.ts +++ b/src/domain/rum/view/ViewCollection.spec.ts @@ -12,18 +12,10 @@ vi.mock('../../../tools/display', () => ({ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { TimeStamp } from '@datadog/js-core/time'; -import { ViewCollection, SESSION_KEEP_ALIVE_INTERVAL, VIEW_UPDATE_THROTTLE_DELAY } from './ViewCollection'; -import { - EventManager, - EventKind, - EventFormat, - EventSource, - EventTrack, - LifecycleKind, - type RawRumEvent, -} from '../../../event'; +import { ViewCollection, SESSION_KEEP_ALIVE_INTERVAL } from './ViewCollection'; +import { EventManager, EventKind, EventFormat, EventSource, LifecycleKind, type RawRumEvent } from '../../../event'; import { createFormatHooks, type FormatHooks } from '../../../assembly'; -import { createServerRumEvent, createServerRumView } from '../../../mocks.specUtil'; +import { SessionManager } from '../../session'; import { RawRumView } from '../rawRumData.types'; vi.mock('node:fs/promises'); @@ -37,6 +29,7 @@ describe('ViewCollection', () => { let hooks: FormatHooks; let viewCollection: ViewCollection; let rawRumEvents: RawRumEvent[]; + let mockSessionManager: { getSession: ReturnType }; beforeEach(async () => { vi.useFakeTimers(); @@ -52,7 +45,8 @@ describe('ViewCollection', () => { handle: (event) => rawRumEvents.push(event), }); - viewCollection = await ViewCollection.start(eventManager, hooks); + mockSessionManager = { getSession: vi.fn().mockReturnValue({ id: 'session-id-1', status: 'active' }) }; + viewCollection = await ViewCollection.start(eventManager, hooks, mockSessionManager as unknown as SessionManager); }); afterEach(() => { @@ -70,9 +64,11 @@ describe('ViewCollection', () => { expect(data.date).toBe(0); expect(data._dd.document_version).toBe(1); expect(data.view.is_active).toBe(true); - expect(data.view.action.count).toBe(0); - expect(data.view.error.count).toBe(0); - expect(data.view.resource.count).toBe(0); + }); + + it('uses session.id as view.id', () => { + const data = rawRumEvents[0].data as RawRumView; + expect(data.view.id).toBe('session-id-1'); }); it('sets date to the view start time, not the update time', () => { @@ -85,19 +81,17 @@ describe('ViewCollection', () => { describe('hook registration', () => { it('injects view attributes into RUM hooks', () => { - const initialViewAttributes = (rawRumEvents[0].data as RawRumView).view; const result = hooks.triggerRum({ eventType: 'view', startTime: T0, source: EventSource.MAIN }); expect(result).toMatchObject({ - view: { id: initialViewAttributes.id }, + view: { id: 'session-id-1' }, }); }); it('injects view attributes into telemetry hooks', () => { - const initialView = (rawRumEvents[0].data as RawRumView).view; const result = hooks.triggerTelemetry({ startTime: T0, source: EventSource.MAIN }); - expect(result).toEqual({ view: { id: initialView.id } }); + expect(result).toEqual({ view: { id: 'session-id-1' } }); }); }); @@ -133,54 +127,44 @@ describe('ViewCollection', () => { }); describe('session renew', () => { - it('creates a new view with reset state', () => { - const originalViewId = (rawRumEvents[0].data as RawRumView).view.id; - + it('creates a new view with reset state using new session.id', () => { + mockSessionManager.getSession.mockReturnValue({ id: 'session-id-2', status: 'active' }); eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_RENEW }); expect(rawRumEvents).toHaveLength(2); const data = rawRumEvents[1].data as RawRumView; - expect(data.view.id).not.toBe(originalViewId); + expect(data.view.id).toBe('session-id-2'); expect(data.view.is_active).toBe(true); expect(data._dd.document_version).toBe(1); - expect(data.view.action.count).toBe(0); - expect(data.view.error.count).toBe(0); - expect(data.view.resource.count).toBe(0); }); - it('updates view.id in hooks', () => { - const originalViewId = (rawRumEvents[0].data as RawRumView).view.id; - + it('updates view.id in hooks to new session.id', () => { + mockSessionManager.getSession.mockReturnValue({ id: 'session-id-2', status: 'active' }); eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_RENEW }); const result = hooks.triggerRum({ eventType: 'view', startTime: T0, source: EventSource.MAIN }); - const newViewId = (rawRumEvents[1].data as RawRumView).view.id; - expect(result).toMatchObject({ view: { id: newViewId } }); - expect(newViewId).not.toBe(originalViewId); + expect(result).toMatchObject({ view: { id: 'session-id-2' } }); }); it('attributes events with old startTime to the previous view', () => { - const originalViewId = (rawRumEvents[0].data as RawRumView).view.id; - vi.advanceTimersByTime(10); // move to T10 eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_EXPIRED }); + mockSessionManager.getSession.mockReturnValue({ id: 'session-id-2', status: 'active' }); eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_RENEW }); - const newViewId = (rawRumEvents[rawRumEvents.length - 1].data as RawRumView).view.id; - expect(newViewId).not.toBe(originalViewId); - // event started at T0 (before renewal at T10) → attributed to original view expect(hooks.triggerRum({ eventType: 'view', startTime: T0, source: EventSource.MAIN })).toMatchObject({ - view: { id: originalViewId }, + view: { id: 'session-id-1' }, }); // event started at T10 → attributed to new view expect(hooks.triggerRum({ eventType: 'view', startTime: T10, source: EventSource.MAIN })).toMatchObject({ - view: { id: newViewId }, + view: { id: 'session-id-2' }, }); }); it('restarts periodic updates', () => { eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_EXPIRED }); + mockSessionManager.getSession.mockReturnValue({ id: 'session-id-2', status: 'active' }); eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_RENEW }); vi.advanceTimersByTime(SESSION_KEEP_ALIVE_INTERVAL); @@ -190,49 +174,6 @@ describe('ViewCollection', () => { }); }); - describe('event counters', () => { - it.each(['action', 'error', 'resource'] as const)( - 'increments %s counter on corresponding ServerRumEvent', - (type) => { - eventManager.notify({ - kind: EventKind.SERVER, - track: EventTrack.RUM, - source: EventSource.MAIN, - data: createServerRumEvent(type), - }); - - expect(rawRumEvents).toHaveLength(2); - const data = rawRumEvents[1].data as RawRumView; - expect(data.view[type].count).toBe(1); - expect(data._dd.document_version).toBe(2); - } - ); - - it('does not count view type ServerEvents', () => { - eventManager.notify({ - kind: EventKind.SERVER, - track: EventTrack.RUM, - source: EventSource.MAIN, - data: createServerRumView(), - }); - - // Only the initial event, no update - expect(rawRumEvents).toHaveLength(1); - }); - - it('does not count renderer events', () => { - eventManager.notify({ - kind: EventKind.SERVER, - track: EventTrack.RUM, - source: EventSource.RENDERER, - data: createServerRumEvent('error'), - }); - - // Only the initial event, no update - expect(rawRumEvents).toHaveLength(1); - }); - }); - describe('stop', () => { it('clears periodic timer and unsubscribes lifecycle handlers', () => { viewCollection.stop(); @@ -244,87 +185,4 @@ describe('ViewCollection', () => { expect(rawRumEvents).toHaveLength(1); }); }); - - describe('throttled view updates', () => { - function notifyServerRumEvent(type: 'action' | 'error' | 'resource') { - eventManager.notify({ - kind: EventKind.SERVER, - track: EventTrack.RUM, - source: EventSource.MAIN, - data: createServerRumEvent(type), - }); - } - - it('collapses a burst into a leading and a trailing update', () => { - notifyServerRumEvent('resource'); - notifyServerRumEvent('resource'); - notifyServerRumEvent('resource'); - - // initial + leading only, no intermediate updates - expect(rawRumEvents).toHaveLength(2); - - vi.advanceTimersByTime(VIEW_UPDATE_THROTTLE_DELAY); - - // trailing fires with final accumulated state - expect(rawRumEvents).toHaveLength(3); - }); - - it('trailing update contains final accumulated counters and document_version', () => { - notifyServerRumEvent('resource'); - notifyServerRumEvent('error'); - notifyServerRumEvent('action'); - - vi.advanceTimersByTime(VIEW_UPDATE_THROTTLE_DELAY); - - const trailing = rawRumEvents[rawRumEvents.length - 1].data as RawRumView; - expect(trailing.view.resource.count).toBe(1); - expect(trailing.view.error.count).toBe(1); - expect(trailing.view.action.count).toBe(1); - // initial=1, leading=2 (first resource), trailing=4 (after error+action increments) - expect(trailing._dd.document_version).toBe(4); - }); - - it('session expired cancels pending trailing update', () => { - notifyServerRumEvent('resource'); - notifyServerRumEvent('resource'); - - // initial + leading - expect(rawRumEvents).toHaveLength(2); - - eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_EXPIRED }); - - vi.advanceTimersByTime(VIEW_UPDATE_THROTTLE_DELAY); - - // initial + leading + expired final — no stale trailing - expect(rawRumEvents).toHaveLength(3); - expect((rawRumEvents[2].data as RawRumView).view.is_active).toBe(false); - }); - - it('session renew cancels pending trailing update', () => { - const originalViewId = (rawRumEvents[0].data as RawRumView).view.id; - - notifyServerRumEvent('resource'); - notifyServerRumEvent('resource'); - - eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_RENEW }); - - vi.advanceTimersByTime(VIEW_UPDATE_THROTTLE_DELAY); - - // initial + leading + renew initial — no old-view trailing - expect(rawRumEvents).toHaveLength(3); - expect((rawRumEvents[2].data as RawRumView).view.id).not.toBe(originalViewId); - }); - - it('stop cancels pending trailing update', () => { - notifyServerRumEvent('resource'); - notifyServerRumEvent('resource'); - - viewCollection.stop(); - - vi.advanceTimersByTime(VIEW_UPDATE_THROTTLE_DELAY); - - // initial + leading — no trailing after stop - expect(rawRumEvents).toHaveLength(2); - }); - }); }); diff --git a/src/domain/rum/view/ViewCollection.ts b/src/domain/rum/view/ViewCollection.ts index c03ee7b8..06fb9343 100644 --- a/src/domain/rum/view/ViewCollection.ts +++ b/src/domain/rum/view/ViewCollection.ts @@ -1,65 +1,54 @@ -import { elapsed, ONE_MINUTE, ONE_SECOND, timeStampNow, toServerDuration, TimeStamp } from '@datadog/js-core/time'; -import { generateUUID, Subscription } from '@datadog/browser-core'; -import { - EventFormat, - EventKind, - EventManager, - EventSource, - EventTrack, - type LifecycleEvent, - LifecycleKind, - ServerRumEvent, -} from '../../../event'; +import { elapsed, ONE_MINUTE, timeStampNow, toServerDuration, TimeStamp } from '@datadog/js-core/time'; +import { Subscription } from '@datadog/browser-core'; +import { EventFormat, EventKind, EventManager, type LifecycleEvent, LifecycleKind } from '../../../event'; import type { FormatHooks } from '../../../assembly'; -import { setInterval, throttle } from '../../telemetry'; +import { setInterval } from '../../telemetry'; import type { RawRumView } from '../rawRumData.types'; import { ViewContext } from './ViewContext'; +import { SessionManager } from '../../session'; export const SESSION_KEEP_ALIVE_INTERVAL = 5 * ONE_MINUTE; -// throttle view updates to avoid bursts -export const VIEW_UPDATE_THROTTLE_DELAY = 3 * ONE_SECOND; interface ViewState { id: string; startTime: TimeStamp; documentVersion: number; isActive: boolean; - counters: { action: { count: number }; error: { count: number }; resource: { count: number } }; } /** - * Track the main view lifecycle + * Track the fake main-process view lifecycle. + * - view.id == session.id (fake view, not a real renderer view) * - on creation, emit an initial view event - * - keep session alive by regularly send view updates + * - keep session alive by regularly sending view updates + * // TODO: challenge whether keep-alive is still needed once the backend + * // uses process heartbeats for session liveness * - on SESSION_EXPIRED, emit a final inactive view update - * - on SESSION_RENEW, create a new view - * - on RUM server event (action, error, resource), increment view counters (throttled) + * - on SESSION_RENEW, create a new view with the new session.id */ export class ViewCollection { private currentView!: ViewState; private viewContext!: ViewContext; private keepAliveIntervalId: ReturnType | undefined; - private scheduleViewUpdate!: () => void; - private cancelScheduledViewUpdate!: () => void; private lifecycleSubscription!: Subscription; - private serverEventSubscription!: Subscription; constructor( private readonly eventManager: EventManager, - private readonly hooks: FormatHooks + private readonly hooks: FormatHooks, + private readonly sessionManager: SessionManager ) {} - static async start(eventManager: EventManager, hooks: FormatHooks): Promise { - const collection = new ViewCollection(eventManager, hooks); + static async start( + eventManager: EventManager, + hooks: FormatHooks, + sessionManager: SessionManager + ): Promise { + const collection = new ViewCollection(eventManager, hooks, sessionManager); await collection.init(); return collection; } private async init(): Promise { - const { throttled, cancel } = throttle(() => this.emitViewUpdate(), VIEW_UPDATE_THROTTLE_DELAY); - this.scheduleViewUpdate = throttled; - this.cancelScheduledViewUpdate = cancel; - this.viewContext = await ViewContext.init(this.hooks); this.createNewView(); @@ -73,28 +62,20 @@ export class ViewCollection { } }, }); - - this.serverEventSubscription = this.eventManager.registerHandler({ - canHandle: (event): event is ServerRumEvent => event.kind === EventKind.SERVER && event.track === EventTrack.RUM, - handle: (event) => this.onServerRumEvent(event), - }); } stop(): void { - this.cancelScheduledViewUpdate(); this.stopSessionKeepAlive(); this.lifecycleSubscription.unsubscribe(); - this.serverEventSubscription.unsubscribe(); } private createNewView(): void { - const viewId = generateUUID(); + const viewId = this.sessionManager.getSession().id; this.currentView = { id: viewId, startTime: timeStampNow(), documentVersion: 1, isActive: true, - counters: { action: { count: 0 }, error: { count: 0 }, resource: { count: 0 } }, }; this.viewContext.close(); // close previous view if any (ensures non-overlapping history entries) @@ -111,7 +92,9 @@ export class ViewCollection { id: this.currentView.id, time_spent: toServerDuration(elapsed(this.currentView.startTime, timeStampNow())), is_active: this.currentView.isActive, - ...this.currentView.counters, + action: { count: 0 }, + error: { count: 0 }, + resource: { count: 0 }, }, _dd: { document_version: this.currentView.documentVersion }, }; @@ -125,7 +108,6 @@ export class ViewCollection { } private onSessionExpired(): void { - this.cancelScheduledViewUpdate(); this.stopSessionKeepAlive(); this.currentView.isActive = false; this.currentView.documentVersion++; @@ -134,23 +116,9 @@ export class ViewCollection { } private onSessionRenew(): void { - this.cancelScheduledViewUpdate(); this.createNewView(); } - private onServerRumEvent(event: ServerRumEvent): void { - if (event.source === EventSource.RENDERER) { - return; - } - - const type = event.data.type; - if (type === 'action' || type === 'error' || type === 'resource') { - this.currentView.counters[type].count++; - this.currentView.documentVersion++; - this.scheduleViewUpdate(); - } - } - private keepSessionAlive(): void { this.stopSessionKeepAlive(); this.keepAliveIntervalId = setInterval(() => { diff --git a/src/domain/rum/view/ViewContext.spec.ts b/src/domain/rum/view/ViewContext.spec.ts index 118bc652..8084c590 100644 --- a/src/domain/rum/view/ViewContext.spec.ts +++ b/src/domain/rum/view/ViewContext.spec.ts @@ -61,15 +61,15 @@ describe('ViewContext', () => { }); describe('after add()', () => { - it('RUM hook returns id, name, url for main source', async () => { + it('RUM hook returns id, url, is_fake for main source', async () => { const hooks = createFormatHooks(); const context = await ViewContext.init(hooks, EXPIRE_DELAY); context.add(VIEW_ID); - expect(hooks.triggerRum({ eventType: 'view', startTime: T0, source: EventSource.MAIN })).toMatchObject({ - view: { id: VIEW_ID, name: 'main process', url: 'electron://main-process' }, - }); + const result = hooks.triggerRum({ eventType: 'view', startTime: T0, source: EventSource.MAIN }); + expect(result).toMatchObject({ view: { id: VIEW_ID, url: 'electron://fake', is_fake: true } }); + expect(result).not.toHaveProperty('view.name'); }); it('RUM hook returns container.view.id for renderer source', async () => { diff --git a/src/domain/rum/view/ViewContext.ts b/src/domain/rum/view/ViewContext.ts index e836c80a..3a235edf 100644 --- a/src/domain/rum/view/ViewContext.ts +++ b/src/domain/rum/view/ViewContext.ts @@ -22,7 +22,7 @@ export class ViewContext { case EventSource.RENDERER: return { container: { view: { id } } }; case EventSource.MAIN: - return { view: { id, name: 'main process', url: 'electron://main-process' } }; // TODO(RUM-14657) improve name / url + return { view: { id, url: 'electron://fake', is_fake: true } }; } }); diff --git a/src/index.ts b/src/index.ts index 501cbd7e..30a19e25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -import { MainAssembly, RendererPipeline, createFormatHooks, registerCommonContext } from './assembly'; +import { + MainAssembly, + RendererPipeline, + createFormatHooks, + registerCommonContext, + registerProcessContext, +} from './assembly'; import { setDurationVitalApi } from './api'; import type { AccountInfo, UserInfo } from './domain/customer-context'; import { AccountContext, UserContext } from './domain/customer-context'; @@ -6,6 +12,7 @@ import type { InitConfiguration } from './config'; import { buildConfiguration } from './config'; import type { ErrorOptions, FailureReason, FeatureOperationOptions } from './domain/rum'; import { RumCollection } from './domain/rum'; +import { ProcessCollection } from './domain/rum/process'; import { SessionManager } from './domain/session'; import { callMonitored, startTelemetry } from './domain/telemetry'; import { SpanProcessor } from './domain/tracing/SpanProcessor'; @@ -68,10 +75,16 @@ export async function init(configuration: InitConfiguration): Promise { } transport = await Transport.create(config, eventManager); - const rum = await RumCollection.start(eventManager, hooks); + const rum = await RumCollection.start(eventManager, hooks, sessionManager); rumApi = rum.getApi(); setDurationVitalApi(rumApi); + // ProcessCollection must start after MainAssembly and RumCollection so all + // event handlers and format hooks (session, view) are registered before the + // first process event is emitted. + const processCollection = ProcessCollection.start(eventManager); + registerProcessContext(processCollection.processContext, hooks); + return true; } diff --git a/src/mocks.specUtil.ts b/src/mocks.specUtil.ts index abf1192d..1cd0bb7e 100644 --- a/src/mocks.specUtil.ts +++ b/src/mocks.specUtil.ts @@ -1,9 +1,9 @@ import { type MockInstance } from 'vitest'; import * as fs from 'node:fs/promises'; import type { Configuration } from './config'; -import { RawRumView, RumActionEvent, RumErrorEvent, RumEvent, RumResourceEvent, RumViewEvent } from './domain/rum'; +import { RawRumView } from './domain/rum'; import { type ServerDuration } from '@datadog/js-core/time'; -import { combine, mergeInto, type RecursivePartial } from '@datadog/js-core/util'; +import { mergeInto, type RecursivePartial } from '@datadog/js-core/util'; export function mockFs() { const mocks = { @@ -50,8 +50,6 @@ export function createRawRumView(overrides?: RecursivePartial): RawR type: 'view' as const, view: { id: '1', - name: 'name', - url: 'url', time_spent: 0 as ServerDuration, is_active: true, action: { count: 0 }, @@ -63,95 +61,3 @@ export function createRawRumView(overrides?: RecursivePartial): RawR overrides ); } - -export function createServerRumEvent(type: RumEvent['type'], overrides?: RecursivePartial): T { - if (type === 'view') { - return createServerRumView(overrides as RecursivePartial) as T; - } - if (type === 'resource') { - return createServerRumResource(overrides as RecursivePartial) as T; - } - if (type === 'error') { - return createServerRumError(overrides as RecursivePartial) as T; - } - if (type === 'action') { - return createServerRumAction(overrides as RecursivePartial) as T; - } - throw new Error(`Unhandled type: '${type}'`); -} - -const SERVER_EVENT_COMMON_CONTEXT = { - application: { - id: 'app-id', - }, - session: { - id: '2', - type: 'user', - }, - view: { - id: '1', - name: 'name', - url: 'url', - }, - _dd: { format_version: 2 }, -}; - -export function createServerRumView(overrides?: RecursivePartial): RumViewEvent { - return combine( - { - type: 'view' as const, - date: Date.now(), - view: { - time_spent: 0, - action: { count: 0 }, - error: { count: 0 }, - resource: { count: 0 }, - }, - _dd: { document_version: 1 }, - }, - SERVER_EVENT_COMMON_CONTEXT, - overrides - ) as RumViewEvent; -} - -export function createServerRumResource(overrides?: RecursivePartial): RumResourceEvent { - return combine( - { - type: 'resource' as const, - date: Date.now(), - resource: { - type: 'fetch', - url: 'url', - }, - }, - SERVER_EVENT_COMMON_CONTEXT, - overrides - ) as RumResourceEvent; -} -export function createServerRumError(overrides?: RecursivePartial): RumErrorEvent { - return combine( - { - type: 'error' as const, - date: Date.now(), - error: { - message: 'Oops', - source: 'source', - }, - }, - SERVER_EVENT_COMMON_CONTEXT, - overrides - ) as RumErrorEvent; -} -export function createServerRumAction(overrides?: RecursivePartial): RumActionEvent { - return combine( - { - type: 'action' as const, - date: Date.now(), - action: { - type: 'custom', - }, - }, - SERVER_EVENT_COMMON_CONTEXT, - overrides - ) as RumActionEvent; -}