Skip to content

✨ [RUM-18031] represent electron IPC calls as correlated RUM resource events (prototype) - #203

Draft
bcaudan wants to merge 23 commits into
mainfrom
bcaudan/ipc
Draft

✨ [RUM-18031] represent electron IPC calls as correlated RUM resource events (prototype)#203
bcaudan wants to merge 23 commits into
mainfrom
bcaudan/ipc

Conversation

@bcaudan

@bcaudan bcaudan commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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 to
see what an IPC call triggered on the other side, or what it caused further down the chain.

Changes

  • Main-process IPC instrumentation (ipcMain.handle/on, webContents.send/sendToFrame) now
    publishes lifecycle events instead of creating dd-trace spans; IpcResourceCollector turns them into
    RUM resource events (type: 'native', context.ipc.{role,id,method,parent_ids}resource.type has
    no 'ipc' enum value in the real schema, so identity lives entirely in context.ipc).
  • New renderer-side preload module wraps ipcRenderer.invoke/send/on, correlating both sides via an
    appended ipc.id carrier, and a new @datadog/electron-sdk/renderer entry (datadogRendererPlugin)
    wires it to datadogRum with zero dependency on @datadog/browser-rum's package.
  • context.ipc.parent_ids lets a customer follow the causal chain of an IPC call (e.g. a broadcast
    relay, 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.
  • Playground demonstrates every IPC shape (request/response, both fire-and-forget directions, nested,
    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_ids chains, not
    just mocked ones.

Test instructions

  1. From the electron-sdk root: yarn build, then cd playground && yarn install && yarn dev.
  2. Click through the "IPC Scenarios (prototype)" buttons — Request/Response, both fire-and-forget
    directions, Nested IPC, then "Open broadcast windows" followed by "Broadcast to opened windows" —
    and watch the IPC activity log update for each.
  3. Click "Open in RUM Explorer" and confirm the resource events show up with context.ipc correlating
    both sides of each call by id, and (for nested/broadcast) parent_ids linking each causally
    triggered call back to the one that spawned it.

Checklist

  • Tested locally (playground)
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated related documentation.
  • Agentic code review findings addressed or explicitly dismissed.

bcaudan added 23 commits August 14, 2026 11:51
- 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');
@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Aug 19, 2026

Copy link
Copy Markdown

Pipelines

Unblock PR with BitsAI

⚠️ Warnings

🚦 4 Pipeline jobs failed

DataDog/electron-sdk | e2e — 🔧 Needs a code fix, caused by this PR

View in Datadog · View in GitLab

DataDog/electron-sdk | integration — 🔧 Needs a code fix, caused by this PR

View in Datadog · View in GitLab

DataDog/electron-sdk | lint

View in Datadog · View in GitLab

View all 4 failed jobs.

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 2ddd682 | Docs | View more details | Give us feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants