diff --git a/package.json b/package.json index 8fcdecab..37d5c9d6 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,12 @@ "require": "./dist/esbuild-plugin.cjs", "import": "./dist/esbuild-plugin.mjs" }, - "./preload": "./dist/preload.js" + "./preload": "./dist/preload.js", + "./renderer": { + "types": "./dist/renderer.d.ts", + "require": "./dist/renderer.cjs", + "import": "./dist/renderer.mjs" + } }, "typesVersions": { "*": { @@ -47,6 +52,9 @@ ], "esbuild-plugin": [ "./dist/esbuild-plugin.d.ts" + ], + "renderer": [ + "./dist/renderer.d.ts" ] } }, @@ -81,6 +89,11 @@ "dist/esbuild-plugin.mjs.map", "dist/esbuild-plugin.d.ts", "dist/preload.js", + "dist/renderer.cjs", + "dist/renderer.cjs.map", + "dist/renderer.mjs", + "dist/renderer.mjs.map", + "dist/renderer.d.ts", "README.md", "LICENSE", "LICENSE-3rdparty.csv" diff --git a/playground/.yarnrc.yml b/playground/.yarnrc.yml index c6eb6ab8..f6d410b2 100644 --- a/playground/.yarnrc.yml +++ b/playground/.yarnrc.yml @@ -1,2 +1,4 @@ -# Align with Renovate's minimumReleaseAge of 7 days for defense-in-depth +approvedGitRepositories: + - '**' + npmMinimalAgeGate: 7d diff --git a/playground/package.json b/playground/package.json index a572a1d7..26a4e703 100644 --- a/playground/package.json +++ b/playground/package.json @@ -14,7 +14,7 @@ "test": "yarn build && playwright test -c test/playwright.config.ts" }, "dependencies": { - "@datadog/browser-rum": "7.5.0", + "@datadog/browser-rum": "portal:/Users/bastien.caudan/go/src/github.com/DataDog/browser-sdk/.worktrees/bcaudan/browser-ipc/packages/browser-rum", "@datadog/electron-sdk": "portal:.." }, "devDependencies": { diff --git a/playground/src/index.html b/playground/src/index.html index 3efe0803..cad2b096 100644 --- a/playground/src/index.html +++ b/playground/src/index.html @@ -198,6 +198,16 @@

Session ID:

+
IPC Scenarios (prototype)
+
+ + + + + + +
+
Custom Duration Vitals (main process)
diff --git a/playground/src/main.ts b/playground/src/main.ts index e87241c2..046d2464 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -11,6 +11,7 @@ import { _flushTransport, getInternalContext, _generateTelemetryError, + addError, addDurationVital, startDurationVital, stopDurationVital, @@ -145,6 +146,69 @@ ipcMain.handle('main:fetch-api-net', async () => { return (await res.json()) as unknown; }); +ipcMain.handle('ipc-demo:get-profile', async () => { + const res = await fetch('https://httpbin.org/json'); + return (await res.json()) as unknown; +}); + +ipcMain.handle('ipc-demo:get-profile-with-progress', async () => { + mainWindow?.webContents.send('ipc-demo:profile-progress', { status: 'fetching' }); + startDurationVital('profile.fetch'); + const res = await fetch('https://httpbin.org/json'); + stopDurationVital('profile.fetch'); + return (await res.json()) as unknown; +}); + +ipcMain.on('ipc-demo:ping-main', () => { + void fetch('https://httpbin.org/json').catch(() => undefined); +}); +ipcMain.on('ipc-demo:ping-main', () => { + void fetch('https://httpbin.org/uuid').catch(() => undefined); + addError(new Error('ping-main listener #2 failed'), { context: { scenario: 'ipc-demo:ping-main' } }); +}); + +ipcMain.handle('ipc-demo:trigger-ping-renderer', () => { + void fetch('https://httpbin.org/json').catch(() => undefined); + mainWindow?.webContents.send('ipc-demo:ping-renderer', { from: 'main' }); +}); + +let broadcastWindows: BrowserWindow[] = []; + +// Opening the helper windows is its own explicit user action (the "Open broadcast windows" button), +// separate from actually broadcasting. This keeps the broadcast handler itself fully synchronous — no +// await between receiving the trigger and relaying to each window — which matters for two reasons: +// (1) by the time a user can click "Broadcast", the windows the earlier click already opened are +// guaranteed loaded (`loadURL` resolves once the page has finished loading, by which point the +// renderer's synchronous top-level script — including its ipcRenderer.on registration — has already +// run), so Electron never silently drops a send aimed at a not-yet-registered listener; and (2) a +// synchronous handler keeps the relay sends within the same ambient IPC context as the trigger call, +// so they correctly inherit it as their parent (see src/domain/tracing/ipcParentContext.ts) — an +// `await` between the trigger and the relay would clear that context before the sends fire. +ipcMain.handle('ipc-demo:open-broadcast-windows', async () => { + if (broadcastWindows.length > 0) return; // already opened, idempotent + broadcastWindows = [0, 1].map( + () => + new BrowserWindow({ + width: 400, + height: 300, + show: !isTestMode, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + preload: path.join(__dirname, 'preload.js'), + }, + }) + ); + await Promise.all(broadcastWindows.map((win) => win.loadURL('app://app/'))); +}); + +ipcMain.handle('ipc-demo:broadcast', (_event, data: unknown) => { + void fetch('https://httpbin.org/json').catch(() => undefined); + for (const win of broadcastWindows) { + win.webContents.send('ipc-demo:broadcast-received', data); + } +}); + // IPC handler to crash the main process ipcMain.handle('crash', () => { process.crash(); @@ -207,7 +271,7 @@ ipcMain.handle( } ); -const ACTIVE_ENV = 'staging'; +const ACTIVE_ENV = 'prod'; const CONF = { staging: { applicationId: '6efd3722-af0a-4070-994c-0e87076d4814', diff --git a/playground/src/preload.ts b/playground/src/preload.ts index c9178d3f..43f8c647 100644 --- a/playground/src/preload.ts +++ b/playground/src/preload.ts @@ -35,6 +35,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('main:fail-operation', name, failureReason, options), mainFetchApiFetch: () => ipcRenderer.invoke('main:fetch-api-fetch'), mainFetchApiNet: () => ipcRenderer.invoke('main:fetch-api-net'), + getProfile: () => ipcRenderer.invoke('ipc-demo:get-profile'), + getProfileWithProgress: () => ipcRenderer.invoke('ipc-demo:get-profile-with-progress'), + onProfileProgress: (callback: (data: unknown) => void) => { + ipcRenderer.on('ipc-demo:profile-progress', (_event, data) => callback(data)); + }, + pingMain: () => ipcRenderer.send('ipc-demo:ping-main'), openRumExplorer: () => ipcRenderer.invoke('open-rum-explorer'), flushTransport: () => ipcRenderer.invoke('flush-transport'), setUserInfo: () => ipcRenderer.invoke('main:set-user-info'), @@ -43,4 +49,13 @@ contextBridge.exposeInMainWorld('electronAPI', { setAccountInfo: () => ipcRenderer.invoke('main:set-account-info'), addAccountExtraInfo: () => ipcRenderer.invoke('main:add-account-extra-info'), clearAccountInfo: () => ipcRenderer.invoke('main:clear-account-info'), + triggerPingRenderer: () => ipcRenderer.invoke('ipc-demo:trigger-ping-renderer'), + onPingFromMain: (callback: (data: unknown) => void) => { + ipcRenderer.on('ipc-demo:ping-renderer', (_event, data) => callback(data)); + }, + openBroadcastWindows: () => ipcRenderer.invoke('ipc-demo:open-broadcast-windows'), + broadcast: (data: unknown) => ipcRenderer.invoke('ipc-demo:broadcast', data), + onBroadcastReceived: (callback: (data: unknown) => void) => { + ipcRenderer.on('ipc-demo:broadcast-received', (_event, data) => callback(data)); + }, }); diff --git a/playground/src/renderer.ts b/playground/src/renderer.ts index 4224f849..00bed6a4 100644 --- a/playground/src/renderer.ts +++ b/playground/src/renderer.ts @@ -1,4 +1,5 @@ import { datadogRum } from '@datadog/browser-rum'; +import { datadogRendererPlugin } from '@datadog/electron-sdk/renderer'; interface DurationVitalOptions { vitalKey?: string; @@ -26,6 +27,7 @@ datadogRum.init({ trackResources: true, trackLongTasks: true, trackUserInteractions: true, + plugins: [datadogRendererPlugin()], }); // Type definition for the exposed API @@ -49,6 +51,10 @@ interface ElectronAPI { ) => Promise; mainFetchApiFetch: () => Promise; mainFetchApiNet: () => Promise; + getProfile: () => Promise; + getProfileWithProgress: () => Promise; + onProfileProgress: (callback: (data: unknown) => void) => void; + pingMain: () => void; openRumExplorer: () => Promise; flushTransport: () => Promise; setUserInfo: () => Promise; @@ -57,6 +63,11 @@ interface ElectronAPI { setAccountInfo: () => Promise; addAccountExtraInfo: () => Promise; clearAccountInfo: () => Promise; + triggerPingRenderer: () => Promise; + onPingFromMain: (callback: (data: unknown) => void) => void; + openBroadcastWindows: () => Promise; + broadcast: (data: unknown) => Promise; + onBroadcastReceived: (callback: (data: unknown) => void) => void; } declare global { @@ -261,6 +272,40 @@ setupDemoButton('clear-account-info', 'main:clear-account-info', () => window.el setupDemoButton('main-fetch', 'main:fetch-api', () => window.electronAPI.mainFetchApi()); setupDemoButton('main-fetch-fetch', 'main:fetch-api-fetch', () => window.electronAPI.mainFetchApiFetch()); setupDemoButton('main-fetch-net', 'main:fetch-api-net', () => window.electronAPI.mainFetchApiNet()); +setupDemoButton('ipc-get-profile', 'ipc-demo:get-profile', () => window.electronAPI.getProfile()); +setupDemoButton('ipc-ping-main', 'ipc-demo:ping-main', () => { + window.electronAPI.pingMain(); + return Promise.resolve(); +}); + +// Register two separate listeners for ping-renderer to demonstrate multiplicity +window.electronAPI.onPingFromMain((data) => logIpcCall('ipc-demo:ping-renderer#1', 'done', 0, JSON.stringify(data))); +window.electronAPI.onPingFromMain((data) => { + logIpcCall('ipc-demo:ping-renderer#2', 'done', 0, JSON.stringify(data)); + datadogRum.addDurationVital('ping-renderer.handling', { + startTime: Date.now() - 50, + duration: 50, + context: { scenario: 'ipc-demo:ping-renderer' }, + }); +}); + +setupDemoButton('ipc-ping-renderer', 'ipc-demo:trigger-ping-renderer', () => window.electronAPI.triggerPingRenderer()); + +window.electronAPI.onProfileProgress((data) => + logIpcCall('ipc-demo:profile-progress', 'done', 0, JSON.stringify(data)) +); +setupDemoButton('ipc-nested-profile', 'ipc-demo:get-profile-with-progress', () => + window.electronAPI.getProfileWithProgress() +); + +window.electronAPI.onBroadcastReceived((data) => { + logIpcCall('ipc-demo:broadcast-received', 'done', 0, JSON.stringify(data)); + datadogRum.addError(new Error('broadcast-received handling failed'), { scenario: 'ipc-demo:broadcast-received' }); +}); +setupDemoButton('ipc-open-broadcast-windows', 'ipc-demo:open-broadcast-windows', () => + window.electronAPI.openBroadcastWindows() +); +setupDemoButton('ipc-broadcast', 'ipc-demo:broadcast', () => window.electronAPI.broadcast({ from: 'main-window' })); // --- Custom duration vital demo buttons --- diff --git a/playground/test/ipc-scenarios.scenario.ts b/playground/test/ipc-scenarios.scenario.ts new file mode 100644 index 00000000..8b995454 --- /dev/null +++ b/playground/test/ipc-scenarios.scenario.ts @@ -0,0 +1,238 @@ +import type { Page } from '@playwright/test'; +import type { Intake, ReceivedEvent } from '../../e2e/lib/intake'; +import { test, expect, flushTransport } from './helpers'; + +interface IpcResourceBody { + type: 'resource'; + resource: { type: string; url: string; duration: number }; + context: { ipc: { role: 'source' | 'destination'; id: string; parent_ids: string[]; method: string } }; +} + +/** + * Destination-side handlers in this prototype do real `fetch()` calls to httpbin.org, so a single + * flushTransport() right after the click races the network round trip: the destination event may + * not exist yet when the transport is flushed, and nothing re-flushes it afterwards. Poll by + * flushing repeatedly until the expected count shows up (or the overall timeout elapses). + */ +async function flushUntilEventCount( + window: Page, + intake: Intake, + count: number, + predicate: (event: ReceivedEvent) => boolean, + overallTimeout = 20000 +): Promise { + const start = Date.now(); + for (;;) { + await flushTransport(window); + try { + return await intake.waitForEventCount('resource', count, { predicate, timeout: 500 }); + } catch (err) { + if (Date.now() - start >= overallTimeout) throw err; + } + } +} + +test('request/response IPC produces two RUM ipc resource events sharing the same ipc.id', async ({ + window, + intake, +}) => { + await window.click('#ipc-get-profile'); + + // Source (renderer, method 'invoke') and destination (main, method 'handle') are DIFFERENT method + // values for the same logical call — filter by the shared channel/url, not by method, or the source + // side's own event would never be joined by a matching destination-side predicate. + const events = await flushUntilEventCount( + window, + intake, + 2, + (event) => (event.body as IpcResourceBody).resource?.url === 'ipc-demo:get-profile' + ); + + const bodies = events.map((event) => event.body as IpcResourceBody); + const source = bodies.find((body) => body.context.ipc.role === 'source'); + const destination = bodies.find((body) => body.context.ipc.role === 'destination'); + + expect(source).toBeDefined(); + expect(destination).toBeDefined(); + expect(source!.context.ipc.method).toBe('invoke'); + expect(destination!.context.ipc.method).toBe('handle'); + expect(source!.context.ipc.id).toBe(destination!.context.ipc.id); + expect(source!.resource.type).toBe('native'); + + // Top-level, user-initiated call — not triggered from within any other IPC handler, so no parent. + expect(source!.context.ipc.parent_ids).toEqual([]); + expect(destination!.context.ipc.parent_ids).toEqual([]); +}); + +test('fire-and-forget renderer→main produces one source event and two destination events sharing ipc.id', async ({ + window, + intake, +}) => { + await window.click('#ipc-ping-main'); + + // Same reasoning as above: source is 'send', destinations are 'on' — filter by channel/url. + const events = await flushUntilEventCount( + window, + intake, + 3, + (event) => (event.body as IpcResourceBody).resource?.url === 'ipc-demo:ping-main' + ); + const bodies = events.map((event) => event.body as IpcResourceBody); + const sources = bodies.filter((body) => body.context.ipc.role === 'source'); + const destinations = bodies.filter((body) => body.context.ipc.role === 'destination'); + + expect(sources).toHaveLength(1); + expect(destinations).toHaveLength(2); + expect(destinations.every((body) => body.context.ipc.id === sources[0].context.ipc.id)).toBe(true); +}); + +test('fire-and-forget main→renderer produces one source event and two destination events sharing ipc.id', async ({ + window, + intake, +}) => { + await window.click('#ipc-ping-renderer'); + + // Note: clicking the button first does an invoke/handle round trip on channel + // 'ipc-demo:trigger-ping-renderer' (its own separate ipc.id), which then triggers a single + // webContents.send on channel 'ipc-demo:ping-renderer'. Electron delivers that one send to BOTH + // registered ipcRenderer.on listeners with the same appended id, so filtering by the relayed + // channel/url gives 1 source + 2 destinations = 3 events, all sharing that one id (same shape as + // above, just main-initiated instead of renderer-initiated). + const triggerEvents = await flushUntilEventCount( + window, + intake, + 2, + (event) => (event.body as IpcResourceBody).resource?.url === 'ipc-demo:trigger-ping-renderer' + ); + const triggerId = (triggerEvents[0].body as IpcResourceBody).context.ipc.id; + + const events = await flushUntilEventCount( + window, + intake, + 3, + (event) => (event.body as IpcResourceBody).resource?.url === 'ipc-demo:ping-renderer' + ); + const bodies = events.map((event) => event.body as IpcResourceBody); + expect(bodies.filter((b) => b.context.ipc.role === 'destination')).toHaveLength(2); + expect(bodies.every((b) => b.context.ipc.id === bodies[0].context.ipc.id)).toBe(true); + + // The relay was triggered synchronously from within the trigger's handler, so it must inherit the + // trigger's id as its own parent chain — this is what lets a customer follow the causal chain from + // one IPC call to the one it spawned, not just correlate the two sides of a single call. + expect(bodies.every((b) => b.context.ipc.parent_ids.length === 1 && b.context.ipc.parent_ids[0] === triggerId)).toBe( + true + ); +}); + +test("nested IPC: progress send falls within the parent handle event's time window", async ({ window, intake }) => { + await window.click('#ipc-nested-profile'); + + // Scoped by url (not just method): the test's own polling helper calls flushTransport(), which is + // itself an `ipcMain.handle('flush-transport', ...)` call and would otherwise satisfy a bare + // `method === 'handle'` predicate before the real nested-profile event exists. + const [handleEvent] = await flushUntilEventCount( + window, + intake, + 1, + (event) => + (event.body as IpcResourceBody).context?.ipc?.method === 'handle' && + (event.body as IpcResourceBody).resource?.url === 'ipc-demo:get-profile-with-progress' + ); + const [progressEvent] = await flushUntilEventCount( + window, + intake, + 1, + (event) => + (event.body as IpcResourceBody).context?.ipc?.method === 'send' && + (event.body as IpcResourceBody).resource?.url === 'ipc-demo:profile-progress' + ); + + const handleBody = handleEvent.body as IpcResourceBody & { date: number }; + const progressBody = progressEvent.body as IpcResourceBody & { date: number }; + + // Validates axis 2.B's premise: the nested send's timestamp falls inside the handle event's window. + expect(progressBody.date).toBeGreaterThanOrEqual(handleBody.date); + expect(progressBody.date).toBeLessThanOrEqual(handleBody.date + handleBody.resource.duration); + + // The progress send was triggered synchronously from within the outer handle's handler, so it + // must inherit the outer call's id as its own parent chain. + expect(progressBody.context.ipc.parent_ids).toEqual([handleBody.context.ipc.id]); +}); + +test('broadcast produces an independent source/destination pair for each relayed send', async ({ window, intake }) => { + // Opening the helper windows is a separate, explicit action from broadcasting (see main.ts's + // comment on ipc-demo:open-broadcast-windows) — wait for the button to re-enable, which happens + // once its invoke (awaiting both windows' loadURL) resolves, before clicking broadcast. + await window.click('#ipc-open-broadcast-windows'); + await expect(window.locator('#ipc-open-broadcast-windows')).toBeEnabled(); + + await window.click('#ipc-broadcast'); + const triggerEvents = await flushUntilEventCount( + window, + intake, + 2, + (event) => (event.body as IpcResourceBody).resource?.url === 'ipc-demo:broadcast' + ); + const triggerId = (triggerEvents[0].body as IpcResourceBody).context.ipc.id; + + const relayedEvents = await flushUntilEventCount( + window, + intake, + 4, + (event) => (event.body as IpcResourceBody).resource?.url === 'ipc-demo:broadcast-received' + ); + + // The initial invoke/handle (channel 'ipc-demo:broadcast') and each relay send/on (channel + // 'ipc-demo:broadcast-received', one per receiving window) are independent calls: main's relay loop + // calls webContents.send once per window, and each call generates its OWN ipc.id (Task 2's + // startSendWithIpcId mints a fresh id every invocation) — the two relay pairs do NOT share an id + // with each other. They DO, however, each carry the trigger's id as their shared parent_ids, since + // both sends were triggered synchronously from within the trigger's own handler. + const bodies = relayedEvents.map((event) => event.body as IpcResourceBody); + const sources = bodies.filter((b) => b.context.ipc.role === 'source'); + const destinations = bodies.filter((b) => b.context.ipc.role === 'destination'); + + expect(sources).toHaveLength(2); // one relay send per receiving window + expect(destinations).toHaveLength(2); // one 'on' event per receiving window + + // Each relay send pairs with exactly one destination sharing its own id, but the two pairs are + // otherwise unrelated to each other. + const sourceIds = sources.map((s) => s.context.ipc.id).sort(); + const destinationIds = destinations.map((d) => d.context.ipc.id).sort(); + expect(destinationIds).toEqual(sourceIds); + expect(new Set(sourceIds).size).toBe(2); + + // Both relay pairs share the SAME parent chain (the trigger's id), even though their own ids are + // independent of each other — this is what lets a customer find every call a given IPC event + // spawned, not just correlate one call's two sides. + expect(bodies.every((b) => b.context.ipc.parent_ids.length === 1 && b.context.ipc.parent_ids[0] === triggerId)).toBe( + true + ); +}); + +test('a real network call inside a destination handler produces a correlated resource within the IPC event window', async ({ + window, + intake, +}) => { + await window.click('#ipc-get-profile'); + + await flushUntilEventCount( + window, + intake, + 1, + (event) => + (event.body as IpcResourceBody).context?.ipc?.role === 'destination' && + (event.body as IpcResourceBody).context?.ipc?.method === 'handle' + ); + + const networkEvents = await flushUntilEventCount(window, intake, 1, (event) => { + // Both IPC and real network resources use resource.type: 'native' (resource.type has no 'ipc' + // enum value, see Task 3's correction note) — distinguish by the absence of context.ipc instead. + const body = event.body as { context?: { ipc?: unknown }; resource?: { url?: string } }; + return !body.context?.ipc && !!body.resource?.url?.includes('httpbin.org'); + }); + + expect(networkEvents.length).toBeGreaterThan(0); + // This is the raw ingredient axis 2.B's "pivot by time-window overlap" needs — the product query + // itself is not SDK code, but the test proves the timestamps make that query possible. +}); diff --git a/rollup.config.mjs b/rollup.config.mjs index 72633f7a..052b9ee6 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -172,6 +172,32 @@ const config = [ external: ['electron'], plugins: sharedPlugins, }, + // Renderer: wires the preload's IPC resource bridge to a host app's own datadogRum instance + { + input: 'src/entries/renderer.ts', + output: [ + { + file: 'dist/renderer.cjs', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/renderer.mjs', + format: 'esm', + sourcemap: true, + }, + ], + plugins: sharedPlugins, + }, + // TypeScript declarations: renderer + { + input: 'src/entries/renderer.ts', + output: { + file: 'dist/renderer.d.ts', + format: 'esm', + }, + plugins: [dts({ tsconfig: './tsconfig.build.json', respectExternal: true })], + }, ]; export default config; diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index 8a9ee918..55d07a83 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -99,12 +99,12 @@ export interface RawRumResource extends RecursivePartial { duration: ServerDuration; type: 'native'; method?: RumResourceEvent['resource']['method']; - status_code: number; + status_code?: number; url: string; }; _dd: { - trace_id: string; - span_id: string; + trace_id?: string; + span_id?: string; format_version: 2; }; } diff --git a/src/domain/tracing/IpcResourceCollector.spec.ts b/src/domain/tracing/IpcResourceCollector.spec.ts new file mode 100644 index 00000000..eabbb382 --- /dev/null +++ b/src/domain/tracing/IpcResourceCollector.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EventKind, EventFormat, EventManager } from '../../event'; +import { IpcResourceCollector } from './IpcResourceCollector'; +import type { IpcChannelMessage } from '../../instrument/ipc'; + +describe('IpcResourceCollector', () => { + it('emits a RUM ipc resource event when the registered handler is invoked', () => { + const eventManager = new EventManager(); + const notifySpy = vi.spyOn(eventManager, 'notify'); + + let registeredHandler: ((message: IpcChannelMessage) => void) | undefined; + const fakeSetIpcEventHandler = (handler: (message: IpcChannelMessage) => void) => { + registeredHandler = handler; + }; + + new IpcResourceCollector(eventManager, fakeSetIpcEventHandler); + + registeredHandler!({ + role: 'destination', + id: 'call-abc', + parentIds: ['call-root'], + method: 'handle', + channel: 'get-profile', + startTime: 1000, + duration: 42, + error: false, + }); + + expect(notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: EventKind.RAW, + format: EventFormat.RUM, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: expect.objectContaining({ + type: 'resource', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + resource: expect.objectContaining({ type: 'native', url: 'get-profile' }), + context: { ipc: { role: 'destination', id: 'call-abc', parent_ids: ['call-root'], method: 'handle' } }, + }), + }) + ); + }); +}); diff --git a/src/domain/tracing/IpcResourceCollector.ts b/src/domain/tracing/IpcResourceCollector.ts new file mode 100644 index 00000000..e765b11b --- /dev/null +++ b/src/domain/tracing/IpcResourceCollector.ts @@ -0,0 +1,58 @@ +import { type Duration, type TimeStamp, toServerDuration } from '@datadog/js-core/time'; +import { generateUUID } from '@datadog/browser-core'; +import { EventFormat, EventKind, type EventManager } from '../../event'; +import { setIpcEventHandler, type IpcChannelMessage } from '../../instrument/ipc'; +import { monitor } from '../telemetry'; +import type { RawRumResource } from '../rum'; + +/** + * Registers itself as ipc.ts's IPC event handler, and converts each invocation into a RUM resource + * event. `registerHandler` defaults to the real `setIpcEventHandler`, and is injectable so tests can + * supply a fake without going through ipc.ts's module-level setter. + * + * `resource.type` stays `'native'` — the rum-events-format schema (auto-generated, does not accept an + * `'ipc'` literal) has no dedicated IPC resource type. IPC identity is carried entirely by + * `context.ipc.{role,id,method}`, not a top-level `ipc` field, for parity with the renderer side + * (Task 4/9), which can only attach custom data via `startResource`/`stopResource`'s `context` option. + * `_dd` without `trace_id`/`span_id` is valid per the widened `RawRumResource` type from Step 1, no + * cast needed. + */ +export class IpcResourceCollector { + constructor( + private eventManager: EventManager, + registerHandler: (handler: (message: IpcChannelMessage) => void) => void = setIpcEventHandler + ) { + registerHandler(monitor((message: IpcChannelMessage) => this.processMessage(message))); + } + + private processMessage(message: IpcChannelMessage): void { + const rawRumEvent: RawRumResource = { + type: 'resource', + date: message.startTime as TimeStamp, + resource: { + id: generateUUID(), + duration: toServerDuration(message.duration as Duration), + type: 'native', + url: message.channel, + }, + _dd: { + format_version: 2, + }, + context: { + ipc: { + role: message.role, + id: message.id, + parent_ids: message.parentIds, + method: message.method, + }, + }, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + format: EventFormat.RUM, + data: rawRumEvent, + startTime: rawRumEvent.date, + }); + } +} diff --git a/src/domain/tracing/ipcChannelFilter.spec.ts b/src/domain/tracing/ipcChannelFilter.spec.ts new file mode 100644 index 00000000..ecd5c8ef --- /dev/null +++ b/src/domain/tracing/ipcChannelFilter.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { isExcludedIpcChannel } from './ipcChannelFilter'; + +describe('isExcludedIpcChannel', () => { + it('excludes datadog:-prefixed channels', () => { + expect(isExcludedIpcChannel('datadog:bridge-send')).toBe(true); + expect(isExcludedIpcChannel('datadog:bridge-config')).toBe(true); + }); + + it('excludes the hardcoded get-internal-context channel', () => { + expect(isExcludedIpcChannel('get-internal-context')).toBe(true); + }); + + it('excludes the hardcoded ipc-demo:open-broadcast-windows channel', () => { + expect(isExcludedIpcChannel('ipc-demo:open-broadcast-windows')).toBe(true); + }); + + it('does not exclude other channels', () => { + expect(isExcludedIpcChannel('ipc-demo:get-profile')).toBe(false); + expect(isExcludedIpcChannel('stop-session')).toBe(false); + }); +}); diff --git a/src/domain/tracing/ipcChannelFilter.ts b/src/domain/tracing/ipcChannelFilter.ts new file mode 100644 index 00000000..e6a67129 --- /dev/null +++ b/src/domain/tracing/ipcChannelFilter.ts @@ -0,0 +1,18 @@ +/** + * Channels the IPC instrumentation should never turn into a RUM event. `datadog:`-prefixed channels + * are the SDK's own internal bridge channels (see `src/common/channels.ts`), excluded everywhere IPC + * is patched (`src/instrument/ipc.ts`, `src/preload/ipc.ts`) so the SDK doesn't instrument itself. + * + * `get-internal-context` and `ipc-demo:open-broadcast-windows` are hardcoded on top of that, + * specifically to keep the playground demo's RUM data clean: they're plumbing (session id lookup, + * opening the broadcast demo's helper windows), not one of the `ipc-demo:*` scenario channels, and + * firing on every load/click would clutter the demo data. This is a demo-only special case, not a + * general SDK behavior — a real consuming app with its own unrelated channel of the same name would + * also have it silently excluded, which is acceptable for this prototype but worth revisiting (e.g. + * an app-configurable exclusion list) before this graduates past prototype status. + */ +const HARDCODED_EXCLUDED_CHANNELS = new Set(['get-internal-context', 'ipc-demo:open-broadcast-windows']); + +export function isExcludedIpcChannel(channel: string): boolean { + return channel.startsWith('datadog:') || HARDCODED_EXCLUDED_CHANNELS.has(channel); +} diff --git a/src/domain/tracing/ipcParentContext.spec.ts b/src/domain/tracing/ipcParentContext.spec.ts new file mode 100644 index 00000000..548ef2a8 --- /dev/null +++ b/src/domain/tracing/ipcParentContext.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { withIpcContext, computeChildParentIds } from './ipcParentContext'; + +describe('ipcParentContext', () => { + it('returns an empty array when no context is active', () => { + expect(computeChildParentIds()).toEqual([]); + }); + + it('appends the active id to its own parentIds while inside withIpcContext', () => { + withIpcContext('call-A', [], () => { + expect(computeChildParentIds()).toEqual(['call-A']); + }); + }); + + it('accumulates parentIds across nested withIpcContext calls', () => { + withIpcContext('call-A', [], () => { + withIpcContext('call-B', computeChildParentIds(), () => { + expect(computeChildParentIds()).toEqual(['call-A', 'call-B']); + }); + }); + }); + + it('restores the previous context after the synchronous callback returns', () => { + withIpcContext('call-A', [], () => { + withIpcContext('call-B', ['call-A'], () => undefined); + expect(computeChildParentIds()).toEqual(['call-A']); + }); + expect(computeChildParentIds()).toEqual([]); + }); + + it('restores the previous context even if the callback throws', () => { + expect(() => + withIpcContext('call-A', [], () => { + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(computeChildParentIds()).toEqual([]); + }); + + it('returns whatever the callback returns', () => { + const result = withIpcContext('call-A', [], () => 42); + expect(result).toBe(42); + }); + + it('does not persist context past an await inside an async callback (documented limitation)', async () => { + let duringAwait: string[] | undefined; + await withIpcContext('call-A', [], async () => { + await Promise.resolve(); + duringAwait = computeChildParentIds(); + }); + expect(duringAwait).toEqual([]); + }); +}); diff --git a/src/domain/tracing/ipcParentContext.ts b/src/domain/tracing/ipcParentContext.ts new file mode 100644 index 00000000..f30eb416 --- /dev/null +++ b/src/domain/tracing/ipcParentContext.ts @@ -0,0 +1,34 @@ +/** + * Tracks which IPC call is "currently being handled", so a new call initiated from within a + * destination handler can inherit that call's ancestry as its own `parent_ids`. + * + * This is a synchronous approximation, not a true async-context primitive (`node:async_hooks`'s + * `AsyncLocalStorage`): the tracked context is restored as soon as the wrapped callback returns + * *synchronously* — for an async callback, that means right when it returns its pending promise, not + * when that promise settles. A nested call made after an `await` inside the same handler, or two + * handlers whose async execution overlaps, will see an empty (or otherwise incorrect) parent chain + * rather than the real one. Accepted for this prototype: every scenario that actually exists today + * triggers its nested call synchronously, before any `await`. Deliberately plain, dependency-free + * JS — no `async_hooks` — so the exact same module works unmodified in both the main process and a + * (possibly sandboxed) preload script. See ipcParentContext.spec.ts's last test for the documented gap. + */ +interface IpcContext { + id: string; + parentIds: string[]; +} + +let current: IpcContext | undefined; + +export function withIpcContext(id: string, parentIds: string[], fn: () => T): T { + const previous = current; + current = { id, parentIds }; + try { + return fn(); + } finally { + current = previous; + } +} + +export function computeChildParentIds(): string[] { + return current ? [...current.parentIds, current.id] : []; +} diff --git a/src/domain/tracing/ipcResourceBridgeTypes.ts b/src/domain/tracing/ipcResourceBridgeTypes.ts new file mode 100644 index 00000000..a9b51df6 --- /dev/null +++ b/src/domain/tracing/ipcResourceBridgeTypes.ts @@ -0,0 +1,16 @@ +/** + * Shared, side-effect-free types for the renderer-side IPC resource bridge. + * + * Split out from `src/preload/ipc.ts` so `src/renderer/wireIpcResourceBridge.ts` (which runs in the + * main world, not the preload context) can reference the same event shape without importing + * `src/preload/ipc.ts` itself — that module imports `electron`'s `contextBridge`/`ipcRenderer` at the + * top level and calls `contextBridge.exposeInMainWorld` as a side effect on import, neither of which + * is available or safe to run in a contextIsolated renderer's main-world bundle. + */ +export interface ResourceHandlerEvent { + action: 'start' | 'stop'; + url: string; + options?: Record; +} + +export type ResourceHandler = (event: ResourceHandlerEvent) => void; diff --git a/src/entries/preload.ts b/src/entries/preload.ts index d788c48c..4fbf64ca 100644 --- a/src/entries/preload.ts +++ b/src/entries/preload.ts @@ -1 +1,2 @@ import '../preload/bridge'; +import '../preload/ipc'; diff --git a/src/entries/renderer.ts b/src/entries/renderer.ts new file mode 100644 index 00000000..99574e94 --- /dev/null +++ b/src/entries/renderer.ts @@ -0,0 +1,12 @@ +/** + * Renderer entry point — a `RumPlugin` that wires the SDK's preload-exposed IPC resource bridge to + * `datadogRum`, without this package depending on `@datadog/browser-rum`/`@datadog/browser-rum-core`. + * + * Usage: + * import { datadogRum } from '@datadog/browser-rum'; + * import { datadogRendererPlugin } from '@datadog/electron-sdk/renderer'; + * + * datadogRum.init({ ..., plugins: [datadogRendererPlugin()] }); + */ +export { datadogRendererPlugin } from '../renderer/datadogRendererPlugin'; +export type { DatadogRendererPlugin, IpcRumResourceApi } from '../renderer/datadogRendererPlugin'; diff --git a/src/index.ts b/src/index.ts index 5882b387..f893044e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { RumCollection } from './domain/rum'; import { ReplayCollection } from './domain/replay'; import { SessionManager } from './domain/session'; import { callMonitored, startTelemetry } from './domain/telemetry'; +import { IpcResourceCollector } from './domain/tracing/IpcResourceCollector'; import { SpanProcessor } from './domain/tracing/SpanProcessor'; import { Tracing } from './domain/tracing/Tracing'; import { ProfilingCollection } from './domain/profiling'; @@ -58,6 +59,7 @@ export async function init(configuration: InitConfiguration): Promise { new MainAssembly(eventManager, hooks); new RendererPipeline(eventManager, hooks, config); + new IpcResourceCollector(eventManager); new ProfilingCollection(eventManager, sessionManager, config, hooks); replayCollection = new ReplayCollection(eventManager, config, sessionManager, hooks); diff --git a/src/instrument/ipc.spec.ts b/src/instrument/ipc.spec.ts index bd08e2d7..d12be8b0 100644 --- a/src/instrument/ipc.spec.ts +++ b/src/instrument/ipc.spec.ts @@ -1,32 +1,66 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { MockInstance } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { IpcChannelMessage } from './ipc'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFn = (...args: any[]) => any; -const mockSpan = { setTag: vi.fn(), finish: vi.fn() }; -const mockScope = { - activate: vi.fn((_, fn: () => unknown) => fn()), - active: vi.fn<() => object | null>(() => null), -}; -const mockDdTrace = { - startSpan: vi.fn(() => mockSpan), - extract: vi.fn<() => object | null>(() => null), - scope: vi.fn(() => mockScope), -}; +describe('ipc event handler sharing across separate module instances', () => { + afterEach(async () => { + const { setIpcEventHandler } = await import('./ipc'); + setIpcEventHandler(() => undefined); + }); + + it('publishes events set on one module instance to patches applied via a separate instance', async () => { + // The `instrument` and `index` entry points are bundled independently, so ipc.ts is inlined as two + // separate, non-shared copies of its module code in the same process (e.g. IpcResourceCollector in + // the `index` bundle calling setIpcEventHandler, while patchIpcMain/patchWebContents run from the + // `instrument` bundle). vi.resetModules() + two dynamic imports simulates that: each import() + // returns a distinct module instance with its own closures, module-level `let`s, etc. Only a + // globalThis-backed (Symbol.for-keyed) handler slot is actually shared between them — this test + // would fail against a plain module-level variable, which is the bug this regression test targets. + vi.resetModules(); + const instrumentBundle = await import('./ipc'); + vi.resetModules(); + const indexBundle = await import('./ipc'); + expect(instrumentBundle).not.toBe(indexBundle); -vi.mock('../entries/instrument-prelude', () => ({ default: mockDdTrace })); + const received: IpcChannelMessage[] = []; + indexBundle.setIpcEventHandler((message) => received.push(message)); + + const ipcMain: Record = { + addListener: vi.fn(), + handle: vi.fn((_ch: string, l: AnyFn) => { + ipcMain._wrappedHandle = l; + }), + handleOnce: vi.fn(), + off: vi.fn(), + on: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + removeHandler: vi.fn(), + removeListener: vi.fn(), + }; + instrumentBundle.patchIpcMain(ipcMain as unknown as Electron.IpcMain); + ipcMain.handle('ping', vi.fn()); + + ipcMain._wrappedHandle({}, { __ddIpcId: 'cross-bundle-call' }); + + expect(received).toEqual([expect.objectContaining({ id: 'cross-bundle-call', channel: 'ping' })]); + }); +}); describe('patchIpcMain', () => { beforeEach(() => { vi.resetModules(); - vi.clearAllMocks(); - // clearAllMocks resets call history but not implementations; restore the span mock defaults so - // resilience tests that make setTag/finish throw do not leak into later tests. - mockSpan.setTag.mockReset(); - mockSpan.finish.mockReset(); - mockScope.activate.mockImplementation((_, fn: () => unknown) => fn()); + }); + + afterEach(async () => { + // The event handler is module-level, global state (no diagnostics_channel to unsubscribe from), + // so it must not leak a registration into a later test. + const { setIpcEventHandler } = await import('./ipc'); + setIpcEventHandler(() => undefined); }); // Each vi.fn() captures the wrapped listener the wrapper passes to it in _wrapped. @@ -76,49 +110,95 @@ describe('patchIpcMain', () => { } const listenerMethods = [ - { method: 'on', spanName: 'electron.main.receive', storageKey: 'on' }, - { method: 'addListener', spanName: 'electron.main.receive', storageKey: 'addListener' }, + { method: 'on', expectedMethod: 'on', storageKey: 'on' }, + { method: 'addListener', expectedMethod: 'on', storageKey: 'addListener' }, // `once` registers its wrapper through the raw addListener (not Node's `once`) to avoid the // double-wrapping that would otherwise happen when Node's `once` delegates to the patched `on`. - { method: 'once', spanName: 'electron.main.receive', storageKey: 'addListener' }, - { method: 'handle', spanName: 'electron.main.handle', storageKey: 'handle' }, + { method: 'once', expectedMethod: 'on', storageKey: 'addListener' }, + { method: 'handle', expectedMethod: 'handle', storageKey: 'handle' }, // handleOnce is not patched (it delegates to the patched `handle`); covered by a dedicated // real-delegation test below, which the independent mock methods cannot model. ] as const; it.each(listenerMethods)( - 'creates a $spanName consumer span for $method', - async ({ method, spanName, storageKey }) => { - const { patchIpcMain } = await import('./ipc'); + 'publishes a destination-role event for $method when the appended id carrier is present', + async ({ method, expectedMethod, storageKey }) => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); (ipcMain[method] as unknown as AnyFn)('ping', vi.fn()); - ipcMain._wrapped[`${storageKey}:ping`]({}); - expect(mockDdTrace.startSpan).toHaveBeenCalledWith( - spanName, + ipcMain._wrapped[`${storageKey}:ping`]({}, { __ddIpcId: 'call-1' }); + + expect(received).toEqual([ expect.objectContaining({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - tags: expect.objectContaining({ 'span.kind': 'consumer', component: 'electron', 'span.type': 'worker' }), - }) - ); - expect(mockSpan.finish).toHaveBeenCalled(); + role: 'destination', + id: 'call-1', + method: expectedMethod, + channel: 'ping', + error: false, + }), + ]); } ); - it.each(listenerMethods)('does not create a span for datadog: prefixed channels on $method', async ({ method }) => { - const { patchIpcMain } = await import('./ipc'); + it.each(listenerMethods)( + 'does not publish an event for $method when no id carrier is appended', + async ({ method, storageKey }) => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); + const ipcMain = makeMockIpcMain(); + patchIpcMain(ipcMain as unknown as Electron.IpcMain); + const handler = vi.fn(); + (ipcMain[method] as unknown as AnyFn)('ping', handler); + + ipcMain._wrapped[`${storageKey}:ping`]({}); + + expect(received).toEqual([]); + expect(handler).toHaveBeenCalled(); + } + ); + + it.each(listenerMethods)( + 'does not publish an event for datadog: prefixed channels on $method', + async ({ method }) => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); + const ipcMain = makeMockIpcMain(); + patchIpcMain(ipcMain as unknown as Electron.IpcMain); + const handler = vi.fn(); + (ipcMain[method] as unknown as AnyFn)('datadog:bridge-send', handler); + ipcMain._wrapped[`${method}:datadog:bridge-send`]?.({}, { __ddIpcId: 'call-x' }); + expect(received).toEqual([]); + expect(handler).toHaveBeenCalled(); + } + ); + + it('extracts the appended carrier and strips it before the real handler runs', async () => { + const handler = vi.fn().mockResolvedValue('ok'); + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); - const handler = vi.fn(); - (ipcMain[method] as unknown as AnyFn)('datadog:bridge-send', handler); - ipcMain._wrapped[`${method}:datadog:bridge-send`]?.({}); - expect(mockDdTrace.startSpan).not.toHaveBeenCalled(); - expect(handler).toHaveBeenCalled(); + (ipcMain.handle as unknown as AnyFn)('get-profile', handler); + + await ipcMain._wrapped['handle:get-profile']({} /* event */, 'userId123', { __ddIpcId: 'call-abc' }); + + expect(received).toEqual([ + expect.objectContaining({ role: 'destination', id: 'call-abc', method: 'handle', channel: 'get-profile' }), + ]); + expect(handler).toHaveBeenCalledWith(expect.anything(), 'userId123'); }); - it('sets span error tag and finishes when handler throws synchronously', async () => { - const { patchIpcMain } = await import('./ipc'); + it('publishes an event with error true when handler throws synchronously', async () => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const err = new Error('boom'); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); @@ -128,21 +208,18 @@ describe('patchIpcMain', () => { throw err; }) ); - try { - ipcMain._wrapped['handle:ping']({}); - } catch { - /* expected */ - } - expect(mockSpan.setTag).toHaveBeenCalledWith('error', err); - expect(mockSpan.finish).toHaveBeenCalled(); + expect(() => { + ipcMain._wrapped['handle:ping']({}, { __ddIpcId: 'call-2' }); + }).toThrow(err); + expect(received).toEqual([expect.objectContaining({ id: 'call-2', error: true })]); }); - it('preserves the app handler result when an SDK hook throws (finish throws)', async () => { - // A tracing failure must not affect the value the app returns from the handler. - mockSpan.finish.mockImplementation(() => { - throw new Error('finish boom'); + it('preserves the app handler result when the event handler throws', async () => { + // A failure publishing the event must not affect the value the app returns from the handler. + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + setIpcEventHandler(() => { + throw new Error('handler boom'); }); - const { patchIpcMain } = await import('./ipc'); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); (ipcMain.handle as unknown as AnyFn)( @@ -151,18 +228,17 @@ describe('patchIpcMain', () => { ); let result: unknown; expect(() => { - result = ipcMain._wrapped['handle:ping']({}); + result = ipcMain._wrapped['handle:ping']({}, { __ddIpcId: 'call-3' }); }).not.toThrow(); expect(result).toBe('app-result'); }); - it('still propagates the app error when an SDK hook throws on the throw path', async () => { - // The app handler error must still surface even if tagging the span fails. - mockSpan.setTag.mockImplementation(() => { - throw new Error('setTag boom'); + it('still propagates the app error when the event handler also throws', async () => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + setIpcEventHandler(() => { + throw new Error('handler boom'); }); - const appErr = new Error('handler boom'); - const { patchIpcMain } = await import('./ipc'); + const appErr = new Error('handler err'); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); (ipcMain.handle as unknown as AnyFn)( @@ -172,12 +248,14 @@ describe('patchIpcMain', () => { }) ); expect(() => { - ipcMain._wrapped['handle:ping']({}); + ipcMain._wrapped['handle:ping']({}, { __ddIpcId: 'call-4' }); }).toThrow(appErr); }); - it('finishes span after promise resolves', async () => { - const { patchIpcMain } = await import('./ipc'); + it('publishes the event after the promise resolves', async () => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); let resolve!: () => void; @@ -185,16 +263,18 @@ describe('patchIpcMain', () => { 'ping', vi.fn(() => new Promise((r) => (resolve = r))) ); - const result = ipcMain._wrapped['handle:ping']({}) as Promise; - expect(mockSpan.finish).not.toHaveBeenCalled(); + const result = ipcMain._wrapped['handle:ping']({}, { __ddIpcId: 'call-5' }) as Promise; + expect(received).toEqual([]); resolve(); await result; await Promise.resolve(); - expect(mockSpan.finish).toHaveBeenCalled(); + expect(received).toEqual([expect.objectContaining({ id: 'call-5', error: false })]); }); - it('finishes span with error tag after promise rejects', async () => { - const { patchIpcMain } = await import('./ipc'); + it('publishes the event with error true after the promise rejects', async () => { + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const err = new Error('async boom'); const ipcMain = makeMockIpcMain(); patchIpcMain(ipcMain as unknown as Electron.IpcMain); @@ -202,15 +282,14 @@ describe('patchIpcMain', () => { 'ping', vi.fn(() => Promise.reject(err)) ); - await (ipcMain._wrapped['handle:ping']({}) as Promise).catch(() => null); + await (ipcMain._wrapped['handle:ping']({}, { __ddIpcId: 'call-6' }) as Promise).catch(() => null); await Promise.resolve(); - expect(mockSpan.setTag).toHaveBeenCalledWith('error', err); - expect(mockSpan.finish).toHaveBeenCalled(); + expect(received).toEqual([expect.objectContaining({ id: 'call-6', error: true })]); }); - it('does not extract a carrier from the payload and passes all arguments through untouched', async () => { - // A last argument that looks like a trace carrier belongs to the app: the SDK does not inject - // one into IPC, so it must not be extracted or stripped from the handler arguments. + it('does not extract or strip a last argument that is not an id carrier', async () => { + // A last argument that looks like a trace carrier belongs to the app: only an object shaped like + // { __ddIpcId: string } is treated as our own appended carrier. const handler = vi.fn(); const { patchIpcMain } = await import('./ipc'); const ipcMain = makeMockIpcMain(); @@ -218,22 +297,9 @@ describe('patchIpcMain', () => { (ipcMain.handle as unknown as AnyFn)('ping', handler); const carrierLike = { 'x-datadog-trace-id': '123' }; ipcMain._wrapped['handle:ping']({}, 'payload', carrierLike); - expect(mockDdTrace.extract).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalledWith({}, 'payload', carrierLike); }); - it('parents the consumer span to the active scope', async () => { - const activeSpan = { id: 'active' }; - mockScope.active.mockReturnValue(activeSpan); - const ipcMain = await setup(); - ipcMain._wrapped['handle:ping']({}); - expect(mockDdTrace.startSpan).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ childOf: activeSpan }) - ); - mockScope.active.mockReturnValue(null); - }); - it('removeListener passes the wrapped listener (not the original) to the underlying method', async () => { const { patchIpcMain } = await import('./ipc'); const ipcMain = makeMockIpcMain(); @@ -328,17 +394,11 @@ describe('patchIpcMain', () => { it('preserves process unhandledRejection for a rejecting async on() listener (real EventEmitter)', async () => { // A fire-and-forget receive listener that returns a rejecting promise must still surface via // process 'unhandledRejection' (which the SDK ErrorCollection listens on). Swallowing the - // rejection while finishing the span would silently drop the app's error reporting. + // rejection while publishing the event would silently drop the app's error reporting. const { patchIpcMain } = await import('./ipc'); const ipcMain = makeRealIpcMain(); patchIpcMain(ipcMain); - // vi.fn records the promises returned through it, which marks their rejection as handled. Swap in - // a plain scope.activate so the wrapper's returned promise is genuinely unhandled, matching real - // dd-trace behavior; restore the spy afterwards. - const originalActivate = mockScope.activate; - mockScope.activate = ((_: unknown, fn: () => unknown) => fn()) as unknown as typeof mockScope.activate; - // Temporarily take over unhandledRejection so the real rejection is captured here instead of // failing the test runner, then restore the previous listeners. const previous = process.listeners('unhandledRejection'); @@ -361,7 +421,6 @@ describe('patchIpcMain', () => { } finally { process.removeListener('unhandledRejection', capture); previous.forEach((l) => process.on('unhandledRejection', l)); - mockScope.activate = originalActivate; } expect(captured).toContain(rejection); @@ -408,23 +467,25 @@ describe('patchIpcMain', () => { expect(handler).not.toHaveBeenCalled(); }); - it('emits exactly one receive span for a once() listener (no double-wrap) (real EventEmitter)', async () => { + it('invokes the listener exactly once for a once() listener (no double-wrap) (real EventEmitter)', async () => { // Node's once() is implemented via this.on(); since `on` is patched, patching `once` to delegate - // through it would wrap the listener twice and emit nested duplicate spans. The SDK registers the - // once wrapper via the raw addListener instead, so exactly one span is produced. - const { patchIpcMain } = await import('./ipc'); + // through it would wrap the listener twice and publish duplicate events. The SDK registers the + // once wrapper via the raw addListener instead, so exactly one event is published. + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const ipcMain = makeRealIpcMain(); patchIpcMain(ipcMain); const cb = vi.fn(); ipcMain.once('foo', cb); - ipcMain.emit('foo', {}); + ipcMain.emit('foo', {}, { __ddIpcId: 'call-once' }); expect(cb).toHaveBeenCalledTimes(1); - expect(mockDdTrace.startSpan).toHaveBeenCalledTimes(1); + expect(received).toHaveLength(1); // once fired: auto-removed, so nothing remains. expect(ipcMain.listenerCount('foo')).toBe(0); - ipcMain.emit('foo', {}); + ipcMain.emit('foo', {}, { __ddIpcId: 'call-once-2' }); expect(cb).toHaveBeenCalledTimes(1); }); @@ -449,10 +510,12 @@ describe('patchIpcMain', () => { it('does not double-wrap handleOnce, which delegates to the patched handle', async () => { // Electron implements handleOnce as this.handle(channel, bridge) where the bridge removes the // handler after the first call. Since `handle` is patched, patching handleOnce too would wrap the - // listener twice → nested duplicate electron.main.handle spans. handleOnce is left unpatched so it - // delegates to the patched handle, producing exactly one span. Mocks with independent methods - // cannot model this delegation, so this uses a fake that mirrors Electron's implementation. - const { patchIpcMain } = await import('./ipc'); + // listener twice → duplicate published events. handleOnce is left unpatched so it delegates to the + // patched handle, producing exactly one published event. Mocks with independent methods cannot + // model this delegation, so this uses a fake that mirrors Electron's implementation. + const { patchIpcMain, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const handlers: Record = {}; const ipcMain = { handle: (ch: string, fn: AnyFn) => { @@ -477,10 +540,9 @@ describe('patchIpcMain', () => { patchIpcMain(ipcMain as unknown as Electron.IpcMain); ipcMain.handleOnce('ping', vi.fn()); - handlers['ping']({}); + handlers['ping']({}, { __ddIpcId: 'call-handleonce' }); - expect(mockDdTrace.startSpan).toHaveBeenCalledTimes(1); - expect(mockDdTrace.startSpan).toHaveBeenCalledWith('electron.main.handle', expect.anything()); + expect(received).toEqual([expect.objectContaining({ id: 'call-handleonce', method: 'handle', channel: 'ping' })]); }); it('tracks distinct wrappers per registration of the same listener', async () => { @@ -515,12 +577,11 @@ describe('patchIpcMain', () => { describe('patchWebContents', () => { beforeEach(() => { vi.resetModules(); - vi.clearAllMocks(); - // clearAllMocks resets call history but not implementations; restore the span mock defaults so - // resilience tests that make setTag/finish throw do not leak into later tests. - mockSpan.setTag.mockReset(); - mockSpan.finish.mockReset(); - mockScope.active.mockReturnValue(null); + }); + + afterEach(async () => { + const { setIpcEventHandler } = await import('./ipc'); + setIpcEventHandler(() => undefined); }); function makeMockWebContents() { @@ -540,7 +601,7 @@ describe('patchWebContents', () => { } async function setup() { - const { patchWebContents } = await import('./ipc'); + const { patchWebContents, setIpcEventHandler } = await import('./ipc'); const wc = makeMockWebContents(); const sendSpy = wc.send; const sendToFrameSpy = wc.sendToFrame; @@ -549,7 +610,7 @@ describe('patchWebContents', () => { const instance = Object.create(BrowserWindow.prototype) as { webContents: ReturnType; }; - return { wc, instance, BrowserWindow, sendSpy, sendToFrameSpy }; + return { wc, instance, BrowserWindow, sendSpy, sendToFrameSpy, setIpcEventHandler }; } type SetupResult = Awaited>; @@ -585,89 +646,91 @@ describe('patchWebContents', () => { }, ]; - it.each(sendMethods)('creates a producer span for webContents.$name', async ({ invoke }) => { + it.each(sendMethods)('publishes a source-role event for webContents.$name', async ({ invoke, getSpy }) => { const result = await setup(); + const received: IpcChannelMessage[] = []; + result.setIpcEventHandler((message) => received.push(message)); invoke(result); - expect(mockDdTrace.startSpan).toHaveBeenCalledWith( - 'electron.main.send', - expect.objectContaining({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - tags: expect.objectContaining({ - 'span.kind': 'producer', - 'span.type': 'worker', - component: 'electron', - 'resource.name': 'my-channel', - }), - }) - ); - expect(mockSpan.finish).toHaveBeenCalled(); + expect(received).toEqual([ + expect.objectContaining({ role: 'source', method: 'send', channel: 'my-channel', error: false }), + ]); + // The generated id must be threaded through as the appended carrier on the underlying call. + const lastCallArgs = getSpy(result).mock.calls[0] as unknown[]; + const carrier = lastCallArgs[lastCallArgs.length - 1] as { __ddIpcId: string }; + expect(carrier.__ddIpcId).toBe(received[0].id); }); - it('does not append an extra carrier argument for webContents.send', async () => { + it('appends an id carrier as the last argument for webContents.send', async () => { const result = await setup(); result.instance.webContents.send('my-channel', 'arg1'); - expect(result.sendSpy).toHaveBeenCalledWith('my-channel', 'arg1'); - expect(result.sendSpy.mock.calls[0]).toEqual(['my-channel', 'arg1']); + expect(result.sendSpy).toHaveBeenCalledTimes(1); + const args = result.sendSpy.mock.calls[0] as unknown[]; + expect(args[0]).toBe('my-channel'); + expect(args[1]).toBe('arg1'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect(args[2]).toEqual({ __ddIpcId: expect.any(String), __ddParentIds: [] }); }); - it('does not append an extra carrier argument for webContents.sendToFrame', async () => { + it('appends an id carrier as the last argument for webContents.sendToFrame', async () => { const result = await setup(); result.instance.webContents.sendToFrame(1, 'my-channel', 'arg1'); - expect(result.sendToFrameSpy).toHaveBeenCalledWith(1, 'my-channel', 'arg1'); - expect(result.sendToFrameSpy.mock.calls[0]).toEqual([1, 'my-channel', 'arg1']); - }); - - it.each(sendMethods)('parents the producer span to the active scope for webContents.$name', async ({ invoke }) => { - const activeSpan = { id: 'active-handle-span' }; - mockScope.active.mockReturnValue(activeSpan); - const result = await setup(); - invoke(result); - expect(mockDdTrace.startSpan).toHaveBeenCalledWith( - 'electron.main.send', - expect.objectContaining({ childOf: activeSpan }) - ); + expect(result.sendToFrameSpy).toHaveBeenCalledTimes(1); + const args = result.sendToFrameSpy.mock.calls[0] as unknown[]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect(args).toEqual([1, 'my-channel', 'arg1', { __ddIpcId: expect.any(String), __ddParentIds: [] }]); }); it.each(sendMethods)( 'skips instrumentation for datadog: prefixed channels in $name', async ({ invokeDatadog, getSpy, datadogArgs }) => { const result = await setup(); + const received: IpcChannelMessage[] = []; + result.setIpcEventHandler((message) => received.push(message)); invokeDatadog(result); - expect(mockDdTrace.startSpan).not.toHaveBeenCalled(); + expect(received).toEqual([]); expect(getSpy(result)).toHaveBeenCalledWith(...datadogArgs); } ); it.each(sendMethods)( - 'sets span error tag and finishes when underlying $name throws synchronously', + 'publishes an event with error true when underlying $name throws synchronously', async ({ getSpy, invoke }) => { const err = new Error('send boom'); const result = await setup(); + const received: IpcChannelMessage[] = []; + result.setIpcEventHandler((message) => received.push(message)); getSpy(result).mockImplementation(() => { throw err; }); expect(() => invoke(result)).toThrow(err); - expect(mockSpan.setTag).toHaveBeenCalledWith('error', err); - expect(mockSpan.finish).toHaveBeenCalledTimes(1); + expect(received).toEqual([expect.objectContaining({ error: true })]); } ); - it('does not throw and still calls the original when an SDK hook throws (finish throws)', async () => { - // A tracing failure in the producer span must not break webContents.send. - mockSpan.finish.mockImplementation(() => { - throw new Error('finish boom'); - }); + it('does not throw and still calls the original when the event handler throws', async () => { + // A failure publishing the event must not break webContents.send. const result = await setup(); + result.setIpcEventHandler(() => { + throw new Error('handler boom'); + }); expect(() => { result.instance.webContents.send('my-channel', 'arg1'); }).not.toThrow(); - expect(result.sendSpy).toHaveBeenCalledWith('my-channel', 'arg1'); + + expect(result.sendSpy).toHaveBeenCalledWith( + 'my-channel', + 'arg1', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + { __ddIpcId: expect.any(String), __ddParentIds: [] } + ); }); it('patches the parent prototype when BrowserWindow is a subclass without own webContents getter', async () => { // Simulates the DatadogBrowserWindow scenario: patchBrowserWindow creates a subclass // and patchWebContents receives the subclass. The getter lives on the parent prototype. - const { patchWebContents } = await import('./ipc'); + const { patchWebContents, setIpcEventHandler } = await import('./ipc'); + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); const wc = makeMockWebContents(); const parentProto = { get webContents() { @@ -681,14 +744,92 @@ describe('patchWebContents', () => { expect(Object.getOwnPropertyDescriptor(subclassProto, 'webContents')).toBeUndefined(); const instance = Object.create(subclassProto) as { webContents: ReturnType }; instance.webContents.send('test-channel', 'arg'); - expect(mockDdTrace.startSpan).toHaveBeenCalledWith('electron.main.send', expect.any(Object)); + expect(received).toEqual([expect.objectContaining({ role: 'source', channel: 'test-channel' })]); }); it('only wraps webContents once when accessed multiple times', async () => { - const { instance } = await setup(); - instance.webContents.send('ch', 'a'); - vi.clearAllMocks(); - instance.webContents.send('ch', 'b'); - expect(mockDdTrace.startSpan).toHaveBeenCalledTimes(1); + const result = await setup(); + const received: IpcChannelMessage[] = []; + result.setIpcEventHandler((message) => received.push(message)); + result.instance.webContents.send('ch', 'a'); + received.length = 0; + result.instance.webContents.send('ch', 'b'); + expect(received).toHaveLength(1); + }); +}); + +describe('parent_ids propagation', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('a send triggered synchronously from within a destination handler inherits that handler as its parent', async () => { + const { patchIpcMain, patchWebContents, setIpcEventHandler } = await import('./ipc'); + + const wc = { send: vi.fn(), sendToFrame: vi.fn() }; + const rawSendSpy = wc.send; + const BrowserWindow = { + prototype: { + get webContents() { + return wc; + }, + }, + } as unknown as typeof Electron.BrowserWindow; + patchWebContents(BrowserWindow); + const instance = Object.create(BrowserWindow.prototype) as { webContents: typeof wc }; + + const _wrapped: Record unknown> = {}; + const ipcMain = { + addListener: vi.fn(), + handle: vi.fn((ch: string, l: (...args: unknown[]) => unknown) => { + _wrapped[`handle:${ch}`] = l; + }), + handleOnce: vi.fn(), + off: vi.fn(), + on: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + removeHandler: vi.fn(), + removeListener: vi.fn(), + }; + patchIpcMain(ipcMain as unknown as Electron.IpcMain); + + ipcMain.handle('ipc-demo:trigger', () => { + instance.webContents.send('ipc-demo:relay', 'payload'); + }); + + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); + + await _wrapped['handle:ipc-demo:trigger']({}, { __ddIpcId: 'call-A', __ddParentIds: [] }); + + const relaySource = received.find((m) => m.channel === 'ipc-demo:relay' && m.role === 'source'); + expect(relaySource?.parentIds).toEqual(['call-A']); + + const sentArgs = rawSendSpy.mock.calls[0] as unknown[]; + const carrier = sentArgs[sentArgs.length - 1] as { __ddParentIds: string[] }; + expect(carrier.__ddParentIds).toEqual(['call-A']); + }); + + it('a send made outside any destination handler has no parent ids', async () => { + const { patchWebContents, setIpcEventHandler } = await import('./ipc'); + + const wc = { send: vi.fn(), sendToFrame: vi.fn() }; + const BrowserWindow = { + prototype: { + get webContents() { + return wc; + }, + }, + } as unknown as typeof Electron.BrowserWindow; + patchWebContents(BrowserWindow); + const instance = Object.create(BrowserWindow.prototype) as { webContents: typeof wc }; + + const received: IpcChannelMessage[] = []; + setIpcEventHandler((message) => received.push(message)); + + instance.webContents.send('ipc-demo:standalone', 'payload'); + + expect(received).toEqual([expect.objectContaining({ parentIds: [] })]); }); }); diff --git a/src/instrument/ipc.ts b/src/instrument/ipc.ts index 3add2157..b930d81f 100644 --- a/src/instrument/ipc.ts +++ b/src/instrument/ipc.ts @@ -1,5 +1,7 @@ -import ddTrace from '../entries/instrument-prelude'; -import { callMonitored, monitorInstrumentation } from '../domain/telemetry'; +import { generateUUID } from '@datadog/browser-core'; +import { callMonitored } from '../domain/telemetry'; +import { isExcludedIpcChannel } from '../domain/tracing/ipcChannelFilter'; +import { withIpcContext, computeChildParentIds } from '../domain/tracing/ipcParentContext'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFn = (...args: any[]) => any; @@ -7,6 +9,68 @@ type IpcEvent = Electron.IpcMainEvent | Electron.IpcMainInvokeEvent; const wrappedWebContents = new WeakSet(); +export interface IpcChannelMessage { + role: 'source' | 'destination'; + id: string; + parentIds: string[]; + method: 'invoke' | 'handle' | 'send' | 'on'; + channel: string; + startTime: number; + duration: number; + error: boolean; +} + +// The `instrument` and `index` entry points are bundled independently (see instrumentElectron.ts's +// INSTRUMENTED symbol for the same class of problem), so this module is inlined as two separate, +// non-shared copies of its code in the same process: one where patchIpcMain/patchWebContents run, +// another where IpcResourceCollector calls setIpcEventHandler. A module-level `let` would give each +// copy its own private variable. Symbol.for() returns the same symbol across both copies, so keying +// the handler off globalThis makes it a truly shared slot. +const IPC_EVENT_HANDLER = Symbol.for('@datadog/electron-sdk:ipc-event-handler'); + +interface GlobalWithIpcEventHandler { + [IPC_EVENT_HANDLER]?: (message: IpcChannelMessage) => void; +} + +function getGlobalScope(): GlobalWithIpcEventHandler { + return globalThis as GlobalWithIpcEventHandler; +} + +// Set by IpcResourceCollector once it exists (during init()). ipc.ts's patches are applied at +// `instrument` time, before init() runs, so there is a window where this is unset — publishIpcEvent +// is a safe no-op during that window, the same way it would be if this were a diagnostics_channel +// with zero subscribers. +export function setIpcEventHandler(handler: (message: IpcChannelMessage) => void): void { + getGlobalScope()[IPC_EVENT_HANDLER] = handler; +} + +function publishIpcEvent(params: IpcChannelMessage): void { + getGlobalScope()[IPC_EVENT_HANDLER]?.(params); +} + +interface IpcIdCarrier { + __ddIpcId: string; + __ddParentIds: string[]; +} + +function isIpcIdCarrier(value: unknown): value is IpcIdCarrier { + return typeof value === 'object' && value !== null && typeof (value as IpcIdCarrier).__ddIpcId === 'string'; +} + +// Extracts an appended carrier from the end of an args array, if present, returning both the id and +// the args with the carrier stripped so the app's real handler/listener sees its original arity. +function extractIpcId(args: unknown[]): { id: string | undefined; parentIds: string[]; strippedArgs: unknown[] } { + const last = args[args.length - 1]; + if (isIpcIdCarrier(last)) { + return { id: last.__ddIpcId, parentIds: last.__ddParentIds ?? [], strippedArgs: args.slice(0, -1) }; + } + return { id: undefined, parentIds: [], strippedArgs: args }; +} + +function appendIpcId(args: unknown[], id: string, parentIds: string[]): unknown[] { + return [...args, { __ddIpcId: id, __ddParentIds: parentIds } satisfies IpcIdCarrier]; +} + export function patchIpcMain(ipcMain: Electron.IpcMain): void { // Null-prototype maps: IPC channel names are arbitrary user strings and may collide with // Object.prototype keys (__proto__, constructor, toString). On a plain object those names would @@ -74,7 +138,7 @@ function wrapAddListener( ): (addListener: AnyFn) => AnyFn { return (addListener) => function (this: unknown, ipcChannel: string, listener: AnyFn) { - if (ipcChannel.startsWith('datadog:')) { + if (isExcludedIpcChannel(ipcChannel)) { return addListener.call(this, ipcChannel, listener) as unknown; } @@ -95,65 +159,58 @@ function wrapAddListener( if (index !== -1) wrappers.splice(index, 1); } - // Start the span monitored. If it fails (or the SDK is not set up) the span is undefined and - // we run the listener raw so app behavior is preserved. We do not extract a carrier from the - // payload: the SDK does not inject one into IPC messages, so any trace-header-shaped last - // argument belongs to the app and must be passed through untouched. Carrier extraction will - // return together with renderer-side injection when ipcRenderer instrumentation lands. - const span = callMonitored(() => - ddTrace.startSpan(spanName, { - childOf: ddTrace.scope().active() ?? undefined, - tags: { - 'span.kind': 'consumer', - component: 'electron', - 'resource.name': ipcChannel, - 'span.type': 'worker', - }, - }) - ); + const { id, parentIds, strippedArgs } = extractIpcId(args); + const startTime = Date.now(); + const method = spanName === 'electron.main.handle' ? 'handle' : 'on'; - if (!span) { - return listener.call(this, event, ...args) as unknown; - } - - return ddTrace.scope().activate(span, () => { - let result: unknown; - try { - result = listener.call(this, event, ...args) as unknown; - } catch (err) { - // Tag the span monitored, then rethrow outside so invoke() rejections still propagate. - callMonitored(() => { - span.setTag('error', err); - span.finish(); - }); - throw err; - } - - if (isPromise(result)) { - // Return the settled-through promise (mirrors dd-trace's tracePromise): finish the span on - // settle, then re-reject so the rejection keeps propagating. For fire-and-forget receive - // listeners EventEmitter ignores this return, so re-rejecting preserves the app's (and the - // SDK ErrorCollection's) process 'unhandledRejection'; for handle/handleOnce it flows on to - // Electron, which forwards the error to the renderer. Swallowing it here (e.g. via monitor) - // would drop that rejection entirely. - return result.then( - (value) => { - callMonitored(() => span.finish()); - return value; - }, - (err: unknown) => { - callMonitored(() => { - span.setTag('error', err); - span.finish(); - }); - throw err; - } + const finish = (error: boolean) => { + if (id) { + callMonitored(() => + publishIpcEvent({ + role: 'destination', + id, + parentIds, + method, + channel: ipcChannel, + startTime, + duration: Date.now() - startTime, + error, + }) ); } + }; + + const callListener = () => listener.call(this, event, ...strippedArgs) as unknown; - callMonitored(() => span.finish()); - return result; - }); + let result: unknown; + try { + result = id ? withIpcContext(id, parentIds, callListener) : callListener(); + } catch (err) { + finish(true); + throw err; + } + + if (isPromise(result)) { + // Return the settled-through promise (mirrors dd-trace's tracePromise): publish the event on + // settle, then re-reject so the rejection keeps propagating. For fire-and-forget receive + // listeners EventEmitter ignores this return, so re-rejecting preserves the app's (and the + // SDK ErrorCollection's) process 'unhandledRejection'; for handle/handleOnce it flows on to + // Electron, which forwards the error to the renderer. Swallowing it here (e.g. via monitor) + // would drop that rejection entirely. + return result.then( + (value) => { + finish(false); + return value; + }, + (err: unknown) => { + finish(true); + throw err; + } + ); + } + + finish(false); + return result; }; wrappers.push(wrappedListener); @@ -206,46 +263,48 @@ function wrapSend(webContents: Electron.WebContents): void { wrappedWebContents.add(webContents); wrap(webContents, 'send', (original) => (channel: string, ...args: unknown[]) => { - if (channel.startsWith('datadog:')) { + if (isExcludedIpcChannel(channel)) { return original(channel, ...args) as unknown; } - return startSendSpan(channel, () => original(channel, ...args)); + return startSendWithIpcId(channel, args, (argsWithId) => original(channel, ...argsWithId)); }); wrap(webContents, 'sendToFrame', (original) => (frameId: unknown, channel: string, ...args: unknown[]) => { - if (channel.startsWith('datadog:')) { + if (isExcludedIpcChannel(channel)) { return original(frameId, channel, ...args) as unknown; } - return startSendSpan(channel, () => original(frameId, channel, ...args)); + return startSendWithIpcId(channel, args, (argsWithId) => original(frameId, channel, ...argsWithId)); }); } -// The producer span is created for main-side trace visibility, but the trace carrier is -// intentionally NOT injected into the payload: renderer-side ipcRenderer is not yet instrumented -// to consume/strip it, so injecting would mutate the app's IPC args (breaking channels that -// check arity or treat the last arg as options). Carrier injection must be re-added together -// with the matching renderer extraction when ipcRenderer instrumentation lands. -function startSendSpan(channel: string, invokeOriginal: () => unknown): unknown { - let span: ReturnType | undefined; - return monitorInstrumentation(({ onResult, onError }) => { - span = ddTrace.startSpan('electron.main.send', { - // childOf must be passed explicitly: dd-trace's startSpan() does not inherit the active - // scope automatically. This parents the send to whatever is active (e.g. the - // electron.main.handle span) when send is called from inside an IPC handler. - childOf: ddTrace.scope().active() ?? undefined, - tags: { - 'span.kind': 'producer', - 'span.type': 'worker', - component: 'electron', - 'resource.name': channel, - }, - }); - onError((err) => { - span?.setTag('error', err); - span?.finish(); - }); - onResult(() => span?.finish()); - }, invokeOriginal); +function startSendWithIpcId(channel: string, args: unknown[], invokeOriginal: (args: unknown[]) => unknown): unknown { + const id = generateUUID(); + const parentIds = computeChildParentIds(); + const startTime = Date.now(); + + const finish = (error: boolean) => { + callMonitored(() => + publishIpcEvent({ + role: 'source', + id, + parentIds, + method: 'send', + channel, + startTime, + duration: Date.now() - startTime, + error, + }) + ); + }; + + try { + const result = invokeOriginal(appendIpcId(args, id, parentIds)); + finish(false); + return result; + } catch (err) { + finish(true); + throw err; + } } function isPromise(value: unknown): value is Promise { diff --git a/src/preload/ipc.spec.ts b/src/preload/ipc.spec.ts new file mode 100644 index 00000000..49bfbfc0 --- /dev/null +++ b/src/preload/ipc.spec.ts @@ -0,0 +1,142 @@ +/// +/// + +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { invoke: vi.fn(), send: vi.fn(), on: vi.fn() }, +})); + +import { patchIpcRenderer } from './ipc'; + +describe('patchIpcRenderer', () => { + it('appends a generated ipc.id to invoke calls and calls the resource handler on settle', async () => { + const calls: unknown[][] = []; + const fakeIpcRenderer = { + invoke: vi.fn((_channel: string, ...args: unknown[]) => { + calls.push(args); + return Promise.resolve('ok'); + }), + send: vi.fn(), + on: vi.fn(), + }; + + const handler = vi.fn(); + const { registerResourceHandler } = patchIpcRenderer(fakeIpcRenderer); + registerResourceHandler(handler); + + await fakeIpcRenderer.invoke('get-profile', 'userId123'); + + // The real ipcRenderer.invoke was called with the id appended as the last argument. + expect(calls[0][0]).toBe('userId123'); + expect(calls[0][1]).toEqual({ + __ddIpcId: expect.any(String) as unknown, + __ddParentIds: [], + }); + const wireId = (calls[0][1] as { __ddIpcId: string }).__ddIpcId; + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ action: 'start', url: 'get-profile' })); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'stop', + url: 'get-profile', + options: expect.objectContaining({ + context: { ipc: { role: 'source', id: wireId, parent_ids: [], method: 'invoke' } }, + }) as unknown, + }) + ); + }); + + it('passes datadog: channels through untouched (no ipc.id, no resource events)', async () => { + const invokeCalls: unknown[][] = []; + const sendCalls: unknown[][] = []; + const onListener = vi.fn(); + const fakeIpcRenderer = { + invoke: vi.fn((_channel: string, ...args: unknown[]) => { + invokeCalls.push(args); + return Promise.resolve('ok'); + }), + send: vi.fn((_channel: string, ...args: unknown[]) => { + sendCalls.push(args); + }), + on: vi.fn((_channel: string, listener: (event: unknown, ...args: unknown[]) => void) => listener), + }; + + const handler = vi.fn(); + const { registerResourceHandler } = patchIpcRenderer(fakeIpcRenderer); + registerResourceHandler(handler); + + await fakeIpcRenderer.invoke('datadog:bridge-send', 'payload'); + fakeIpcRenderer.send('datadog:bridge-send', 'payload'); + const registeredListener = fakeIpcRenderer.on('datadog:bridge-send', onListener); + registeredListener('event', 'arg1'); + + expect(invokeCalls[0]).toEqual(['payload']); + expect(sendCalls[0]).toEqual(['payload']); + expect(onListener).toHaveBeenCalledWith('event', 'arg1'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('an invoke made synchronously from within an on listener inherits that listener as its parent', async () => { + const listeners: Record void> = {}; + const invokeCalls: unknown[][] = []; + let followUpPromise: Promise | undefined; + const fakeIpcRenderer = { + invoke: vi.fn((_channel: string, ...args: unknown[]) => { + invokeCalls.push(args); + return Promise.resolve('ok'); + }), + send: vi.fn(), + on: vi.fn((channel: string, listener: (event: unknown, ...args: unknown[]) => void) => { + listeners[channel] = listener; + }), + }; + + const events: unknown[] = []; + const { registerResourceHandler } = patchIpcRenderer(fakeIpcRenderer); + registerResourceHandler((event) => events.push(event)); + + fakeIpcRenderer.on('ipc-demo:ping-renderer', () => { + followUpPromise = fakeIpcRenderer.invoke('ipc-demo:follow-up'); + }); + listeners['ipc-demo:ping-renderer']('event', { __ddIpcId: 'call-A', __ddParentIds: [] }); + await followUpPromise; + + // The follow-up invoke's carrier must inherit ['call-A'] as its parent chain. + const followUpArgs = invokeCalls[0]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect(followUpArgs[0]).toEqual({ __ddIpcId: expect.any(String), __ddParentIds: ['call-A'] }); + + const followUpStop = events.find( + (e) => + (e as { url?: string; action?: string }).url === 'ipc-demo:follow-up' && + (e as { action?: string }).action === 'stop' + ) as { options?: { context?: { ipc?: { parent_ids?: string[] } } } }; + expect(followUpStop.options?.context?.ipc?.parent_ids).toEqual(['call-A']); + }); + + it('an invoke made outside any on listener has no parent ids', async () => { + const invokeCalls: unknown[][] = []; + const fakeIpcRenderer = { + invoke: vi.fn((_channel: string, ...args: unknown[]) => { + invokeCalls.push(args); + return Promise.resolve('ok'); + }), + send: vi.fn(), + on: vi.fn(), + }; + + const { registerResourceHandler } = patchIpcRenderer(fakeIpcRenderer); + registerResourceHandler(() => undefined); + + await fakeIpcRenderer.invoke('ipc-demo:standalone'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect(invokeCalls[0][0]).toEqual({ __ddIpcId: expect.any(String), __ddParentIds: [] }); + }); +}); diff --git a/src/preload/ipc.ts b/src/preload/ipc.ts new file mode 100644 index 00000000..0b77fd4c --- /dev/null +++ b/src/preload/ipc.ts @@ -0,0 +1,154 @@ +import { generateUUID } from '@datadog/browser-core'; +import { contextBridge, ipcRenderer } from 'electron'; +import type { ResourceHandler } from '../domain/tracing/ipcResourceBridgeTypes'; +import { isExcludedIpcChannel } from '../domain/tracing/ipcChannelFilter'; +import { withIpcContext, computeChildParentIds } from '../domain/tracing/ipcParentContext'; + +export type { ResourceHandlerEvent, ResourceHandler } from '../domain/tracing/ipcResourceBridgeTypes'; + +interface IpcRendererLike { + invoke: (channel: string, ...args: unknown[]) => Promise; + send: (channel: string, ...args: unknown[]) => void; + on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => unknown; +} + +interface IpcIdCarrier { + __ddIpcId: string; + __ddParentIds: string[]; +} + +function isIpcIdCarrier(value: unknown): value is IpcIdCarrier { + return typeof value === 'object' && value !== null && typeof (value as IpcIdCarrier).__ddIpcId === 'string'; +} + +function extractIpcId(args: unknown[]): { id: string | undefined; parentIds: string[]; strippedArgs: unknown[] } { + const last = args[args.length - 1]; + if (isIpcIdCarrier(last)) { + return { id: last.__ddIpcId, parentIds: last.__ddParentIds ?? [], strippedArgs: args.slice(0, -1) }; + } + return { id: undefined, parentIds: [], strippedArgs: args }; +} + +export function patchIpcRenderer(ipcRendererLike: IpcRendererLike): { + registerResourceHandler: (handler: ResourceHandler) => void; +} { + let handler: ResourceHandler | undefined; + + const rawInvoke = ipcRendererLike.invoke.bind(ipcRendererLike); + const rawSend = ipcRendererLike.send.bind(ipcRendererLike); + const rawOn = ipcRendererLike.on.bind(ipcRendererLike); + + ipcRendererLike.invoke = (channel: string, ...args: unknown[]) => { + if (isExcludedIpcChannel(channel)) { + return rawInvoke(channel, ...args); + } + + const id = generateUUID(); + const parentIds = computeChildParentIds(); + handler?.({ action: 'start', url: channel }); + return rawInvoke(channel, ...args, { __ddIpcId: id, __ddParentIds: parentIds }).then( + (value) => { + handler?.({ + action: 'stop', + url: channel, + options: { context: { ipc: { role: 'source', id, parent_ids: parentIds, method: 'invoke' } } }, + }); + return value; + }, + (err: unknown) => { + handler?.({ + action: 'stop', + url: channel, + options: { + context: { ipc: { role: 'source', id, parent_ids: parentIds, method: 'invoke', error: true } }, + }, + }); + throw err; + } + ); + }; + + ipcRendererLike.send = (channel: string, ...args: unknown[]) => { + if (isExcludedIpcChannel(channel)) { + rawSend(channel, ...args); + return; + } + + const id = generateUUID(); + const parentIds = computeChildParentIds(); + handler?.({ action: 'start', url: channel }); + rawSend(channel, ...args, { __ddIpcId: id, __ddParentIds: parentIds }); + handler?.({ + action: 'stop', + url: channel, + options: { context: { ipc: { role: 'source', id, parent_ids: parentIds, method: 'send' } } }, + }); + }; + + ipcRendererLike.on = (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => { + if (isExcludedIpcChannel(channel)) { + return rawOn(channel, listener); + } + + return rawOn(channel, (event: unknown, ...args: unknown[]) => { + const { id, parentIds, strippedArgs } = extractIpcId(args); + handler?.({ action: 'start', url: channel }); + const callListener = () => listener(event, ...strippedArgs); + try { + if (id) { + withIpcContext(id, parentIds, callListener); + } else { + callListener(); + } + if (id) { + handler?.({ + action: 'stop', + url: channel, + options: { context: { ipc: { role: 'destination', id, parent_ids: parentIds, method: 'on' } } }, + }); + } + } catch (err) { + if (id) { + handler?.({ + action: 'stop', + url: channel, + options: { + context: { ipc: { role: 'destination', id, parent_ids: parentIds, method: 'on', error: true } }, + }, + }); + } + throw err; + } + }); + }; + + return { + registerResourceHandler(newHandler: ResourceHandler) { + handler = newHandler; + }, + }; +} + +declare const window: Record; + +const DD_IPC_BRIDGE_INIT = '__dd_ipc_bridge_initialized'; + +if (!window[DD_IPC_BRIDGE_INIT]) { + window[DD_IPC_BRIDGE_INIT] = true; + + const { registerResourceHandler } = patchIpcRenderer(ipcRenderer); + + const ipcBridge = { + registerResourceHandler(handler: ResourceHandler): void { + registerResourceHandler(handler); + }, + }; + + window.DatadogIpcBridge = ipcBridge; + + try { + contextBridge.exposeInMainWorld('DatadogIpcBridge', ipcBridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } +} diff --git a/src/renderer/datadogRendererPlugin.spec.ts b/src/renderer/datadogRendererPlugin.spec.ts new file mode 100644 index 00000000..63e9de81 --- /dev/null +++ b/src/renderer/datadogRendererPlugin.spec.ts @@ -0,0 +1,53 @@ +/// +/// + +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { ResourceHandler } from '../domain/tracing/ipcResourceBridgeTypes'; +import { datadogRendererPlugin } from './datadogRendererPlugin'; + +afterEach(() => { + delete (window as { DatadogIpcBridge?: unknown }).DatadogIpcBridge; +}); + +describe('datadogRendererPlugin', () => { + it('exposes the RumPlugin name/onInit shape and forwards start/stop events to the RUM public API', () => { + let registeredHandler: ResourceHandler | undefined; + window.DatadogIpcBridge = { + registerResourceHandler: (handler) => { + registeredHandler = handler; + }, + }; + + const plugin = datadogRendererPlugin(); + expect(plugin.name).toBe('electron-ipc-bridge'); + + const publicApi = { startResource: vi.fn(), stopResource: vi.fn() }; + plugin.onInit!({ publicApi }); + + registeredHandler!({ action: 'start', url: 'ipc-demo:get-profile' }); + expect(publicApi.startResource).toHaveBeenCalledWith('ipc-demo:get-profile', { type: 'native' }); + + registeredHandler!({ + action: 'stop', + url: 'ipc-demo:get-profile', + options: { context: { ipc: { role: 'source', id: 'call-abc', method: 'invoke' } } }, + }); + expect(publicApi.stopResource).toHaveBeenCalledWith('ipc-demo:get-profile', { + type: 'native', + context: { ipc: { role: 'source', id: 'call-abc', method: 'invoke' } }, + }); + }); + + it('does nothing (no throw) when window.DatadogIpcBridge is not present', () => { + delete (window as { DatadogIpcBridge?: unknown }).DatadogIpcBridge; + const publicApi = { startResource: vi.fn(), stopResource: vi.fn() }; + + const plugin = datadogRendererPlugin(); + expect(() => plugin.onInit!({ publicApi })).not.toThrow(); + expect(publicApi.startResource).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/datadogRendererPlugin.ts b/src/renderer/datadogRendererPlugin.ts new file mode 100644 index 00000000..8240deb2 --- /dev/null +++ b/src/renderer/datadogRendererPlugin.ts @@ -0,0 +1,57 @@ +import type { ResourceHandlerEvent } from '../domain/tracing/ipcResourceBridgeTypes'; + +declare global { + interface Window { + DatadogIpcBridge?: { + registerResourceHandler: (handler: (event: ResourceHandlerEvent) => void) => void; + }; + } +} + +/** + * A structural subset of `@datadog/browser-rum`'s public `startResource`/`stopResource` API. Defined + * locally (rather than importing `@datadog/browser-rum`'s real types) so this package has no runtime + * or type dependency on browser-rum. `options` is loosened to `Record` rather than + * browser-rum's real `ResourceOptions`/`ResourceStopOptions`, whose `type` field is a closed enum with + * no `'native'` member (see IpcResourceCollector's correction note) — the real `datadogRum` object is + * still structurally assignable to this interface. + */ +export interface IpcRumResourceApi { + startResource(url: string, options?: Record): void; + stopResource(url: string, options?: Record): void; +} + +/** + * A structural subset of `@datadog/browser-rum-core`'s real `RumPlugin` interface (`plugins.ts`, + * itself marked `@experimental`/unstable). Redeclared locally, rather than imported, so this package + * has no dependency on browser-rum-core. Only the `onInit` hook is used, and its `publicApi` parameter + * is narrowed to `IpcRumResourceApi` — the two stable public methods this plugin needs — instead of + * the full `RumPublicApi` surface, which we have no reason to depend on here. + */ +export interface DatadogRendererPlugin { + name: string; + onInit?(options: { publicApi: IpcRumResourceApi }): void; +} + +/** + * A `RumPlugin` that connects the SDK's preload-exposed `window.DatadogIpcBridge` (see + * `src/preload/ipc.ts`) to `datadogRum`, so IPC resource events reach RUM without every app needing to + * hand-write this wiring. Register it via `datadogRum.init({ ..., plugins: [datadogRendererPlugin()] })`. + * + * A no-op if `window.DatadogIpcBridge` isn't present (e.g. `contextIsolation` disabled, or this SDK's + * preload script wasn't loaded). + */ +export function datadogRendererPlugin(): DatadogRendererPlugin { + return { + name: 'electron-ipc-bridge', + onInit({ publicApi }) { + window.DatadogIpcBridge?.registerResourceHandler((event) => { + if (event.action === 'start') { + publicApi.startResource(event.url, { type: 'native' }); + } else { + publicApi.stopResource(event.url, { type: 'native', context: event.options?.context }); + } + }); + }, + }; +}