From ae6c169e41afbd9804f5699d863f1f85f34ae1bc Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 13:45:45 +0200 Subject: [PATCH 01/13] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20switch=20rum-events-?= =?UTF-8?q?format=20submodule=20to=20process-event=20draft=20PR=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rum-events-format | 2 +- src/domain/profiling/profilingEvent.types.ts | 18 ++++++++++++++++ src/domain/rum/rumEvent.types.ts | 22 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) 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/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/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; } /** From 890b35c9969f79d60bb05786b70696d8c8afee34 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 13:49:30 +0200 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20add=20webContentsId=20to=20Ru?= =?UTF-8?q?mAssembleParams=20for=20renderer=20process=20enrichment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assembly/hooks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/assembly/hooks.ts b/src/assembly/hooks.ts index c2717774..fd6a8d4f 100644 --- a/src/assembly/hooks.ts +++ b/src/assembly/hooks.ts @@ -12,6 +12,7 @@ export interface RumAssembleParams { eventType: RumEventType; startTime: TimeStamp; source: EventSource; + webContentsId?: number; } export interface TelemetryAssembleParams { From 58874ac2ba4573cc4e34d6fdc51976aa39dcb122 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 13:58:26 +0200 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=A8=20add=20RawRumProcess=20type=20?= =?UTF-8?q?and=20simplify=20RawRumView=20(remove=20counters)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `RawRumProcess` to `rawRumData.types.ts` and extend `RawRumData` union - Remove `action/error/resource` counter fields from `RawRumView` and `createRawRumView` - Remove counter tracking from `ViewCollection` and update spec accordingly - Guard `MainAssembly` against non-RUM event types (`process`) being passed to `triggerRum` --- src/assembly/MainAssembly.ts | 19 ++++++++++++-- src/domain/rum/rawRumData.types.ts | 30 +++++++++++++++++++--- src/domain/rum/view/ViewCollection.spec.ts | 20 ++++----------- src/domain/rum/view/ViewCollection.ts | 7 +++-- src/mocks.specUtil.ts | 2 -- 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/assembly/MainAssembly.ts b/src/assembly/MainAssembly.ts index e9a866a1..effce1a3 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)) { const hookResult = this.hooks.triggerRum({ eventType: event.data.type, startTime, @@ -78,3 +78,18 @@ export class MainAssembly { function assembleData(rawData: unknown, hookResult: RecursivePartial | undefined): T { return (hookResult ? combine(hookResult, rawData) : rawData) as T; } + +const RUM_EVENT_TYPES = new Set([ + 'error', + 'view', + 'action', + 'long_task', + 'resource', + 'vital', + 'transition', + 'view_update', +]); + +function isRumEventType(type: string): type is RumEventType { + return RUM_EVENT_TYPES.has(type); +} diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index 8a9ee918..765d0cf7 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,18 @@ export interface RawRumResource extends RecursivePartial { format_version: 2; }; } + +export interface RawRumProcess { + type: 'process'; + date: TimeStamp; + process: { + id: string; + role: 'main' | 'renderer' | 'utility'; + pid: number; + ppid?: number; + name?: string; + duration?: ServerDuration; + exit_reason?: string; + }; + _dd: { document_version: number }; +} diff --git a/src/domain/rum/view/ViewCollection.spec.ts b/src/domain/rum/view/ViewCollection.spec.ts index 9fba4fa2..0eae6f28 100644 --- a/src/domain/rum/view/ViewCollection.spec.ts +++ b/src/domain/rum/view/ViewCollection.spec.ts @@ -70,9 +70,6 @@ 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('sets date to the view start time, not the update time', () => { @@ -143,9 +140,6 @@ describe('ViewCollection', () => { expect(data.view.id).not.toBe(originalViewId); 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', () => { @@ -190,9 +184,9 @@ describe('ViewCollection', () => { }); }); - describe('event counters', () => { + describe('server event handling', () => { it.each(['action', 'error', 'resource'] as const)( - 'increments %s counter on corresponding ServerRumEvent', + 'emits a view update with incremented document_version on %s ServerRumEvent', (type) => { eventManager.notify({ kind: EventKind.SERVER, @@ -203,12 +197,11 @@ describe('ViewCollection', () => { 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', () => { + it('does not emit a view update for view type ServerEvents', () => { eventManager.notify({ kind: EventKind.SERVER, track: EventTrack.RUM, @@ -220,7 +213,7 @@ describe('ViewCollection', () => { expect(rawRumEvents).toHaveLength(1); }); - it('does not count renderer events', () => { + it('does not emit a view update for renderer events', () => { eventManager.notify({ kind: EventKind.SERVER, track: EventTrack.RUM, @@ -269,7 +262,7 @@ describe('ViewCollection', () => { expect(rawRumEvents).toHaveLength(3); }); - it('trailing update contains final accumulated counters and document_version', () => { + it('trailing update contains final accumulated document_version', () => { notifyServerRumEvent('resource'); notifyServerRumEvent('error'); notifyServerRumEvent('action'); @@ -277,9 +270,6 @@ describe('ViewCollection', () => { 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); }); diff --git a/src/domain/rum/view/ViewCollection.ts b/src/domain/rum/view/ViewCollection.ts index c03ee7b8..aad50ea1 100644 --- a/src/domain/rum/view/ViewCollection.ts +++ b/src/domain/rum/view/ViewCollection.ts @@ -24,7 +24,6 @@ interface ViewState { startTime: TimeStamp; documentVersion: number; isActive: boolean; - counters: { action: { count: number }; error: { count: number }; resource: { count: number } }; } /** @@ -94,7 +93,6 @@ export class ViewCollection { 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 +109,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 }, }; @@ -145,7 +145,6 @@ export class ViewCollection { const type = event.data.type; if (type === 'action' || type === 'error' || type === 'resource') { - this.currentView.counters[type].count++; this.currentView.documentVersion++; this.scheduleViewUpdate(); } diff --git a/src/mocks.specUtil.ts b/src/mocks.specUtil.ts index abf1192d..d6a92068 100644 --- a/src/mocks.specUtil.ts +++ b/src/mocks.specUtil.ts @@ -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 }, From 44532164cb66c841e89b213939990f98bc8df7a1 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:04:01 +0200 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=91=8C=20fix=20stale=20JSDoc=20and?= =?UTF-8?q?=20make=20RUM=5FEVENT=5FTYPES=20exhaustive=20against=20RumEvent?= =?UTF-8?q?Type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ViewCollection: update stale comment to say 'increment document_version and schedule a throttled view update' - MainAssembly: derive RUM_EVENT_TYPES from a Record satisfies constraint so TypeScript enforces exhaustiveness and rejects values outside the union --- src/assembly/MainAssembly.ts | 28 ++++++++++++++++----------- src/domain/rum/view/ViewCollection.ts | 2 +- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/assembly/MainAssembly.ts b/src/assembly/MainAssembly.ts index effce1a3..125df756 100644 --- a/src/assembly/MainAssembly.ts +++ b/src/assembly/MainAssembly.ts @@ -79,17 +79,23 @@ function assembleData(rawData: unknown, hookResult: RecursivePartial | und return (hookResult ? combine(hookResult, rawData) : rawData) as T; } -const RUM_EVENT_TYPES = new Set([ - 'error', - 'view', - 'action', - 'long_task', - 'resource', - 'vital', - 'transition', - 'view_update', -]); +// 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); + return RUM_EVENT_TYPES.has(type as RumEventType); } diff --git a/src/domain/rum/view/ViewCollection.ts b/src/domain/rum/view/ViewCollection.ts index aad50ea1..6a2ba859 100644 --- a/src/domain/rum/view/ViewCollection.ts +++ b/src/domain/rum/view/ViewCollection.ts @@ -32,7 +32,7 @@ interface ViewState { * - keep session alive by regularly send view updates * - 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 RUM server event (action, error, resource), increment `document_version` and schedule a throttled view update */ export class ViewCollection { private currentView!: ViewState; From e28ff61ffbafe67ad20fc8918a7b245ed036318a Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:12:37 +0200 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8=20replace=20main-process=20view?= =?UTF-8?q?=20with=20fake=20view=20(session.id,=20electron://fake,=20is=5F?= =?UTF-8?q?fake)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ViewContext: url → electron://fake, add is_fake: true, drop name - ViewCollection: accept SessionManager, use session.id as view id, remove throttle and server-event counter logic - RumCollection + index.ts: thread sessionManager through to ViewCollection.start --- src/domain/rum/RumCollection.ts | 9 +- src/domain/rum/view/ViewCollection.spec.ts | 178 +++------------------ src/domain/rum/view/ViewCollection.ts | 71 +++----- src/domain/rum/view/ViewContext.spec.ts | 8 +- src/domain/rum/view/ViewContext.ts | 2 +- src/index.ts | 2 +- 6 files changed, 56 insertions(+), 214 deletions(-) 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/view/ViewCollection.spec.ts b/src/domain/rum/view/ViewCollection.spec.ts index 0eae6f28..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(() => { @@ -72,6 +66,11 @@ describe('ViewCollection', () => { expect(data.view.is_active).toBe(true); }); + 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', () => { vi.advanceTimersByTime(SESSION_KEEP_ALIVE_INTERVAL); @@ -82,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' } }); }); }); @@ -130,51 +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); }); - 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); @@ -184,48 +174,6 @@ describe('ViewCollection', () => { }); }); - describe('server event handling', () => { - it.each(['action', 'error', 'resource'] as const)( - 'emits a view update with incremented document_version on %s 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._dd.document_version).toBe(2); - } - ); - - it('does not emit a view update for 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 emit a view update for 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(); @@ -237,84 +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 document_version', () => { - notifyServerRumEvent('resource'); - notifyServerRumEvent('error'); - notifyServerRumEvent('action'); - - vi.advanceTimersByTime(VIEW_UPDATE_THROTTLE_DELAY); - - const trailing = rawRumEvents[rawRumEvents.length - 1].data as RawRumView; - // 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 6a2ba859..06fb9343 100644 --- a/src/domain/rum/view/ViewCollection.ts +++ b/src/domain/rum/view/ViewCollection.ts @@ -1,23 +1,13 @@ -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; @@ -27,38 +17,38 @@ interface ViewState { } /** - * 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 `document_version` and schedule a throttled view update + * - 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(); @@ -72,22 +62,15 @@ 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(), @@ -125,7 +108,6 @@ export class ViewCollection { } private onSessionExpired(): void { - this.cancelScheduledViewUpdate(); this.stopSessionKeepAlive(); this.currentView.isActive = false; this.currentView.documentVersion++; @@ -134,22 +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.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..824ffd5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,7 +68,7 @@ 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); From 8743813b26a94d08da79561b22b36b01b24b71c4 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:17:58 +0200 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9C=A8=20add=20ProcessContext=20for=20?= =?UTF-8?q?main=20and=20renderer=20process=20enrichment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/rum/process/ProcessContext.spec.ts | 37 +++++++++++++++++++ src/domain/rum/process/ProcessContext.ts | 37 +++++++++++++++++++ src/domain/rum/process/index.ts | 3 ++ 3 files changed, 77 insertions(+) create mode 100644 src/domain/rum/process/ProcessContext.spec.ts create mode 100644 src/domain/rum/process/ProcessContext.ts create mode 100644 src/domain/rum/process/index.ts 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..c6a11f37 --- /dev/null +++ b/src/domain/rum/process/index.ts @@ -0,0 +1,3 @@ +export { ProcessContext } from './ProcessContext'; +export type { ProcessContextEntry, ProcessInfo } from './ProcessContext'; +// ProcessCollection will be exported from here in Task 6 From ea023e2215fc05c946fb3f59d25cbc7ef2789082 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:28:12 +0200 Subject: [PATCH 07/13] =?UTF-8?q?=E2=9C=A8=20add=20ProcessCollection=20to?= =?UTF-8?q?=20track=20main=20and=20renderer=20process=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rum/process/ProcessCollection.spec.ts | 128 +++++++++++++ src/domain/rum/process/ProcessCollection.ts | 168 ++++++++++++++++++ src/domain/rum/process/index.ts | 2 +- 3 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/domain/rum/process/ProcessCollection.spec.ts create mode 100644 src/domain/rum/process/ProcessCollection.ts diff --git a/src/domain/rum/process/ProcessCollection.spec.ts b/src/domain/rum/process/ProcessCollection.spec.ts new file mode 100644 index 00000000..a525b9c9 --- /dev/null +++ b/src/domain/rum/process/ProcessCollection.spec.ts @@ -0,0 +1,128 @@ +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 { createFormatHooks } from '../../../assembly'; +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, createFormatHooks()); + }); + + 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(); + }); + }); + + 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..a05b5c10 --- /dev/null +++ b/src/domain/rum/process/ProcessCollection.ts @@ -0,0 +1,168 @@ +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 type { FormatHooks } from '../../../assembly'; +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, + // hooks reserved for enrichment hook in Task 7 + _hooks: FormatHooks + ) { + const mainId = generateUUID(); + this.processContext = new ProcessContext({ id: mainId, name: undefined }); + } + + static start(eventManager: EventManager, hooks: FormatHooks): ProcessCollection { + const collection = new ProcessCollection(eventManager, hooks); + 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) { + 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/index.ts b/src/domain/rum/process/index.ts index c6a11f37..a6d18c3e 100644 --- a/src/domain/rum/process/index.ts +++ b/src/domain/rum/process/index.ts @@ -1,3 +1,3 @@ export { ProcessContext } from './ProcessContext'; export type { ProcessContextEntry, ProcessInfo } from './ProcessContext'; -// ProcessCollection will be exported from here in Task 6 +export { ProcessCollection, PROCESS_UPDATE_INTERVAL } from './ProcessCollection'; From dec6980f8b6a37d45b3696565d234f42ff8fac01 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:34:04 +0200 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=90=9B=20clear=20main=20process=20t?= =?UTF-8?q?imer=20on=20SESSION=5FEXPIRED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SESSION_EXPIRED handler was emitting a final process event but leaving the periodic timer running, causing resource leaks and spurious events after session end. Add clearInterval() call before the final event, and add a test verifying no further updates fire after expiry. --- src/domain/rum/process/ProcessCollection.spec.ts | 7 +++++++ src/domain/rum/process/ProcessCollection.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/src/domain/rum/process/ProcessCollection.spec.ts b/src/domain/rum/process/ProcessCollection.spec.ts index a525b9c9..b3823eca 100644 --- a/src/domain/rum/process/ProcessCollection.spec.ts +++ b/src/domain/rum/process/ProcessCollection.spec.ts @@ -68,6 +68,13 @@ describe('ProcessCollection', () => { 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', () => { diff --git a/src/domain/rum/process/ProcessCollection.ts b/src/domain/rum/process/ProcessCollection.ts index a05b5c10..6172e9a8 100644 --- a/src/domain/rum/process/ProcessCollection.ts +++ b/src/domain/rum/process/ProcessCollection.ts @@ -69,6 +69,7 @@ export class ProcessCollection { 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, From ba0b2fc6a541027a93ed51ae8e2be26d73010e89 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:40:50 +0200 Subject: [PATCH 09/13] =?UTF-8?q?=E2=9C=A8=20enrich=20RUM=20events=20with?= =?UTF-8?q?=20process=20context=20via=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assembly/RendererPipeline.spec.ts | 14 ++++++++- src/assembly/RendererPipeline.ts | 11 ++++--- src/assembly/commonContext.spec.ts | 44 ++++++++++++++++++++++++++- src/assembly/commonContext.ts | 17 +++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) 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; + }); +} From 469d13d67f952cd636d91f58200b0913e5ba9d5a Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:47:50 +0200 Subject: [PATCH 10/13] =?UTF-8?q?=E2=9C=A8=20wire=20ProcessCollection=20an?= =?UTF-8?q?d=20process=20enrichment=20hook=20in=20SDK=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProcessCollection.start() at the end of init(), after RumCollection and MainAssembly are set up - Register the process enrichment hook before returning from init() - Export registerProcessContext from src/assembly/index.ts to comply with internal module rules --- src/assembly/index.ts | 2 +- src/index.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) 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/index.ts b/src/index.ts index 824ffd5f..1f8ef0d5 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'; @@ -72,6 +79,12 @@ export async function init(configuration: InitConfiguration): Promise { 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, hooks); + registerProcessContext(processCollection.processContext, hooks); + return true; } From 75520090143df839bd2521e9aa6ba29084e78c99 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:52:25 +0200 Subject: [PATCH 11/13] =?UTF-8?q?=E2=9C=85=20update=20view=20E2E=20and=20a?= =?UTF-8?q?dd=20process=20event=20E2E=20scenario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/app/src/main.ts | 22 ++++++++++ e2e/app/src/preload.ts | 2 + e2e/lib/mainPage.ts | 12 ++++++ e2e/scenarios/process.scenario.ts | 69 +++++++++++++++++++++++++++++++ e2e/scenarios/view.scenario.ts | 23 ++--------- 5 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 e2e/scenarios/process.scenario.ts 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); -}); From 7df438aa1fbe5bf97ce015018eb05434f86a37f1 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 14:56:43 +0200 Subject: [PATCH 12/13] =?UTF-8?q?=E2=9C=A8=20add=20process=20lifecycle=20c?= =?UTF-8?q?ontrols=20to=20playground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/package.json | 5 +- playground/src/index.html | 5 ++ playground/src/main.ts | 18 +++++ playground/src/preload.ts | 1 + playground/src/renderer.ts | 3 + playground/src/secondary-renderer.ts | 38 ++++++++++ playground/src/secondary.html | 108 +++++++++++++++++++++++++++ 7 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 playground/src/secondary-renderer.ts create mode 100644 playground/src/secondary.html 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

+ + +
+ + +
+ +
+
+ + + + From 7570f20a9acb8e1e344ddc1f40038d1e43ddefc0 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Wed, 22 Jul 2026 15:10:53 +0200 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=90=9B=20fix=20process=20event=20as?= =?UTF-8?q?sembly=20path=20and=20clean=20up=20unused=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assembly/MainAssembly.spec.ts | 55 ++++++++++- src/assembly/MainAssembly.ts | 2 +- src/assembly/hooks.ts | 2 +- src/domain/profiling/ProfilingCollection.ts | 2 +- .../rum/process/ProcessCollection.spec.ts | 3 +- src/domain/rum/process/ProcessCollection.ts | 11 +-- src/domain/rum/rawRumData.types.ts | 3 +- src/index.ts | 2 +- src/mocks.specUtil.ts | 96 +------------------ 9 files changed, 65 insertions(+), 111 deletions(-) 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 125df756..696d3f3b 100644 --- a/src/assembly/MainAssembly.ts +++ b/src/assembly/MainAssembly.ts @@ -43,7 +43,7 @@ export class MainAssembly { const startTime = event.startTime ?? timeStampNow(); const source = EventSource.MAIN; - if (event.format === EventFormat.RUM && isRumEventType(event.data.type)) { + if (event.format === EventFormat.RUM && (isRumEventType(event.data.type) || event.data.type === 'process')) { const hookResult = this.hooks.triggerRum({ eventType: event.data.type, startTime, diff --git a/src/assembly/hooks.ts b/src/assembly/hooks.ts index fd6a8d4f..b82d0c1e 100644 --- a/src/assembly/hooks.ts +++ b/src/assembly/hooks.ts @@ -9,7 +9,7 @@ import { EventSource } from '../event'; export type RumEventType = RumEvent['type']; export interface RumAssembleParams { - eventType: RumEventType; + eventType: string; startTime: TimeStamp; source: EventSource; webContentsId?: number; 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/rum/process/ProcessCollection.spec.ts b/src/domain/rum/process/ProcessCollection.spec.ts index b3823eca..166d2be9 100644 --- a/src/domain/rum/process/ProcessCollection.spec.ts +++ b/src/domain/rum/process/ProcessCollection.spec.ts @@ -9,7 +9,6 @@ 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 { createFormatHooks } from '../../../assembly'; import { RawRumProcess } from '../rawRumData.types'; describe('ProcessCollection', () => { @@ -36,7 +35,7 @@ describe('ProcessCollection', () => { return app; }); - processCollection = ProcessCollection.start(eventManager, createFormatHooks()); + processCollection = ProcessCollection.start(eventManager); }); afterEach(() => { diff --git a/src/domain/rum/process/ProcessCollection.ts b/src/domain/rum/process/ProcessCollection.ts index 6172e9a8..9fd0933d 100644 --- a/src/domain/rum/process/ProcessCollection.ts +++ b/src/domain/rum/process/ProcessCollection.ts @@ -2,7 +2,6 @@ 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 type { FormatHooks } from '../../../assembly'; import { setInterval } from '../../telemetry'; import { ProcessContext } from './ProcessContext'; import type { RawRumProcess } from '../rawRumData.types'; @@ -23,17 +22,13 @@ export class ProcessCollection { private mainState!: ProcessState; private readonly rendererStates = new Map(); - private constructor( - private readonly eventManager: EventManager, - // hooks reserved for enrichment hook in Task 7 - _hooks: FormatHooks - ) { + private constructor(private readonly eventManager: EventManager) { const mainId = generateUUID(); this.processContext = new ProcessContext({ id: mainId, name: undefined }); } - static start(eventManager: EventManager, hooks: FormatHooks): ProcessCollection { - const collection = new ProcessCollection(eventManager, hooks); + static start(eventManager: EventManager): ProcessCollection { + const collection = new ProcessCollection(eventManager); collection.initMain(); collection.initRendererTracking(); return collection; diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index 765d0cf7..77b78996 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -121,9 +121,8 @@ export interface RawRumProcess { date: TimeStamp; process: { id: string; - role: 'main' | 'renderer' | 'utility'; + role: 'main' | 'renderer'; pid: number; - ppid?: number; name?: string; duration?: ServerDuration; exit_reason?: string; diff --git a/src/index.ts b/src/index.ts index 1f8ef0d5..30a19e25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,7 @@ export async function init(configuration: InitConfiguration): Promise { // 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, hooks); + const processCollection = ProcessCollection.start(eventManager); registerProcessContext(processCollection.processContext, hooks); return true; diff --git a/src/mocks.specUtil.ts b/src/mocks.specUtil.ts index d6a92068..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 = { @@ -61,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; -}