✨ [RUM-18031] represent electron IPC calls as correlated RUM resource events (prototype) - #203
Draft
bcaudan wants to merge 23 commits into
Draft
✨ [RUM-18031] represent electron IPC calls as correlated RUM resource events (prototype)#203bcaudan wants to merge 23 commits into
bcaudan wants to merge 23 commits into
Conversation
- ipc.ts no longer creates dd-trace spans for ipcMain.handle/on/once/
addListener or webContents.send/sendToFrame; it instead publishes
IpcChannelMessage lifecycle data through a module-level
setIpcEventHandler callback for a later RUM-event consumer.
- Adds ipc.id generation (webContents.send/sendToFrame, source role)
and extraction (ipcMain handlers/listeners, destination role) via a
trailing appended { __ddIpcId } carrier, stripped before the app's
real handler/listener runs.
- Updates ipc.spec.ts's pre-existing span assertions to assert on the
new event handler invocation instead.
Implements Task 7: adds trigger API, receiver listeners, and button to playground demo. Uses mainWindow.webContents.send (not event.sender.send) for SDK instrumentation via patchWebContents getter.
- ipcMain handler for get-profile-with-progress sends progress via mainWindow.webContents.send (not event.sender.send) before fetching - Preload exposes getProfileWithProgress() and onProfileProgress() listener - Renderer wires button #ipc-nested-profile and progress listener - HTML button added to IPC Scenarios section
- main.ts: two hidden BrowserWindows created on demand, ipc-demo:broadcast handler fans out to both via webContents.send - preload.ts: expose broadcast/onBroadcastReceived - renderer.ts/index.html: #ipc-broadcast button and log wiring - renderer.ts: wire window.DatadogIpcBridge.registerResourceHandler to datadogRum.startResource/stopResource, so IPC resource events built in earlier tasks actually reach the browser RUM SDK
ipc.ts is inlined separately into the instrument and index entry-point bundles, so a module-level `let ipcEventHandler` gave each bundle its own private copy: IpcResourceCollector's setIpcEventHandler() (index bundle) never reached publishIpcEvent() (instrument bundle), silently dropping every main-process IPC event. - Key the handler off a Symbol.for()-keyed slot on globalThis instead of a module-level variable, matching instrumentElectron.ts's INSTRUMENTED guard for the same cross-bundle-instance problem. - Add a regression test that obtains two separate module instances via vi.resetModules() and proves an event set through one instance's setIpcEventHandler is observed by patches applied through the other.
Covers all 5 playground IPC scenarios (request/response, both fire-and-forget directions, nested, broadcast) plus a network correlation check, asserting on RUM resource events captured by the fake intake: - shared ipc.id across source/destination pairs - correct role/method values per side - nested IPC's timestamp falling within the parent handle's window - independent id pairs per broadcast relay - real network resources correlating by time window with the destination handler's IPC event
…ring Cast 'native' to ResourceType and context to Context to match browser-rum's public API type expectations. Runtime behavior unchanged — only type-level fix for the startResource/stopResource calls in the IPC bridge handler.
- ping-main's second listener reports a main-side error instead of a fetch - get-profile-with-progress wraps its fetch in a main-side duration vital - ping-renderer's second listener reports a renderer-side duration vital - broadcast-received handler reports a renderer-side error Demonstrates axis 2.B's time-window pivoting with error/vital events, not just resources.
…events - ping-main's 2nd listener keeps its fetch alongside the reported error - trigger-ping-renderer and broadcast handlers each fire a non-blocking fetch (fire-and-forget, matching ping-main's pattern) so every scenario still produces at least one network resource Non-blocking (void fetch().catch()) rather than awaited: awaiting real network calls before relaying IPC messages caused cascading timeouts across the single-worker E2E suite.
…rce bridge Previously every host app had to hand-write the wiring between window.DatadogIpcBridge (Task 4's preload bridge) and datadogRum.startResource/ stopResource, including knowledge of the 'native' resource.type workaround. Adds wireIpcResourceBridge(rum), a new renderer entry point apps call once with their own already-initialized datadogRum instance. No dependency on @datadog/browser-rum from this package — IpcRumResourceApi is a local structural interface the real datadogRum object satisfies. Extracts ResourceHandlerEvent/ResourceHandler into a shared, side-effect-free types module (src/domain/tracing/ipcResourceBridgeTypes.ts) so the new renderer module doesn't import src/preload/ipc.ts, which pulls in electron's contextBridge/ipcRenderer and isn't safe to bundle into a main-world script. Updates the playground to use the new entry instead of its own hand-rolled wiring.
…tion
Replaces wireIpcResourceBridge(datadogRum) with datadogRendererPlugin(),
registered via datadogRum.init({ ..., plugins: [datadogRendererPlugin()] })
instead of a separate call after init().
Uses browser-rum-core's RumPlugin.onInit hook, which receives publicApi —
the same datadogRum object, with its stable public startResource/stopResource
methods — rather than the internal addEvent/RawRumResourceEvent path (large,
versioned, explicitly not part of the public API). Still no dependency on
@datadog/browser-rum-core: RumPlugin/DatadogRendererPlugin/IpcRumResourceApi
are all redeclared locally as structural interfaces, same trick as before.
Tradeoff: RumPlugin itself is marked @experimental in browser-rum-core, so
this does depend on that registration mechanism staying usable, even though
none of its internal types are imported.
playground/package.json's portal-linked deps pull in browser-sdk's @datadog/rum-events-format (a git-sourced dependency), which yarn 4.13+ gates behind explicit approval for non-interactive installs.
Playground's get-internal-context lookup (used to display session_id) is not one of the ipc-demo:* scenarios, and firing on every load/refresh cluttered demo RUM data. Extracted the existing datadog:-prefix exclusion (already applied in both src/instrument/ipc.ts and src/preload/ipc.ts) into a shared isExcludedIpcChannel() helper and added get-internal-context to it. Demo-only special case, not general SDK behavior — flagged in the helper's own comment as something to revisit (e.g. an app-configurable exclusion list) before this graduates past prototype status.
ensureBroadcastWindows() now awaits each window's loadURL() before the broadcast handler sends to it. Previously the relay send fired immediately on window creation, before the renderer's ipcRenderer.on listener was registered — Electron silently drops a send with no listener, so the very first broadcast click lost both its destination-side resource event and its addError call, with every click after (reusing the now-loaded windows) working fine. Removes the E2E test's warm-up-click workaround, which is no longer needed now that the real app handles this correctly.
When a destination handler (id A) triggers a new outgoing IPC call (id B) synchronously, B's carrier and RUM events now carry parent_ids: [A, ...], letting a customer walk the full causal chain of an IPC call, not just correlate one call's two sides. Implementation: src/domain/tracing/ipcParentContext.ts, a small synchronous-only 'current call' tracker (withIpcContext/computeChildParentIds) shared unmodified by both src/instrument/ipc.ts (main) and src/preload/ipc.ts (renderer/preload) — plain JS, no node:async_hooks, so it works regardless of preload sandboxing. Context is restored as soon as the wrapped listener returns synchronously; a nested call made after an internal await (or from an overlapping concurrent handler) won't see the real parent chain. This is an accepted, documented limitation for this prototype, not silently wrong: it falls back to an empty parent_ids, never a corrupted one. The broadcast scenario actually hit this gap: its handler used to await opening its two helper windows before relaying, clearing the context before the sends fired. Fixed by splitting window lifecycle out of the broadcast action entirely — a new 'Open broadcast windows' button/channel (ipc-demo:open-broadcast-windows, excluded from RUM like get-internal-context) opens and loads them ahead of time, so the broadcast handler stays fully synchronous and never needs to await anything.
'Broadcast (fan-out to 2 renderers)' predates the ipc-demo:open-broadcast-windows split and no longer describes what the button does on its own.
| // 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'); |
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Electron IPC (main↔renderer) is currently only visible via APM spans, which requires tracing to be
enabled and doesn't survive sampling or the absence of an APM product. This prototype validates
representing IPC calls as RUM resource events instead — correlated across processes by a shared
ipc.id— so IPC visibility no longer depends on tracing, and a customer can pivot from one process tosee what an IPC call triggered on the other side, or what it caused further down the chain.
Changes
ipcMain.handle/on,webContents.send/sendToFrame) nowpublishes lifecycle events instead of creating dd-trace spans;
IpcResourceCollectorturns them intoRUM resource events (
type: 'native',context.ipc.{role,id,method,parent_ids}—resource.typehasno
'ipc'enum value in the real schema, so identity lives entirely incontext.ipc).ipcRenderer.invoke/send/on, correlating both sides via anappended
ipc.idcarrier, and a new@datadog/electron-sdk/rendererentry (datadogRendererPlugin)wires it to
datadogRumwith zero dependency on@datadog/browser-rum's package.context.ipc.parent_idslets a customer follow the causal chain of an IPC call (e.g. a broadcastrelay, or a nested progress push), not just correlate one call's two sides — computed via a small,
dependency-free "current call" tracker shared unmodified by main and preload.
broadcast) with real network calls, errors, and vitals fired from inside handlers, each proven
end-to-end via Playwright tests against a fake intake — including the actual
parent_idschains, notjust mocked ones.
Test instructions
yarn build, thencd playground && yarn install && yarn dev.directions, Nested IPC, then "Open broadcast windows" followed by "Broadcast to opened windows" —
and watch the IPC activity log update for each.
context.ipccorrelatingboth sides of each call by
id, and (for nested/broadcast)parent_idslinking each causallytriggered call back to the one that spawned it.
Checklist