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 });
+ }
+ });
+ },
+ };
+}