diff --git a/package.json b/package.json index 3a2ae267..d2e884b4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "types": "./dist/preload.d.ts", "require": "./dist/preload-auto.cjs", "import": "./dist/preload.mjs" + }, + "./utility": { + "require": "./dist/utility-preload.cjs" } }, "typesVersions": { @@ -40,6 +43,8 @@ "dist/preload.d.ts", "dist/preload.mjs", "dist/preload.mjs.map", + "dist/utility-preload.cjs", + "dist/utility-preload.cjs.map", "README.md", "LICENSE", "LICENSE-3rdparty.csv" @@ -56,6 +61,7 @@ "typecheck": "tsc -b --noEmit", "dev:init": "cd playground && yarn install", "dev": "scripts/cli dev", + "playground:test": "yarn build && cd playground && yarn test", "test": "vitest", "test:unit": "vitest run --coverage", "test:e2e:init": "yarn build && cd e2e/app && yarn install && yarn build", diff --git a/playground/README.md b/playground/README.md index 423c0949..7802bd79 100644 --- a/playground/README.md +++ b/playground/README.md @@ -45,6 +45,42 @@ This will: - Reload Electron when files change - Open DevTools by default +## Testing + +Automated Playwright tests validate playground scenarios against a mock intake server. + +### Run Tests + +```bash +# From playground/ +yarn test + +# Or from root +yarn playground:test +``` + +### Creating a Scenario + +1. **Add a button + IPC handler** in `src/main.ts` and `src/preload.ts` for the feature you want to test +2. **Write a test** in `test/.scenario.ts`: + +```typescript +import { test, expect } from './helpers'; + +test('description', async ({ window, intake }) => { + // Click a button in the playground + await window.click('#my-button'); + + // Assert events arrived at the mock intake + const events = await intake.getEventsByType('resource', 10_000); + expect(events.length).toBeGreaterThanOrEqual(1); +}); +``` + +3. **Run** `yarn test` and iterate + +The test harness auto-launches the playground app with SDK events routed to a mock intake server via the `DD_SDK_PROXY` env var. + ## Development with SDK Changes From the root directory, run: diff --git a/playground/package.json b/playground/package.json index 4ff4710b..4d6bd082 100644 --- a/playground/package.json +++ b/playground/package.json @@ -9,7 +9,8 @@ "build:renderer": "esbuild src/renderer.ts --bundle --format=esm --outfile=dist/renderer.js --sourcemap", "build:watch": "concurrently \"tsc --watch\" \"yarn build:renderer --watch\"", "start": "yarn build && electron .", - "dev": "yarn build && concurrently \"yarn build:watch\" \"electron .\"" + "dev": "yarn build && concurrently \"yarn build:watch\" \"electron .\"", + "test": "yarn build && playwright test -c test/playwright.config.ts" }, "dependencies": { "@datadog/browser-rum": "6.30.1", diff --git a/playground/src/index.html b/playground/src/index.html index 3ed83c74..266f450e 100644 --- a/playground/src/index.html +++ b/playground/src/index.html @@ -172,6 +172,9 @@

Electron SDK Playground

Session File Content:

Loading...
+
SDK Actions
@@ -184,6 +187,27 @@

Session File Content:

+
Child Process
+
+ + + + +
+ +
Utility Process
+
+ + + + +
+ +
Renderer Process
+
+ +
+
Fetch
diff --git a/playground/src/main.ts b/playground/src/main.ts index 721d5e8e..c3a39fca 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -1,11 +1,13 @@ -import { app, BrowserWindow, ipcMain } from 'electron'; +import { app, BrowserWindow, ipcMain, utilityProcess } from 'electron'; import * as path from 'node:path'; import * as fs from 'node:fs'; import * as https from 'node:https'; -import { init, stopSession, _generateActivity, _generateTelemetryError } from '@datadog/electron-sdk'; +import * as childProcess from 'node:child_process'; +import { init, stopSession, _generateActivity, _generateTelemetryError, _flushTransport } from '@datadog/electron-sdk'; import { loadWindowState, saveWindowState } from './main/windowState'; import { setupHotReload } from './main/hotReload'; +const isTestMode = process.env.DD_TEST_MODE === '1'; let mainWindow: BrowserWindow | null = null; function getSessionFilePath(): string { @@ -20,6 +22,7 @@ function createWindow() { height: savedState?.height ?? 768, x: savedState?.x, y: savedState?.y, + show: !isTestMode, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -56,6 +59,10 @@ ipcMain.handle('get-session-file', () => { } }); +ipcMain.handle('flushTransport', async () => { + await _flushTransport(); +}); + ipcMain.handle('stop-session', () => { stopSession(); }); @@ -102,6 +109,117 @@ ipcMain.handle('crash', () => { process.crash(); }); +// --- Child process demo handlers --- + +ipcMain.handle('child-process:spawn-ls', () => { + return new Promise((resolve, reject) => { + const child = childProcess.spawn('ls', ['-la']); + let stdout = ''; + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + child.on('close', () => resolve(stdout)); + child.on('error', reject); + }); +}); + +ipcMain.handle('child-process:exec-echo', () => { + return new Promise((resolve, reject) => { + childProcess.exec('echo hello world', (error, stdout) => { + if (error) reject(error); + else resolve(stdout.trim()); + }); + }); +}); + +ipcMain.handle('child-process:spawn-fail', () => { + return new Promise((resolve) => { + const child = childProcess.spawn('nonexistent-command-xyz'); + child.on('error', (err) => resolve(`Error: ${err.message}`)); + child.on('close', (code) => resolve(`Exited with code ${code}`)); + }); +}); + +ipcMain.handle('child-process:exec-timeout', () => { + return new Promise((resolve) => { + childProcess.exec('sleep 10', { timeout: 100 }, (error) => { + resolve(error ? `Timeout: ${error.message}` : 'Completed'); + }); + }); +}); + +// --- Utility process demo handlers --- + +const WORKER_PATH = path.join(__dirname, 'workers', 'demo-worker.js'); + +ipcMain.handle('utility-process:fork', () => { + return new Promise((resolve) => { + const child = utilityProcess.fork(WORKER_PATH, [], { serviceName: 'dd-demo-fork' }); + child.once('message', (msg: { ready?: boolean }) => { + if (msg.ready) resolve(`Worker forked, pid: ${child.pid}`); + }); + child.once('exit', (code: number) => resolve(`Worker exited with code ${code}`)); + }); +}); + +ipcMain.handle('utility-process:send-message', () => { + return new Promise((resolve) => { + const child = utilityProcess.fork(WORKER_PATH, [], { serviceName: 'dd-demo-message' }); + child.on('message', (msg: { ready?: boolean; reply?: string }) => { + if (msg.ready) { + child.postMessage({ action: 'ping' }); + } else if (msg.reply) { + resolve(`Reply: ${msg.reply}`); + child.kill(); + } + }); + child.once('exit', () => resolve('Worker exited')); + }); +}); + +ipcMain.handle('utility-process:crash', () => { + return new Promise((resolve) => { + const child = utilityProcess.fork(WORKER_PATH, [], { serviceName: 'dd-demo-crash-worker' }); + child.once('message', (msg: { ready?: boolean }) => { + if (msg.ready) { + child.postMessage({ action: 'crash' }); + } + }); + child.once('exit', (code: number) => resolve(`Worker crashed, exit code: ${code}`)); + }); +}); + +ipcMain.handle('utility-process:throw-error', () => { + return new Promise((resolve) => { + const child = utilityProcess.fork(WORKER_PATH, [], { serviceName: 'dd-demo-throw-worker' }); + child.once('message', (msg: { ready?: boolean }) => { + if (msg.ready) child.postMessage({ action: 'throw-error' }); + }); + child.once('exit', (code: number) => resolve(`Worker exited with code: ${code}`)); + }); +}); + +// --- Renderer process demo handlers --- + +ipcMain.handle('renderer-process:crash', () => { + return new Promise((resolve) => { + const win = new BrowserWindow({ + width: 400, + height: 300, + show: !isTestMode, + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + void win.loadURL('about:blank'); + win.webContents.once('did-finish-load', () => { + // Wait for at least one renderer poll cycle so the process is tracked + setTimeout(() => { + win.webContents.forcefullyCrashRenderer(); + resolve('Renderer crashed'); + }, 3000); + }); + }); +}); + void app.whenReady().then(async () => { // Initialize SDK on app ready (before window creation) console.log('Initializing SDK from main process...'); @@ -121,6 +239,8 @@ void app.whenReady().then(async () => { ...CONF.staging, service: 'electron-playground', env: 'dev', + // Allow tests to redirect events to a mock intake server + ...(process.env.DD_SDK_PROXY ? { proxy: process.env.DD_SDK_PROXY } : {}), }); console.log('SDK init result:', result); diff --git a/playground/src/preload.ts b/playground/src/preload.ts index 66d572be..77d330e7 100644 --- a/playground/src/preload.ts +++ b/playground/src/preload.ts @@ -11,4 +11,14 @@ contextBridge.exposeInMainWorld('electronAPI', { generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'), crash: () => ipcRenderer.invoke('crash'), mainFetchApi: () => ipcRenderer.invoke('main:fetch-api'), + flushTransport: () => ipcRenderer.invoke('flushTransport'), + spawnLs: () => ipcRenderer.invoke('child-process:spawn-ls'), + execEcho: () => ipcRenderer.invoke('child-process:exec-echo'), + spawnFail: () => ipcRenderer.invoke('child-process:spawn-fail'), + execTimeout: () => ipcRenderer.invoke('child-process:exec-timeout'), + forkUtility: () => ipcRenderer.invoke('utility-process:fork'), + sendMessage: () => ipcRenderer.invoke('utility-process:send-message'), + crashUtility: () => ipcRenderer.invoke('utility-process:crash'), + throwUtilityError: () => ipcRenderer.invoke('utility-process:throw-error'), + crashRenderer: () => ipcRenderer.invoke('renderer-process:crash'), }); diff --git a/playground/src/renderer.ts b/playground/src/renderer.ts index 85688d1b..f6e1863b 100644 --- a/playground/src/renderer.ts +++ b/playground/src/renderer.ts @@ -13,7 +13,7 @@ datadogRum.init({ sessionSampleRate: 100, trackResources: true, trackLongTasks: true, - trackUserInteractions: true, + trackUserInteractions: false, }); // Type definition for the exposed API @@ -26,6 +26,16 @@ interface ElectronAPI { generateUnhandledRejection: () => Promise; crash: () => Promise; mainFetchApi: () => Promise; + flushTransport: () => Promise; + forkUtility: () => Promise; + sendMessage: () => Promise; + crashUtility: () => Promise; + throwUtilityError: () => Promise; + crashRenderer: () => Promise; + spawnLs: () => Promise; + execEcho: () => Promise; + spawnFail: () => Promise; + execTimeout: () => Promise; } declare global { @@ -68,6 +78,18 @@ async function refreshSessionDisplay() { } } +const copySessionIdBtn = document.getElementById('copy-session-id') as HTMLButtonElement; +copySessionIdBtn.addEventListener('click', () => { + try { + const parsed = JSON.parse(sessionContent.textContent ?? ''); + if (parsed.id) { + void navigator.clipboard.writeText(parsed.id as string); + } + } catch { + // Session content not valid JSON + } +}); + if (stopBtn && sessionContent) { // Load session file content on page load void refreshSessionDisplay(); @@ -200,3 +222,18 @@ if (rendererFetchBtn) { } setupDemoButton('main-fetch', 'main:fetch-api', () => window.electronAPI.mainFetchApi()); + +// Utility process buttons +setupDemoButton('fork-utility', 'utility-process:fork', () => window.electronAPI.forkUtility()); +setupDemoButton('send-message', 'utility-process:send-message', () => window.electronAPI.sendMessage()); +setupDemoButton('crash-utility', 'utility-process:crash', () => window.electronAPI.crashUtility()); +setupDemoButton('throw-utility-error', 'utility-process:throw-error', () => window.electronAPI.throwUtilityError()); + +// Renderer process buttons +setupDemoButton('crash-renderer', 'renderer-process:crash', () => window.electronAPI.crashRenderer()); + +// Child process buttons +setupDemoButton('spawn-ls', 'child-process:spawn-ls', () => window.electronAPI.spawnLs()); +setupDemoButton('exec-echo', 'child-process:exec-echo', () => window.electronAPI.execEcho()); +setupDemoButton('spawn-fail', 'child-process:spawn-fail', () => window.electronAPI.spawnFail()); +setupDemoButton('exec-timeout', 'child-process:exec-timeout', () => window.electronAPI.execTimeout()); diff --git a/playground/src/workers/demo-worker.ts b/playground/src/workers/demo-worker.ts new file mode 100644 index 00000000..54108903 --- /dev/null +++ b/playground/src/workers/demo-worker.ts @@ -0,0 +1,27 @@ +/** + * Demo utility process worker for playground testing. + * Receives messages via parentPort and responds. + */ + +// Enable Datadog SDK error forwarding for this utility process +// eslint-disable-next-line @typescript-eslint/no-require-imports +require('@datadog/electron-sdk/utility'); + +process.parentPort.on('message', (e: Electron.MessageEvent) => { + const { action } = e.data as { action: string }; + + if (action === 'ping') { + process.parentPort.postMessage({ reply: 'pong' }); + } else if (action === 'crash') { + process.crash(); + } else if (action === 'exit-clean') { + process.exit(0); + } else if (action === 'exit-error') { + process.exit(1); + } else if (action === 'throw-error') { + throw new Error('Uncaught error in utility process'); + } +}); + +// Signal that the worker is ready +process.parentPort.postMessage({ ready: true }); diff --git a/playground/test/child-process.scenario.ts b/playground/test/child-process.scenario.ts new file mode 100644 index 00000000..3e5596c0 --- /dev/null +++ b/playground/test/child-process.scenario.ts @@ -0,0 +1,63 @@ +import { test, expect, flushTransport } from './helpers'; +import type { RumResourceEvent } from '@datadog/electron-sdk'; + +function filterResources(events: { body: unknown }[], urlPattern: string): { body: unknown }[] { + return events.filter((e) => (e.body as RumResourceEvent).resource.url.includes(urlPattern)); +} + +test('spawn ls produces exactly one resource event', async ({ window, intake }) => { + await window.click('#spawn-ls'); + await window.waitForTimeout(1000); + await flushTransport(window); + + const resources = await intake.getEventsByType('resource', 10_000); + const lsResources = filterResources(resources, 'spawn://ls'); + + expect(lsResources).toHaveLength(1); + const body = lsResources[0].body as RumResourceEvent; + expect(body.resource.type).toBe('native'); + expect(body.resource.status_code).toBe(0); + expect(body.resource.duration).toBeGreaterThan(0); +}); + +test('exec echo produces exactly one resource event', async ({ window, intake }) => { + await window.click('#exec-echo'); + await window.waitForTimeout(1000); + await flushTransport(window); + + const resources = await intake.getEventsByType('resource', 10_000); + const echoResources = filterResources(resources, 'echo hello world'); + + expect(echoResources).toHaveLength(1); + const body = echoResources[0].body as RumResourceEvent; + expect(body.resource.type).toBe('native'); + expect(body.resource.status_code).toBe(0); +}); + +test('spawn fail produces exactly one resource event with error', async ({ window, intake }) => { + await window.click('#spawn-fail'); + await window.waitForTimeout(1000); + await flushTransport(window); + + const resources = await intake.getEventsByType('resource', 10_000); + const failResources = filterResources(resources, 'nonexistent-command-xyz'); + + expect(failResources).toHaveLength(1); + const body = failResources[0].body as RumResourceEvent; + expect(body.resource.type).toBe('native'); + expect(body.resource.status_code).toBe(-1); +}); + +test('exec timeout produces exactly one resource event with error', async ({ window, intake }) => { + await window.click('#exec-timeout'); + await window.waitForTimeout(1000); + await flushTransport(window); + + const resources = await intake.getEventsByType('resource', 10_000); + const timeoutResources = filterResources(resources, 'sleep 10'); + + expect(timeoutResources).toHaveLength(1); + const body = timeoutResources[0].body as RumResourceEvent; + expect(body.resource.type).toBe('native'); + expect(body.resource.status_code).not.toBe(0); +}); diff --git a/playground/test/helpers.ts b/playground/test/helpers.ts new file mode 100644 index 00000000..ac52e460 --- /dev/null +++ b/playground/test/helpers.ts @@ -0,0 +1,62 @@ +import { test as base, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'; +import { join } from 'node:path'; +import { Intake } from '../../e2e/lib/intake'; + +// Get electron executable path from the playground's node_modules +// eslint-disable-next-line @typescript-eslint/no-require-imports +const electronPath = require(join(__dirname, '../node_modules/electron')) as string; + +export interface TestFixtures { + electronApp: ElectronApplication; + window: Page; + intake: Intake; +} + +/** + * Custom Playwright test with playground app fixtures. + * Launches the playground app pointed at a mock intake server. + */ +export const test = base.extend({ + intake: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const intake = new Intake(); + await intake.start(); + await use(intake); + await intake.stop(); + }, + { option: true }, + ], + + electronApp: async ({ intake }, use) => { + const electronApp = await electron.launch({ + executablePath: electronPath, + args: [join(__dirname, '../dist/main.js')], + env: { + ...process.env, + DD_TEST_MODE: '1', + // Override SDK config to point at mock intake + DD_SDK_PROXY: `http://localhost:${intake.getPort()}/api/v2/rum`, + } as Record, + }); + await use(electronApp); + await electronApp.close(); + }, + + window: async ({ electronApp }, use) => { + const window = await electronApp.firstWindow(); + window.on('console', (msg) => console.log('Playground console:', msg.text())); + await window.waitForLoadState('load'); + await window.waitForTimeout(500); + await use(window); + }, +}); + +export { expect } from '@playwright/test'; + +/** Flush SDK transport so buffered events reach the mock intake. */ +export async function flushTransport(window: Page): Promise { + await window.evaluate(() => + (window as unknown as { electronAPI: { flushTransport(): Promise } }).electronAPI.flushTransport() + ); +} diff --git a/playground/test/playwright.config.ts b/playground/test/playwright.config.ts new file mode 100644 index 00000000..e3181ad0 --- /dev/null +++ b/playground/test/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.scenario.ts', + timeout: 30000, + workers: 1, + retries: 0, + reporter: 'list', + use: { + trace: 'on-first-retry', + }, +}); diff --git a/playground/test/renderer-process.scenario.ts b/playground/test/renderer-process.scenario.ts new file mode 100644 index 00000000..e09e7f5d --- /dev/null +++ b/playground/test/renderer-process.scenario.ts @@ -0,0 +1,71 @@ +import { test, expect, flushTransport } from './helpers'; +import type { RumViewEvent, RumErrorEvent } from '@datadog/electron-sdk'; + +test('renderer process view has page title', async ({ window, intake }) => { + await window.waitForTimeout(3000); + await flushTransport(window); + + const viewEvents = await intake.getEventsByType('view', 10_000); + const rendererView = viewEvents.find((e) => ((e.body as RumViewEvent).view.name ?? '').startsWith('Renderer:')); + + expect(rendererView).toBeDefined(); + const body = rendererView!.body as RumViewEvent; + expect(body.view.id).toBeDefined(); + expect(body.view.is_active).toBe(true); + expect(body.view.name).toContain('Electron SDK Playground'); +}); + +test('browser-rum view URL is sanitized', async ({ window, intake }) => { + await window.waitForTimeout(3000); + await flushTransport(window); + + const viewEvents = await intake.getEventsByType('view', 10_000); + const browserRumView = viewEvents.find((e) => (e.body as RumViewEvent & { source?: string }).source === 'browser'); + + expect(browserRumView).toBeDefined(); + const browserBody = browserRumView!.body as RumViewEvent; + expect(browserBody.view.url).toBeDefined(); + expect(browserBody.view.url).not.toContain('/Users/'); + // App path (including dist/) is stripped by sanitizeAppPaths + expect(browserBody.view.url).toBe('file:///index.html'); +}); + +test('renderer process view starts before or at the same time as browser-rum view', async ({ window, intake }) => { + await window.waitForTimeout(3000); + await flushTransport(window); + + const viewEvents = await intake.getEventsByType('view', 10_000); + + // Find the latest renderer process view update (highest document version has the corrected date) + const rendererProcessViews = viewEvents.filter((e) => + ((e.body as RumViewEvent).view.name ?? '').startsWith('Renderer:') + ); + // Find the browser-rum view (emitted by browser-rum in the renderer, has source: 'browser') + const browserRumView = viewEvents.find((e) => (e.body as RumViewEvent & { source?: string }).source === 'browser'); + + expect(rendererProcessViews.length).toBeGreaterThanOrEqual(1); + expect(browserRumView).toBeDefined(); + + // Use the latest view update — it has the most accurate (backdated) startTime + const latestRendererView = rendererProcessViews[rendererProcessViews.length - 1]; + const rendererDate = (latestRendererView.body as RumViewEvent).date; + const browserDate = (browserRumView!.body as RumViewEvent).date; + expect(rendererDate).toBeLessThanOrEqual(browserDate); +}); + +test('crash renderer produces an error on renderer view', async ({ window, intake }) => { + await window.click('#crash-renderer'); + // Wait for: 3s delay in handler before crash + 2s for events to propagate + await window.waitForTimeout(6000); + await flushTransport(window); + + const errorEvents = await intake.getEventsByType('error', 10_000); + const rendererError = errorEvents.find((e) => + ((e.body as RumErrorEvent).error.message ?? '').includes('Renderer process') + ); + + expect(rendererError).toBeDefined(); + const body = rendererError!.body as RumErrorEvent; + expect(body.error.source).toBe('source'); + expect(body.error.handling).toBe('unhandled'); +}); diff --git a/playground/test/smoke.scenario.ts b/playground/test/smoke.scenario.ts new file mode 100644 index 00000000..d61f8e27 --- /dev/null +++ b/playground/test/smoke.scenario.ts @@ -0,0 +1,16 @@ +import { test, expect, flushTransport } from './helpers'; +import type { RumViewEvent } from '@datadog/electron-sdk'; + +test('app launches and a view event arrives at intake', async ({ window, intake }) => { + await flushTransport(window); + + const viewEvents = await intake.getEventsByType('view', 10_000); + + expect(viewEvents.length).toBeGreaterThanOrEqual(1); + + const body = viewEvents[0].body as RumViewEvent; + expect(body.type).toBe('view'); + expect(body.view.id).toBeDefined(); + expect(body.application.id).toBeDefined(); + expect(body.session.id).toBeDefined(); +}); diff --git a/playground/test/tsconfig.json b/playground/test/tsconfig.json new file mode 100644 index 00000000..a9f68ad4 --- /dev/null +++ b/playground/test/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "lib": ["ES2020"], + "types": ["node"], + "baseUrl": ".", + "paths": { + "@datadog/electron-sdk": ["../../dist/index.d.ts"] + }, + "composite": true, + "emitDeclarationOnly": true + }, + "include": ["**/*.ts", "../../e2e/lib/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/playground/test/utility-process.scenario.ts b/playground/test/utility-process.scenario.ts new file mode 100644 index 00000000..44e31451 --- /dev/null +++ b/playground/test/utility-process.scenario.ts @@ -0,0 +1,96 @@ +import { test, expect, flushTransport } from './helpers'; +import type { RumViewEvent, RumErrorEvent } from '@datadog/electron-sdk'; + +test('fork utility produces a view event', async ({ window, intake }) => { + await window.click('#fork-utility'); + await window.waitForTimeout(2000); + await flushTransport(window); + + const viewEvents = await intake.getEventsByType('view', 10_000); + const utilityView = viewEvents.find((e) => ((e.body as RumViewEvent).view.name ?? '').includes('Utility:')); + + expect(utilityView).toBeDefined(); + const body = utilityView!.body as RumViewEvent; + expect(body.view.name).toContain('dd-demo-fork'); + expect(body.view.id).toBeDefined(); +}); + +test('fork utility produces view updates with memory metrics', async ({ window, intake }) => { + await window.click('#fork-utility'); + // Wait long enough for at least one metrics poll (2s interval) + await window.waitForTimeout(4000); + await flushTransport(window); + + const viewEvents = await intake.getEventsByType('view', 10_000); + // Find a view update that has memory fields (from metrics poll) + const viewWithMemory = viewEvents.find((e) => { + const body = e.body as RumViewEvent; + return (body.view.name ?? '').includes('dd-demo-fork') && body.view.memory_average !== undefined; + }); + + expect(viewWithMemory).toBeDefined(); + const body = viewWithMemory!.body as RumViewEvent; + expect(body.view.memory_average).toBeGreaterThan(0); + expect(body.view.memory_max).toBeGreaterThan(0); +}); + +test('throw error in utility produces an error event with stack trace', async ({ window, intake }) => { + await window.click('#throw-utility-error'); + await window.waitForTimeout(3000); + await flushTransport(window); + + const errorEvents = await intake.getEventsByType('error', 10_000); + const thrownError = errorEvents.find((e) => + ((e.body as RumErrorEvent).error.message ?? '').includes('Uncaught error in utility process') + ); + + expect(thrownError).toBeDefined(); + const errorBody = thrownError!.body as RumErrorEvent; + expect(errorBody.error.source).toBe('source'); + expect(errorBody.error.handling).toBe('unhandled'); + expect(errorBody.error.stack).toBeDefined(); + expect(errorBody.error.stack!.length).toBeGreaterThan(0); + + // Verify the error is linked to the utility process view + const viewEvents = await intake.getEventsByType('view', 10_000); + const utilityView = viewEvents.find( + (e) => + ((e.body as RumViewEvent).view.name ?? '').includes('dd-demo-throw-worker') && + !(e.body as RumViewEvent).view.is_active + ); + + expect(utilityView).toBeDefined(); + const viewBody = utilityView!.body as RumViewEvent; + expect(viewBody.view.error.count).toBeGreaterThanOrEqual(1); + // Error should be linked to the same view + expect(errorBody.view.id).toBe(viewBody.view.id); +}); + +test('crash utility produces an error event linked to utility view', async ({ window, intake }) => { + await window.click('#crash-utility'); + await window.waitForTimeout(3000); + await flushTransport(window); + + // Wait for error event first — it proves the crash was processed + const errorEvents = await intake.getEventsByType('error', 10_000); + const processError = errorEvents.find((e) => + ((e.body as RumErrorEvent).error.message ?? '').includes('dd-demo-crash-worker') + ); + + expect(processError).toBeDefined(); + const errorBody = processError!.body as RumErrorEvent; + expect(errorBody.error.source).toBe('source'); + expect(errorBody.error.handling).toBe('unhandled'); + + // Find the final view update (is_active: false) which should have the error count + const viewEvents = await intake.getEventsByType('view', 10_000); + const finalView = viewEvents.find( + (e) => + ((e.body as RumViewEvent).view.name ?? '').includes('dd-demo-crash-worker') && + !(e.body as RumViewEvent).view.is_active + ); + + expect(finalView).toBeDefined(); + const viewBody = finalView!.body as RumViewEvent; + expect(viewBody.view.error.count).toBeGreaterThanOrEqual(1); +}); diff --git a/prototype-child-processes_00_research.md b/prototype-child-processes_00_research.md new file mode 100644 index 00000000..3a3679e0 --- /dev/null +++ b/prototype-child-processes_00_research.md @@ -0,0 +1,422 @@ +# Child Process Monitoring — Landscape Survey + +Research hub for evaluating how the Datadog Electron SDK could monitor Electron child processes. + +## Priority Matrix + +> Updated after prototyping. See `prototype-child-processes_01_prototypes.md` for detailed findings. + +| # | Mechanism | Customer Likelihood | Monitoring Value | Feasibility (validated) | **Priority** | +| --- | ----------------------------------- | ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------ | ----------------- | +| 1 | Electron lifecycle events | Universal | High | **Trivial.** Event listeners only, zero risk. Validated. | **P0 — do first** | +| 2 | `child_process.spawn/exec/execFile` | Very High | Very High | **Feasible with caveats.** Requires `require()` + `Object.defineProperty`. Dedup and bundler concerns. | **P0** | +| 3 | Electron `utilityProcess` | High, growing fast | High | **Fully feasible.** Fork wrapper + MessagePort telemetry channel. Validated. | **P1** | +| 4 | `child_process.fork` | Medium, declining | High | Same approach as spawn. Not prototyped but expected to work. | **P2** | +| 5 | `worker_threads` | Moderate (~20-30%) | Medium-High | Not prototyped. Constructor patch likely works, child injection hard. | **P3** | +| 6 | `MessagePort/MessageChannel` | Growing (~15-25%) | Low as target | **Validated as transport** for utility process telemetry. Skip as monitoring target. | N/A (transport) | + +--- + +## 1. child_process.spawn / exec / execFile + +### What it is + +Node.js APIs for executing external commands and binaries: + +- **`spawn(command, args, options)`** — Launches a process, returns a `ChildProcess` with streaming stdio. No shell by default. +- **`exec(command, options, callback)`** — Runs a command in a shell, buffers stdout/stderr, delivers via callback. Also `util.promisify`-able. +- **`execFile(file, args, options, callback)`** — Like exec but without a shell (safer). Buffers output. +- Each has a sync variant (`spawnSync`, `execSync`, `execFileSync`) that blocks the event loop. + +Internal call chain: `exec` → `execFile` → `spawn`. This matters for deduplication. + +### Customer use cases + +- **VS Code** — Spawns git processes constantly (status, diff, log, blame), language servers, terminal shells, build tools, linters, formatters. Extensions spawn arbitrary processes. +- **Cursor** — VS Code foundation + AI processes, local model inference, CLI tools. +- **GitHub Desktop** — Git commands for all repository operations. +- **Slack** — Native helpers for screen capture, call quality diagnostics. +- **Generic patterns** — Running CLI tools (ffmpeg, imagemagick, pandoc), platform utilities (sw_vers — our SDK does this), package managers, background daemons. + +### Customer likelihood + +**Very high.** Virtually every non-trivial Electron app uses these. Developer tools (IDEs, terminals, git clients) use them extremely heavily. + +### Monitoring value + +| Data | Value | +| ------------------------ | ---------------------------------------------- | +| command + args | Identify processes launched, detect anomalies | +| Duration (spawn → exit) | Performance monitoring, detect hangs | +| Exit code + signal | Error detection, crash monitoring | +| stdout/stderr byte count | Resource usage, detect runaway output | +| Error event details | Failure classification (ENOENT, EACCES, EPERM) | +| Concurrency count | Active child processes simultaneously | +| options.shell | Security signal (shell injection risk) | +| options.cwd | Context for relative commands | + +### Instrumentation approach + +Monkey-patch each method on the `child_process` module. Wrap the returned `ChildProcess` with event listeners for `exit`, `error`, `spawn`. + +**Deduplication strategy:** Since `exec` → `execFile` → `spawn` internally, patching all four would create duplicate events. Two options: + +- Use a `Symbol` marker on options to track the highest-level entry point, skip reporting at lower levels +- Use a `WeakSet` to track already-reported `ChildProcess` instances + +**Sync variants** need separate wrappers — they return result objects, not `ChildProcess` instances. + +**Self-instrumentation:** The SDK itself uses `execFile('sw_vers')` in `src/transport/userAgent.ts`. Must filter out internal calls. + +### Bundler impact + +**Safe.** All bundlers (Webpack, Vite, esbuild) externalize `child_process` when targeting Electron main process. The `require()` cache ensures all consumers get the patched version. Both `child_process` and `node:child_process` resolve to the same module. + +The patch must happen early (before any customer code calls `require('child_process')`), achievable since the SDK initializes at app startup. + +### Instrumentation overhead + +**Negligible.** Process creation is an OS-level operation (5-50ms). The wrapper adds microseconds (function call + event listener registration + timestamp). Even for high-frequency spawners like VS Code (hundreds of git commands per session), unmeasurable. + +### Data flow back to main process + +**Trivial.** The parent IS the main process. All events (`exit`, `error`) fire in the main process event loop. Data feeds directly into the SDK's event pipeline. + +### Risks / unknowns — what prototyping would answer + +- **Argument normalization** — All methods have overloaded signatures (args optional, options optional, callback optional). Need robust normalizer. +- **`util.promisify` compatibility** — Wrapping `exec`/`execFile` must preserve the `[util.promisify.custom]` symbol. Needs validation. +- **Sensitive data in args** — Commands may contain tokens, passwords, API keys. Need scrubbing strategy. +- **High-frequency spawners** — Telemetry volume from apps like VS Code. Need sampling/aggregation strategy. +- **Detached processes** — `options.detached` + `child.unref()` means the child outlives the parent. `exit` event may never fire. +- **Double-counting** — Validate the deduplication approach across the exec → spawn call chain. + +--- + +## 2. Electron Lifecycle Events + +### What it is + +Electron framework events that fire when child processes terminate, plus a polling API for process metrics. + +- **`app.on('child-process-gone')`** — Fires when a non-renderer child process disappears. Provides: `type` (Utility, GPU, Zygote, etc.), `reason` (clean-exit, abnormal-exit, killed, crashed, oom, launch-failed, integrity-failure, memory-eviction), `exitCode`, `serviceName`, `name`. +- **`app.on('render-process-gone')`** — Fires when a renderer crashes. Provides: `reason`, `exitCode`, plus the associated `WebContents` for window correlation. +- **`app.getAppMetrics()`** — Returns `ProcessMetric[]` with: `pid`, `type` (Browser, Tab, Utility, GPU, etc.), `cpu` (percentCPUUsage, idleWakeupsPerSecond, cumulativeCPUUsage), `memory` (workingSetSize, peakWorkingSetSize), `creationTime`, `serviceName`, `sandboxed`, `integrityLevel` (Windows). + +### Customer use cases + +These are framework events, not customer-initiated. They track ALL Electron child processes (renderers, GPU, utility, service workers). Major apps show crash recovery UI and log crashes. + +### Customer likelihood + +**Universal.** Every Electron app has these processes. Crashes are infrequent in stable apps but each one is a significant user-impacting event. OOM is the most common production crash reason. + +### Monitoring value + +**Exit reason to RUM event type mapping:** + +| Reason | Suggested RUM Type | +| ------------------- | ------------------------------------------------- | +| `clean-exit` | Action (lifecycle) — may want to filter for noise | +| `abnormal-exit` | Error | +| `killed` | Error | +| `crashed` | Error | +| `oom` | Error — high-value signal | +| `launch-failed` | Error | +| `integrity-failure` | Error (Windows) | +| `memory-eviction` | Error | + +**Metrics:** CPU% per process type (detect runaway renderers), memory per process (detect leaks approaching OOM), process count (detect proliferation), creation time (detect crash loops via frequent restarts). + +### Instrumentation approach + +**Trivial.** Simple event listeners on `app`, no monkey-patching: + +``` +app.on('child-process-gone', ...) +app.on('render-process-gone', ...) +setInterval(() => app.getAppMetrics(), POLL_INTERVAL) +``` + +Fits cleanly into the existing `EventManager` handler pattern. + +### Bundler impact + +**None.** `require('electron')` is universally externalized by all Electron bundlers. + +### Instrumentation overhead + +**Negligible.** Event listeners: zero cost (fire only on termination). `getAppMetrics()` polling: synchronous call, microseconds, proportional to process count (typically 5-15). Even at 1-second interval, very low. + +### Data flow back to main process + +**Ideal.** Both events fire in the main process where the SDK lives. `getAppMetrics()` is a main-process API. No cross-process communication needed. + +### Risks / unknowns + +- **`clean-exit` noise** — `child-process-gone` fires for clean exits too. Utility processes routinely cycled may generate low-value events. May need filtering. +- **`getAppMetrics()` CPU quirk** — `percentCPUUsage` returns 0 on first call, measures since last call. SDK must call at least twice; measurement window = poll interval. +- **No `child-process-spawned` event** — No event for process start. Detect new processes by diffing `getAppMetrics()` snapshots using `pid` + `creationTime` as unique key. +- **`MemoryInfo` limitations** — `privateBytes` is Windows-only. No per-process JS heap size (V8 heap stats only available in-process). + +--- + +## 3. Electron utilityProcess + +### What it is + +Electron API (since v22) for spawning **sandboxed Node.js child processes** from the main process. Alternative to `child_process.fork` designed for Electron. + +`utilityProcess.fork(modulePath, args?, options?)` — returns a `UtilityProcess` instance. Key options: `serviceName` (appears in `getAppMetrics` and `child-process-gone`), `env`, `execArgv`, `cwd`, `stdio`, `allowLoadingUnsignedLibraries` (macOS). + +**vs child_process.fork:** + +| Aspect | child_process.fork | utilityProcess.fork | +| --------------- | ------------------------------ | ------------------------------------ | +| Binary | Full Electron (~150MB) | Lightweight Node.js context | +| IPC | Node.js channel (process.send) | MessagePort (parentPort.postMessage) | +| Crash reporting | Not integrated | Integrated (child-process-gone) | +| Metrics | Not in getAppMetrics | Visible in getAppMetrics | +| Sandboxing | None | Chromium sandbox | + +Inside the utility process: `process.parentPort` for communication with main. Has `postMessage` and `on('message')`. + +### Customer use cases + +- **VS Code** — Extension host, language servers, file watcher, search — all migrated from `child_process.fork` to `utilityProcess`. +- **Generic patterns** — Native addon isolation (crash doesn't kill main), background network requests (Electron `net` module available), heavy computation, plugin systems. + +### Customer likelihood + +**High and growing rapidly (~30-40%).** Electron docs recommend it over `child_process.fork`. VS Code's adoption is a strong ecosystem signal. New Electron apps almost certainly use it for background processing. + +### Monitoring value + +**High.** Most telemetry is available from main-process APIs without instrumenting the child: + +- `serviceName` identifies each utility process's purpose +- `app.getAppMetrics()` provides CPU/memory per process +- `child-process-gone` provides crash reasons +- `error` event provides V8 fatal errors with diagnostic reports +- Fork wrapper captures: modulePath, creation/exit times, exit codes, message counts + +### Instrumentation approach + +Wrap `utilityProcess.fork()` — it's a static method on a singleton, single patch point. The returned `UtilityProcess` is an EventEmitter — standard event listening for `spawn`, `exit`, `message`, `error`. + +**Main-process-only approach (recommended first):** Fork wrapping + `getAppMetrics()` polling + `child-process-gone` listener. Captures ~80% of value with near-zero complexity. + +**Child-side instrumentation (later):** Inject via `execArgv` with `--require` or transfer a dedicated `MessagePortMain` for a telemetry channel. + +### Bundler impact + +**Low risk.** `require('electron')` is always external. The monkey-patch intercepts the call before module path resolution. For child-side injection via `--require`, the required file must exist on disk (not inside a bundle) — the SDK ships as a node_modules file, so this works. + +Electron Forge (Webpack), electron-vite, and electron-builder all support utility process entry points as separate entries. + +### Instrumentation overhead + +**Very low.** Fork wrapping: negligible (process creation takes 50-200ms). `getAppMetrics()` polling: <1ms per call. Event listeners: zero cost. Message counting: optional, count-only is cheap. + +### Data flow back to main process + +**Excellent.** Three layers of data available without any child instrumentation: + +1. `child-process-gone` event — crash reasons, exit codes +2. `app.getAppMetrics()` — CPU, memory, process type, service name +3. Fork wrapper — creation time, module path, message count + +For richer child-side telemetry: `MessagePortMain` (dedicated channel) or `parentPort.postMessage` (piggyback on existing channel). + +### Risks / unknowns + +- **`serviceName` uniqueness** — If app doesn't set it, all utility processes appear as "Node Utility Process". Need fallback identification (modulePath, PID). +- **`execArgv` injection safety** — Adding `--require` must not break the utility process's own flags (`--inspect`, `--max-old-space-size`). +- **Crash report correlation** — When a utility process crashes, unsent telemetry from that process is lost. Main-process-only approach avoids this issue. +- **Multiple concurrent utility processes** — Apps like VS Code spawn many. SDK must handle tens of concurrent processes efficiently. + +--- + +## 4. child_process.fork + +### What it is + +Specialized `spawn` for creating Node.js child processes with built-in IPC. `fork(modulePath, args?, options?)` spawns a new Node.js instance running the specified module. Always creates an IPC channel (`child.send()` / `child.on('message')`). + +Key detail in Electron: `fork()` uses the Electron binary as the Node.js runtime by default (since `process.execPath` = Electron). The forked process is a full Electron process. Developers often set `options.execPath` to a standalone `node` binary, or use `utilityProcess` instead. + +### Customer use cases + +- **CPU-intensive offloading** — Image processing, data transformation, encryption. +- **VS Code (legacy)** — Extension host was originally a forked process (migrated to utilityProcess). +- **Worker-like patterns** — Before `worker_threads` was stable, fork was the standard for parallel computation. +- **Build tools** — Running webpack, TypeScript compiler as separate processes. + +### Customer likelihood + +**Medium and declining.** `worker_threads` and `utilityProcess` are replacing fork use cases. Still significant in legacy apps and frameworks that predate Electron 22. + +### Monitoring value + +Same as spawn, plus: + +- IPC message count/volume — communication overhead +- `modulePath` — which JS module is the child +- Whether `execPath` overrides Electron — full Electron process vs lightweight node +- `execArgv` — memory limits, debug flags + +### Instrumentation approach + +Same monkey-patching pattern as spawn. Additionally can count IPC messages via `child.on('message')`. No double-counting concern with spawn — can detect fork-originated processes by the `'ipc'` in `options.stdio`. + +**Data flow options:** + +- **Parent-side only (recommended):** Instrument the fork wrapper, capture modulePath, duration, exit code, IPC message count. No child modification needed. +- **IPC-based child telemetry (later):** Inject `--require` preload via `execArgv`. Child reports memory, errors, custom metrics via `process.send()`. Parent filters out DD-internal messages. + +### Bundler impact + +Same as spawn — `child_process` is externalized. Additional note: the `modulePath` argument is a file path that must exist at runtime. If the app is bundled, the worker script may not be in the expected location — but this is the developer's problem, not ours. + +### Instrumentation overhead + +**Negligible.** Fork creates a full Node.js process (50-200ms startup). Wrapper cost immeasurable. + +**IPC message counting risk:** For high-frequency IPC (e.g., language servers sending hundreds of messages/sec), listening on every `message` event could add measurable overhead. Needs benchmarking. + +### Risks / unknowns + +- **IPC message counting overhead** — Benchmark for high-frequency forked processes. +- **Process trees** — Fork can create nested trees (A forks B, B forks C). Monitoring depth is a concern. +- **fork + custom execPath** — When set to standalone `node`, child preload injection must work in both Electron and plain Node contexts. +- **Zombie processes** — If IPC channel breaks (parent crashes), children become orphans. + +--- + +## 5. worker_threads + +### What it is + +Node.js API for in-process parallelism via OS threads. Each worker has a separate V8 context but shares the same OS process. Key APIs: `new Worker(filename, options)`, `parentPort`, `workerData`, `MessageChannel`, `SharedArrayBuffer`. + +Workers can set `resourceLimits` (maxOldGenerationSizeMb, maxYoungGenerationSizeMb, stackSizeMb). Workers have their own event loop. + +### Customer use cases + +- **CPU-intensive computation** — Image processing, data transformation, search indexing, compression (fflate), encryption. +- **Library-internal usage** — Many npm packages use workers internally (esbuild, synckit, eslint, vitest). Apps may transitively use them without explicit intent. +- **Native addon isolation** — Partial — a segfault still kills the whole process. + +### Customer likelihood + +**Moderate (~20-30%).** Common in libraries/build tooling, less common as direct API usage in Electron main processes. Most Electron developers prefer process-level isolation (`utilityProcess` or `fork`). + +### Monitoring value + +| Data | Value | +| --------------------------- | ----------------------------------------------- | +| Worker creation/termination | High — lifecycle, detect leaks | +| Exit codes + error events | High — uncaught exceptions | +| Worker script path | High — identify workers | +| CPU time per worker | Medium — `worker.cpuUsage()` available natively | +| Memory/heap stats | Medium — `worker.getHeapStatistics()` available | +| Message count/size | Medium — detect chatty workers | +| Thread count over time | Medium — detect pool exhaustion | + +### Instrumentation approach + +**Constructor patching:** Replace `Worker` on the `worker_threads` module with an instrumented subclass or wrapper. Straightforward since the module is a singleton with a reassignable `Worker` property. + +**Child-side injection is the hard part:** The worker script is opaque. Options: + +- `execArgv` with `--require` — loads instrumentation before the worker script. Needs validation. +- `workerData` — pass config, worker voluntarily imports SDK. Not transparent. +- Dedicated `MessageChannel` — transfer a port via `workerData` for telemetry. + +### Bundler impact + +**Medium concern.** Constructor patching is safe (module is externalized). But worker scripts need separate bundling — Webpack 5 has patterns for this, esbuild does not auto-handle it. Inline workers (`eval: true`) bypass file issues but are rare. + +### Instrumentation overhead + +Worker creation wrapping: negligible (~50-100ms for V8 context setup, wrapper adds microseconds). Message interception is the concern — workers can send thousands of messages/sec. Count-only mode (~1μs/message) is safe. Full size measurement could double serialization time. + +### Data flow back to main process + +Good options: `parentPort` (built-in), dedicated `MessageChannel` (recommended), `SharedArrayBuffer` (zero-copy for numeric data, complex to implement), `BroadcastChannel` (simple, less controlled). + +### Risks / unknowns + +- **Child-side injection** — `--require` via `execArgv` needs prototyping. +- **`SharedArrayBuffer` interception** — Not feasible (direct memory access). +- **Worker pools** (`piscina`, `workerpool`) — Instrumentation must correctly attribute work across pooled workers. +- **`eval: true` inline workers** — Cannot inject instrumentation. +- **Native addon workers** — Must not interfere with addon loading. + +--- + +## 6. MessagePort / MessageChannel + +### What it is + +Electron's cross-process communication primitives. `MessageChannelMain` (main process only) creates paired `MessagePortMain` objects. Ports transfer across process boundaries via `webContents.postMessage()` or `utilityProcess.postMessage()`. + +Key differences from Web/Node.js: `MessagePortMain` uses Node's EventEmitter API, exists only in main process. Renderers use standard web `MessagePort`. + +### Customer use cases + +- **VS Code** — Primary IPC for extension host (utility process), SharedProcess, service workers. +- **Utility process communication** — MessagePort is the primary way to establish channels with utility processes (no `ipcMain`/`ipcRenderer` equivalent). +- Most apps using only renderers + main still prefer `ipcMain`/`ipcRenderer` (simpler, channel-name routing). + +### Customer likelihood + +**Growing (~15-25%).** Tied to utility process adoption. Apps with complex multi-process architectures use it. + +### Monitoring value + +**Low as standalone telemetry.** IPC messages are implementation details — volume/size metrics are niche. **High as SDK transport mechanism** for getting telemetry from utility/renderer processes back to main. + +### Instrumentation approach + +**As monitoring target (not recommended):** Patch `MessagePortMain.prototype.postMessage`. Risky — it's a C++ binding. Ports are anonymous (no channel names for identification). High overhead on hot paths. + +**As SDK transport (recommended use):** Create a dedicated `MessageChannelMain` per child process, transfer one port to the child. Clean, no interference with app messages. + +### Bundler impact + +**None.** Runtime APIs, not module imports. + +### Instrumentation overhead + +**High risk as monitoring target** — VS Code extension host can send thousands of messages/sec. **Negligible as SDK transport** — SDK telemetry volume is low. + +### Risks / unknowns + +- Prototype patching reliability on native bindings needs validation +- Port lifecycle management (renderer crash destroys ports, need reconnection) +- No ordering guarantees if process crashes mid-send + +--- + +## Cross-Cutting Concerns + +### Deduplication + +Internal call chain: `exec` → `execFile` → `spawn`. Patching all creates duplicates. Recommended: use a `Symbol` marker on options to track the entry point, skip lower-level reporting. + +### Sensitive data scrubbing + +Commands/args may contain tokens, passwords, API keys. Need: pattern-based scrubbing (URLs with credentials, `--password`/`--token` flags), customer-configurable rules. + +### Telemetry volume / sampling + +High-frequency spawners (VS Code) need: rate sampling, command-based filtering, aggregation (group by command, report counts + avg duration). + +### Self-instrumentation filtering + +SDK uses `execFile('sw_vers')` internally. Must exclude from customer telemetry. + +### Relationship to SDK architecture + +Child process events fit as a new `RawEvent` source flowing through `EventManager` → `Assembly` → `Transport`. Events tagged with `EventSource.MAIN`. Could add `EventSource.CHILD_PROCESS` for events originating from child-side instrumentation. diff --git a/prototype-child-processes_01_prototypes.md b/prototype-child-processes_01_prototypes.md new file mode 100644 index 00000000..0ecd187a --- /dev/null +++ b/prototype-child-processes_01_prototypes.md @@ -0,0 +1,220 @@ +# Child Process Monitoring — Prototype Findings + +Results from hands-on prototyping of child process instrumentation approaches. + +See `prototype-child-processes_00_research.md` for the landscape survey. + +--- + +## Prototype 1: child_process.spawn/exec/execFile + +**Branch:** `bcaudan/proto-spawn` +**Status:** Complete + +### Instrumentation strategy + +Monkey-patch `spawn`, `exec`, `execFile` (and sync variants) on the `child_process` module object at SDK initialization time, before any customer code runs. + +``` +SDK init (early) + └─ instrumentChildProcess() + ├─ require('node:child_process') ← must use require(), not import * + ├─ Object.defineProperty(cp, 'spawn', wrappedSpawn) + ├─ Object.defineProperty(cp, 'exec', wrappedExec) + ├─ Object.defineProperty(cp, 'execFile', wrappedExecFile) + └─ (same for sync variants) + +Customer code calls child_process.spawn/exec/execFile + └─ Our wrapper intercepts + ├─ Records: command, args, start time + ├─ Calls original function + ├─ Listens for exit/error events (async) or wraps callback (exec/execFile) + └─ Logs telemetry to main process: command, args, duration, exitCode/error +``` + +All data stays in the main process — no cross-process communication needed since the parent IS the main process. + +### Findings + +| Test | Result | +| --------------------------------- | -------------------------------------------------------------------- | +| `spawn('ls', ['-la'])` | Captured: command, args, duration (22ms), exitCode=0 | +| `exec('echo hello world')` | Captured: command, duration (8ms), stdoutSize, stderrSize | +| `execFile('node', ['--version'])` | Captured: command, args, duration (247ms), exitCode=0 | +| `spawn('nonexistent')` | Error captured: ENOENT | +| `exec('sleep 10', {timeout:100})` | Timeout captured: killed=true, signal=SIGTERM | +| `util.promisify(exec)` | Works after symbol copying fix | +| `spawnSync('ls')` | Captured: exitCode=0, duration (5ms) | +| SDK self-instrumentation | `execFile('sw_vers')` from `userAgent.ts` captured — needs filtering | + +### What worked + +- **`Object.defineProperty` on the real module object** — `require('node:child_process')` returns a module with `writable:true, configurable:true` properties. Patching via `Object.defineProperty` works reliably. +- **All async and sync variants captured** — spawn, exec, execFile, spawnSync, execSync, execFileSync all successfully instrumented. +- **Symbol copying preserves `util.promisify`** — copying `[util.promisify.custom]` from original to wrapper makes promisified exec work. +- **Error cases captured** — ENOENT (missing binary), timeout kills, both properly logged. + +### What didn't + +- **`import * as child_process` is NOT patchable** — TypeScript's `__importStar` helper creates a wrapper object with **non-configurable getter-only** properties. Both direct assignment and `Object.defineProperty` fail. Must use `require()` to get the real module. +- **`diagnostics_channel` for child_process does NOT exist** in Node.js v22.21.1 (Electron 39). Only `http`, `net`, and `undici` have tracing channels. Not a viable alternative currently. +- **Deduplication is unsolvable at the public API level** — `exec` calls `execFile` internally (not `spawn`). Patching both produces duplicate events. The internal chain `exec` → `execFile` → internal spawn bypasses the public `spawn`. Options: (a) accept duplicates, (b) only patch `exec` and `spawn` (miss direct `execFile` calls), (c) use a marker/WeakSet for dedup. +- **`util.promisify(exec)` bypasses the exec wrapper** — The `[util.promisify.custom]` function on `exec` calls `execFile` directly. So promisified exec produces an `execFile` log, not an `exec` log. + +### Unexpected issues + +- **`__importStar` is the #1 monkey-patching hazard for Electron** — any TypeScript/bundler that wraps `import *` into a frozen namespace object will break patching. This affects all built-in module patching, not just child_process. +- **Alternative approaches available if monkey-patching proves insufficient:** `shimmer` (dd-trace's approach), `require-in-the-middle` (RITM), `import-in-the-middle` (IITM). Documented for future reference. +- **`execFile` is the internal workhorse** — both `exec` and `util.promisify(exec)` route through `execFile`. If we could only patch one method, `execFile` gives the broadest coverage (catches exec, execFile, and promisified exec). `spawn` must be patched separately. +- **SDK self-instrumentation** — the SDK's own `execFile('sw_vers')` in `userAgent.ts` is captured. Production implementation needs a filtering mechanism. + +### Open questions for production + +- **Bundler behavior untested** — the prototype uses tsc (no bundling for main process). When the app is bundled with Webpack/esbuild/Vite, bundlers may handle `require('node:child_process')` differently. Need to verify patching works in bundled apps. +- **Best monkey-patching approach** — for production, evaluate `shimmer`, `require-in-the-middle` (RITM), or `import-in-the-middle` (IITM) instead of manual `Object.defineProperty`. The `__importStar` constraint must be accounted for in any approach — this is a cross-cutting concern for all built-in module instrumentation. +- **`diagnostics_channel` for `child_process`** — does not exist in Node 22 but may be added in future versions. Monitor Node.js releases; this would be the cleanest long-term approach. + +### Feasibility verdict + +**Feasible with caveats.** Monkey-patching works but requires `require()` (not `import *`) and `Object.defineProperty`. Deduplication across the exec→execFile chain needs a design decision. For production implementation, the monkey-patching approach should be evaluated carefully — consider using `shimmer` or `require-in-the-middle` for robustness, and test with all major bundler configurations (Webpack, esbuild, Vite). + +--- + +## Prototype 2: Electron lifecycle events + +**Branch:** `bcaudan/proto-lifecycle` +**Status:** Complete + +### Instrumentation strategy + +Subscribe to Electron's built-in process lifecycle events and poll `app.getAppMetrics()` on a timer. No monkey-patching needed — pure event listeners. + +``` +SDK init (after app.whenReady) + └─ setupLifecycleMonitoring() + ├─ app.on('child-process-gone') ← Electron-managed processes only (GPU, utility, etc.) + ├─ app.on('render-process-gone') ← renderer crashes, with webContentsId for correlation + └─ setInterval(5s) + └─ app.getAppMetrics() ← CPU%, memory per process + └─ Diff snapshots by pid+creationTime → detect appeared/disappeared processes +``` + +All data originates in the main process. Lifecycle events provide crash reasons (clean-exit, abnormal-exit, killed, crashed, oom). Metrics polling provides continuous CPU/memory per process type. + +### Findings + +| Test | Result | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `app.getAppMetrics()` polling | Works. Returns CPU%, memory (workingSetSize, peakWorkingSetSize), process type, serviceName, pid, creationTime. | +| Process discovery via snapshot diff | Works. Detects appeared/disappeared processes by diffing `pid+creationTime` between polls. | +| `render-process-gone` on crash | Works. Provides `reason=killed`, `exitCode=2`, `webContentsId` for correlation. | +| `render-process-gone` on normal close | **Does NOT fire.** Cmd+W window close produces no event — only abnormal terminations trigger it. | +| `child-process-gone` for Node.js `child_process.spawn` | **Does NOT fire.** Only Electron-managed processes (GPU, utility, renderers) trigger this event. | +| CPU% quirk (first call returns 0) | Confirmed. First `getAppMetrics()` call returns `percentCPUUsage=0.0%` for all processes. Meaningful values from second call onward. | +| Default Electron process tree | 4 processes at startup: Browser (main), GPU, Utility (network.mojom.NetworkService), Tab (renderer). | + +### What worked + +- Simple event listeners — no monkey-patching needed, trivially integrates with existing SDK `EventManager` pattern +- `getAppMetrics()` provides rich data without any child process instrumentation (CPU, memory, process type, serviceName) +- Process discovery via snapshot diffing is reliable — `pid+creationTime` is a unique stable key +- `render-process-gone` provides `webContentsId` for correlating crashes to specific windows/views + +### What didn't + +- `child-process-gone` does not cover Node.js `child_process` — separate instrumentation (proto-spawn) is needed for those +- Crashing the main process (`process.crash()`) kills the observer — no events captured. Main process crashes must be detected via crash dumps at next startup (existing `CrashCollection`). + +### Unexpected issues + +- Electron already runs a `Utility` process by default (network service, `serviceName=network.mojom.NetworkService`). `getAppMetrics()` will always show at least one utility process even without customer code spawning any. +- `render-process-gone` with `reason=killed` (not `crashed`) when using `forcefullyCrashRenderer()`. The `reason` field values may not always match intuition. +- Normal window close does NOT trigger `render-process-gone` — every event from this listener is genuinely abnormal. No filtering needed. + +### Feasibility verdict + +**Fully feasible. Trivial to implement.** Zero risk, zero overhead, high value. Should be included in the SDK immediately — it's the lowest-hanging fruit of all mechanisms surveyed. + +--- + +## Prototype 3: Electron utilityProcess + +**Branch:** `bcaudan/proto-utility` +**Status:** Complete + +### Instrumentation strategy + +Two layers: (1) monkey-patch `utilityProcess.fork()` in the main process, and (2) establish a dedicated telemetry channel to the utility process for rich data. + +``` +SDK init (early) + └─ instrumentUtilityProcess() + ├─ Object.defineProperty(utilityProcess, 'fork', wrappedFork) + └─ app.on('child-process-gone') ← catches utility process crashes + +Customer code calls utilityProcess.fork(modulePath, args, options) + └─ Our wrapper intercepts + ├─ Records: modulePath, serviceName, start time + ├─ Calls original fork → returns UtilityProcess instance + ├─ Wraps child.postMessage() → counts outbound messages + ├─ Listens for spawn, message, exit, error events → counts inbound, logs telemetry + └─ (optional) Sets up dedicated MessagePort telemetry channel: + Main process Utility process + ┌─────────────────┐ ┌──────────────────┐ + │ port1.on('msg') │◄──── MessagePort ──│ port2.postMessage│ + │ (receives │ (dedicated │ (sends periodic │ + │ telemetry) │ channel) │ memory/CPU) │ + └─────────────────┘ └──────────────────┘ + +Error forwarding (parentPort piggyback): + Utility process catches uncaughtException + └─ parentPort.postMessage({ __dd: true, type: 'error', data: { message, stack } }) + └─ Main process filters __dd messages from app messages, logs error with stack trace +``` + +Data flows back to main process via three complementary paths: + +- **`child-process-gone` event** — automatic, provides crash reason (but no error details) +- **parentPort `__dd` messages** — error details with stack traces, arrives before child-process-gone. **Caveat:** `__dd` messages leak into the customer's `child.on('message')` handlers — we can't suppress them since EventEmitter delivers to all listeners. Suitable for one-shot critical data (crash-time error forwarding) but not for periodic telemetry. +- **Dedicated MessagePort** — rich periodic telemetry (memory, CPU), completely separate from app traffic. Invisible to the customer. **Recommended for all ongoing telemetry.** + +### Findings + +| Test | Result | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `utilityProcess.fork()` monkey-patching | Works via `Object.defineProperty` on the `utilityProcess` singleton | +| Fork with serviceName | Captured: modulePath, serviceName, pid on spawn | +| Send ping (round-trip) | Works. Message in/out both tracked with counts | +| Send work (fibonacci 35) | 63ms computation in utility process, result received in main | +| Dedicated MessagePort channel | Works. Periodic telemetry (memoryUsage, cpuUsage) every 2s on dedicated port | +| parentPort piggyback (`__dd` prefix) | Works. Error forwarding with full stack trace received in main. Filtered from app messages. | +| Crash utility (thrown error) | Three events in order: (1) `dd-message` with stack trace, (2) `utilityProcess.exit` exitCode=1, (3) `child-process-gone` reason=abnormal-exit | +| Exit utility (process.exit(1)) | `utilityProcess.exit` exitCode=1 + `child-process-gone` reason=abnormal-exit. **No** error message forwarded. | +| `child-process-gone` correlation | Fires with `type=Utility`, `serviceName=node.mojom.NodeService`, `name=dd-proto-worker` | +| Fork without serviceName | Appears as `service=Node Utility Process` — hard to identify | + +### What worked + +- **Fork wrapper via `Object.defineProperty`** — `utilityProcess.fork` is a static method on a singleton. Straightforward and reliable. +- **All 3 data flow approaches validated:** + - **Dedicated MessagePort channel** — richest. Periodic telemetry (memory, CPU) on a separate port. No interference with app messages. + - **parentPort piggyback (`__dd` prefix)** — simpler setup. Error forwarding with full stack traces. `__dd` flag cleanly separates SDK from app messages. + - **`child-process-gone` event** — fires automatically for all utility process terminations. Provides reason, exitCode, serviceName. +- **Crash error ordering is reliable** — error message via parentPort arrives **before** `child-process-gone`. Allows correlating error details with lifecycle event. +- **`getAppMetrics()` provides free metrics** — CPU%, memory per utility process, keyed by serviceName. No child-side instrumentation needed. + +### What didn't + +- **`child-process-gone` cannot distinguish crash from intentional exit** — both `process.exit(1)` and thrown errors produce `reason=abnormal-exit, exitCode=256`. Only differentiator is whether an error message was forwarded via parentPort. +- **`child-process-gone` serviceName is Chromium-internal** — shows `node.mojom.NodeService`, not the user's serviceName. The `name` field has the user-set value. Different fields for different identifiers. +- **`child-process-gone` exitCode differs from utility exit event** — Chromium reports `exitCode=256` for `process.exit(1)`, while the utility `exit` event correctly reports `exitCode=1`. +- **parentPort piggyback leaks into customer message handlers** — `__dd`-prefixed messages are visible to the customer's `child.on('message')` listeners. EventEmitter delivers to all listeners; we cannot suppress delivery. This makes parentPort piggyback unsuitable for periodic telemetry (pollutes customer IPC). Use only for one-shot critical data (crash-time error forwarding where the process is dying anyway). Use the dedicated MessagePort channel for all ongoing telemetry. + +### Unexpected issues + +- Fork without serviceName defaults to `Node Utility Process` — multiple unnamed utility processes would be indistinguishable. SDK guidance should recommend always setting serviceName. +- Electron always runs a `network.mojom.NetworkService` utility process. `getAppMetrics()` always shows at least one Utility type. + +### Feasibility verdict + +**Fully feasible. High value, low complexity.** Main-process-only approach (fork wrapper + `getAppMetrics()` + `child-process-gone`) captures ~80% of useful telemetry with near-zero overhead. Dedicated MessagePort channel provides rich child-side telemetry when deeper visibility is needed. diff --git a/prototype-child-processes_02_ai-usage.md b/prototype-child-processes_02_ai-usage.md new file mode 100644 index 00000000..ad4b2984 --- /dev/null +++ b/prototype-child-processes_02_ai-usage.md @@ -0,0 +1,185 @@ +# Child Process Monitoring — AI Usage Log + +## Overview + +This prototype ran over 4 sessions, progressing from landscape research → RUM data model design → working prototype → conclusion doc. AI (Claude Code) was the primary implementer throughout, with the human acting as architect, domain expert, and QA reviewer. + +### How AI was used + +- **Research & exploration**: parallel sub-agents surveyed mechanisms, parsed RUM schemas from GitHub, fetched product briefs via Google Workspace MCP, and reviewed PRs +- **Technical prototypes**: 3 parallel prototype worktrees (monkey-patching, utility process, MessagePort) built, run via background Electron shell, and tested interactively +- **Domain design**: iterative brainstorming (one question at a time, multiple choice) to converge on the "processes as views" data model +- **Domain prototype**: sequential plan execution with self-validation via Playwright + mock intake — the key enabler for agent autonomy +- **Domain prototype refinement**: screenshot-driven debugging where the human shared Datadog Explorer screenshots and the agent diagnosed and fixed issues +- **Organize conclusion**: parallel explore agents gathered findings, plan mode structured the document, iterative feedback refined it + +### Key insights + +1. **Self-validation infrastructure is the highest-leverage investment.** Session 1 required human button-clicking for every test. Session 2 planned a Playwright + mock intake harness. Session 3 used it — the agent iterated on failures autonomously, dramatically increasing throughput. + +2. **Human domain expertise steers, AI executes.** Every significant design decision came from the human (processes as views, container hierarchy, context fields are customer-owned, path sanitization). AI was effective at implementing those decisions and exploring their implications, but not at originating them. + +3. **Parallel agents scale research, not implementation.** Parallel sub-agents worked well for broad surveys (6 mechanisms × 9 dimensions), PR reviews (2 PRs simultaneously), and codebase exploration. Implementation remained sequential — each step depended on the previous one. + +4. **Screenshots bridge the observability gap.** When the agent can't access the production tool (RUM Explorer), user-provided screenshots were an effective substitute — the agent diagnosed duplicate events, wrong timestamps, and UI display issues from visual data alone. + +--- + +## Session 1: Research + Technical Prototypes + +**Scope:** Landscape survey of 6 mechanisms + prototyping 3 +**Outcome:** Research doc, 3 prototype worktrees, documented findings + +### How it went + +**Research phase:** 3 sub-agents in parallel, each covering 2 mechanisms across 9 dimensions. Compiled into priority matrix. + +**Prototyping phase:** Attempted 3 parallel implementation agents. Each prototype was built, run via background shell, and tested interactively (human clicks buttons, agent reads logs). + +### Human interventions that shaped the outcome + +- Added evaluation dimensions (bundler impact, overhead, data flow) +- Asked about existing instrumentation libraries → discovered `shimmer`, RITM, IITM; tested `diagnostics_channel` (not available for child_process in Node 22) +- Asked about parentPort reliability for customers → identified message leaking, shaped MessagePort recommendation +- Prompted exit(1) vs crash test → discovered `child-process-gone` can't distinguish them + +### What worked well with AI + +- Parallel research agents for broad surveys +- Running Electron app in background + reading logs for validation +- Running diagnostic scripts in Electron to investigate runtime behavior (`__importStar` property descriptors, `diagnostics_channel` availability) +- Iterative fix cycles (error → diagnose → fix → rebuild → retest) + +### What didn't + +- First monkey-patching approach (3 iterations needed: direct assignment → `Object.defineProperty` → `require()` instead of `import *`) +- Stale `tsconfig.tsbuildinfo` causing silent build failures + +### Improvement for future sessions + +- **Agents should self-verify prototypes** — in this session, the human had to click buttons and report results. For future prototypes, design a self-test mode (e.g. headless Electron with programmatic IPC triggers + assertion checks) so agents can iterate without human intervention. + +--- + +## Session 2: RUM Concept Mapping + Prototype Planning + +**Scope:** Map child process telemetry to RUM events, plan prototype implementation +**Outcome:** RUM mapping doc (`_03_rum-mapping.md`), implementation plan (`.plans/cozy-booping-mango.md`) + +### How it went + +**Exploration phase:** 2 parallel sub-agents explored the SDK architecture (event pipeline, RUM types, raw data types, Assembly, Transport) and existing child process code. Fetched RUM event schemas from `rum-events-format` repo (error, action, resource, view, vital, common, view-container, view-properties). Read the product brief via Google Workspace MCP. + +**Brainstorming phase:** Iterative Q&A with the user (8 rounds), one question at a time. Explored alternatives, proposed mappings, refined based on user feedback. Resulted in the "processes as views" model. + +**Planning phase:** Designed implementation approach (playground + Playwright + intake for agent self-validation), defined steps with priorities. + +### Human interventions that shaped the outcome + +- **Processes as views concept** — user proposed modeling long-lived processes as RUM views to attach performance metrics, inspired by how the main process is already a view +- **Container hierarchy** — user pointed to `_view-container-schema.json` and the existing `Assembly.ts:64` (`container.view.id`), revealing the parent-child view mechanism already exists for Electron +- **Mobile SDK metrics pattern** — user pointed to `_view-properties-schema.json` showing `memory_average`, `cpu_ticks_per_second` on views, suggesting we follow the same pattern +- **Context attributes are customer-owned** — user flagged that `context` fields cannot be used in production for SDK data, only as prototype placeholders. Production needs schema extensions. +- **Spawn/exec errors on resource, not separate Error** — user requested error info stay on the Resource event itself (like failed HTTP requests), avoiding separate Error events +- **Resource event lifecycle** — user suggested starting resource tracking at spawn time but waiting for completion to emit, and correlating `child-process-gone` to ongoing resources (clarified: only applies to utility processes, not Node.js child_process) +- **Product brief reference** — user shared Google Doc link, asked to flag brief recommendations but allow challenging them +- **Playground test harness** — user wanted agents to validate without clicking buttons and without RUM Explorer. Explored 3 approaches (e2e app, playground headless, playground + Playwright + intake). User chose C (Playwright clicking playground buttons + intake assertions) because it reuses demo buttons + +### What worked well with AI + +- Fetching and parsing RUM event schemas from GitHub API for analysis +- Reading product brief via Google Workspace MCP (after reauthentication) +- One-question-at-a-time brainstorming with multiple choice options +- Exploring e2e infrastructure (2 parallel agents) to inform the validation approach +- Iterating on the test harness design based on user feedback + +### What didn't + +- Product brief JSON was 431KB raw — needed Python extraction to get readable text + +### Improvement for future sessions + +- **Playground test harness** — the plan includes setting up Playwright + intake for the playground (Step 0). Once built, agents can self-validate by clicking buttons and asserting on captured events, eliminating the manual validation bottleneck from session 1. + +--- + +## Session 3: RUM Mapping Prototype Implementation + Refinements + +**Scope:** Implement all 5 steps of the prototype plan, then iterative refinements based on user testing in Datadog +**Outcome:** Full working prototype (12 Playwright scenarios), findings doc (`_04_rum-mapping-prototype.md`), multiple bug fixes + +### How it went + +**Implementation phase:** Sequential execution of the 5 plan steps. Each step: implement SDK collection → add playground buttons/IPC → write Playwright scenario → run checks (typecheck, lint, unit tests, playground tests) → commit. + +**Refinement phase:** User tested the prototype against real Datadog RUM Explorer. Iterative cycle: user spots issue in screenshot → agent diagnoses → implements fix → verifies with tests → commits. Covered: duplicate resources, view date bug, renderer timing, path sanitization, view naming. + +### Human interventions that shaped the outcome + +- **Hidden window in test mode** — user requested playground window stays hidden during tests to avoid disruption +- **Distinct serviceName per utility button** — user asked for unique names (`dd-demo-fork`, `dd-demo-message`, `dd-demo-crash-worker`) to ease event identification in Explorer +- **Duplicate resource events** — user spotted duplicates in Explorer screenshot, leading to discovery of exec→execFile→spawn chain and close+error double-emission +- **Main view date bug** — user noticed main process view had wrong start time in Explorer, revealing pre-existing bug where `commonContext` hook's `Date.now()` overwrote the view creation time +- **Renderer view timing** — user asked how to ensure renderer view predates browser-rum events, leading to `did-start-navigation` + lazy creation with backdating approach +- **Bridge-level detection alternative** — user suggested creating renderer views at bridge level (earlier than Assembly) as production improvement +- **Path sanitization** — user flagged filesystem paths leaking in events. Iterative debugging: `[APP_PATH]` placeholder → discovered it broke Datadog UI display → switched to stripping path entirely +- **Memory on view fields** — user pointed out existing `view.memory_average`/`view.memory_max` schema fields (from mobile SDKs) instead of using context +- **CPU not prototyped** — user decided to skip CPU metrics after discussion of unit mismatch (ticks vs seconds) and lack of Datadog display support +- **Method-specific URL schemes** — user preferred `spawn://`, `exec://` over generic `child_process://` for resource URLs +- **Renderer view naming** — user wanted page title for grouping, pid in context only, with `Renderer:` prefix for consistency with `Utility:` + +### What worked well with AI + +- **Self-validating test harness** — the Playwright + mock intake setup (session 2's improvement) worked as designed. Agent ran `yarn playground:test` after each change and iterated on failures without human intervention +- **Screenshot-driven debugging** — user shared Datadog Explorer screenshots, agent diagnosed issues from the visual data (duplicate events, wrong timestamps, missing URLs) +- **Incremental commits** — plan steps + fixup commits kept history clean and reviewable +- **Parallel exploration agents** — used for initial codebase understanding (e2e infra, SDK types, Electron APIs) + +### What didn't + +- **Rollup namespace wrapper** — took multiple iterations to discover that `import * as mod` produces a wrapper object that `Object.defineProperty` can't patch through. Required reading bundled output to diagnose. +- **Path sanitization iteration** — 3 rounds needed: `[APP_PATH]` placeholder → broken UI → strip path entirely → discovered `sanitizeAppPaths` call was dropped during debug logging → final fix + +--- + +## Session 4: Conclusion Document + dd-trace PR Review + +**Scope:** Synthesize findings from docs 00-04 into a conclusion document, review dd-trace integration PRs +**Outcome:** Conclusion doc (`_05_conclusion.md`), dd-trace evaluation findings + +### How it went + +**Planning phase:** 2 parallel Explore agents gathered all findings from docs 00-04 and searched for dd-trace references. Plan agent designed the document structure. Iterative refinement with user through plan mode. + +**Writing phase:** Wrote the conclusion doc organized by topic with value/complexity ratings. Multiple rounds of user feedback reshaped the structure: + +- Lifecycle events folded into their respective process topics (not standalone — they need a View to attach to) +- Data model moved to topic 1 as prerequisite, expanded with dedicated sections (processes as views, command execution as resource) and alternatives considered +- dd-trace evaluation placed right after data model for parallel exploration +- exec/spawn split into monkey-patching (challenging) vs collection (easy-medium) to surface where complexity actually lives + +**dd-trace review phase:** 2 parallel Explore agents analyzed both PRs (electron-sdk#95, dd-trace-js#7002). Key finding: the current integration covers HTTP spans and IPC tracing only — no child_process, no utilityProcess, no lifecycle events. BrowserWindow wrapping is for preload injection, not monitoring. The existing `child_process` plugin exists in dd-trace but isn't enabled in the Electron integration. + +**Data model exploration:** Investigated RUM Vitals and Feature Operations as alternative data models. Fetched official docs for operations monitoring. Concluded both are wrong abstraction level — operations sit above views (user-facing workflows), while our processes sit below/alongside views (infrastructure containers). + +### Human interventions that shaped the outcome + +- **Lifecycle events are coupled** — user pointed out that `getAppMetrics` and lifecycle events are useless without a RUM View to attach them to, leading to restructuring +- **Custom complexity scale** — user defined easy/medium/challenging instead of standard T-shirt sizing +- **Monkey-patching is child_process-only** — user confirmed that utility process and renderer use simple Electron API wrapping, no bundler concerns. Led to clearer separation in the doc +- **Feature Operations consideration** — user suggested checking if RUM vitals/operations could fit the data model, leading to documentation of why they were rejected +- **JIRA link** — user provided RUM-15282 for path sanitization bug, connecting prototype findings to existing backlog + +### What worked well with AI + +- **Parallel PR analysis** — 2 agents reviewed both PRs simultaneously, giving a complete picture of the dd-trace integration in one round +- **Iterative document refinement** — plan mode enabled back-and-forth on structure before writing +- **Web fetch for docs** — fetched official Datadog operations monitoring docs to evaluate the Feature Operations alternative + +### What didn't + +- **Initial topic ordering** — first draft had lifecycle events as standalone topic and data model buried at position 4. Required user feedback to restructure + +### Improvement for future sessions + +- **Review PRs early** — the dd-trace PR review answered most evaluation questions concretely. In future prototypes, review related in-progress work earlier to avoid speculative sections diff --git a/prototype-child-processes_03_rum-mapping.md b/prototype-child-processes_03_rum-mapping.md new file mode 100644 index 00000000..5af1b1ae --- /dev/null +++ b/prototype-child-processes_03_rum-mapping.md @@ -0,0 +1,128 @@ +# Child Process Monitoring — RUM Concept Mapping + +How child process telemetry maps to RUM events. Based on prototype findings (`_01_prototypes.md`), RUM schema analysis (`rum-events-format`), and product brief alignment. + +--- + +## Foundation: Processes as Views + +Every Electron process becomes a **RUM View**. This provides: + +- A `view.id` to correlate all events from that process +- Built-in `memory_average`, `memory_max`, `cpu_ticks_per_second` fields (existing mobile schema in `_view-properties-schema.json`) +- Built-in `error.count`, `resource.count`, `action.count` counters +- `container` hierarchy for renderer → page view nesting (already implemented in `Assembly.ts`) + +View naming: `"{ProcessType}: {serviceName}"` (e.g., "Utility: dd-proto-worker", "Renderer: webContentsId=1", "GPU") + +### Container Hierarchy + +``` +Session +├── Main process view (source=electron) ← already exists +├── GPU process view (source=electron) +├── Utility process view (source=electron) +│ └── errors, resources attached to this view +├── Renderer process view (source=electron) +│ └── container for: +│ └── Page view (source=browser, container.view.id → renderer view) +│ └── browser-rum events +``` + +The `container` mechanism is already in place — `Assembly.ts` sets `container.view.id` on renderer events. The `_view-container-schema.json` already lists `"electron"` as a valid `container.source`. For renderer processes, we extend the existing mechanism so browser-rum events point to their **renderer process view**. + +--- + +## Datapoint Mapping + +### Utility Process Monitoring + +| Datapoint | RUM Event | Fields | Notes | +| ----------------------------------------- | ------------------------- | ------------------------------------------------------- | ------------------------------------ | +| `utilityProcess.fork()` | **View start** | `view.name`: "Utility: {serviceName}" | New view per process | +| Utility spawn | **View update** | context: pid, creationTime | Process identity | +| Utility exit (code=0) | **View end** + **Action** | `action.type`: custom, `target.name`: "process_exit" | Product brief: clean-exit → Action | +| Utility exit (code≠0) | **View end** + **Error** | `error.source`: "source", `error.handling`: "unhandled" | Product brief: abnormal-exit → Error | +| `child-process-gone` (crashed/oom/killed) | **Error** on process view | `error.is_crash`: true, context: reason, exitCode | Enriches with crash reason | +| Error forwarded via parentPort | **Error** on process view | `error.message`, `error.stack`, `error.type` | Full stack trace available | +| Metrics (getAppMetrics) | **View update** | `memory_average`, `memory_max`, `cpu_ticks_per_second` | Periodic polling | + +**Event lifecycle:** + +1. `fork()` → create view, record start time +2. `spawn` event → record pid +3. `exit` event → record exitCode, duration +4. `child-process-gone` (if abnormal) → enrich with crash reason +5. Emit view end + error/action + +### spawn/exec as Resource + +| Datapoint | RUM Event | Fields | Notes | +| ----------------------- | --------------------- | ---------------------------------------------------- | ------------------------------------- | +| `spawn/exec/execFile` | **Resource** | `type`: "native", `url`: "child_process://{command}" | Mirrors network resource/span duality | +| Exit code | Resource | `status_code`: exit code | 0=success, nonzero=failure | +| Duration | Resource | `duration`: spawn→exit time | | +| Error (ENOENT, timeout) | Resource (same event) | `status_code`: -1, context: error details | **No separate Error event** | +| Command args | Resource context | context: args, shell, cwd | Temporary; needs schema extension | +| Sync variants | Resource | Same mapping | | + +**Key design decisions:** + +- Errors on spawn/exec do NOT produce separate Error events. All error info lives on the Resource itself (like failed HTTP requests). +- `child-process-gone` does NOT fire for Node.js child_process spawns (validated in proto-lifecycle). +- The product brief positions spawn/exec as APM `command_execution` spans. The RUM Resource mirrors the APM span, following the same pattern as network requests (RUM Resource + APM client span). + +**Resource event lifecycle:** + +1. Wrapper intercepts spawn/exec call → record start time +2. `exit` event → emit Resource with duration + status_code +3. `error` event → emit Resource with error info in context + +### Renderer Process Monitoring + +| Datapoint | RUM Event | Fields | +| ------------------------------------------ | -------------------------- | ------------------------------------------------------------------- | +| Renderer creation (via getAppMetrics diff) | **View start** | `view.name`: "Renderer: {webContentsId}" | +| `render-process-gone` | **Error** on renderer view | `error.is_crash`: true, context: reason, exitCode | +| Browser-rum page views | **View** (child) | `container.view.id` → renderer view, `container.source`: "electron" | +| Renderer metrics | **View update** | `memory_average`, `cpu_ticks_per_second` | + +### Performance Metrics + +Attached to process views as periodic view updates (like mobile SDKs). + +| getAppMetrics field | View field | Notes | +| --------------------- | ------------------------------- | ----------------------------------- | +| `workingSetSize` | `memory_average` / `memory_max` | Running average + peak | +| `percentCPUUsage` | `cpu_ticks_per_second` | Unit conversion needed (% vs ticks) | +| Process count changes | Main view context | Detect appeared/disappeared | + +--- + +## Schema Fit Assessment + +| Mapping | Fit | Production Requirement | +| --------------------------------- | ------------- | --------------------------------------------------------------------- | +| Processes as Views | **Excellent** | No schema change | +| Container hierarchy | **Excellent** | Already implemented | +| child-process-gone → Error/Action | **Good** | Need schema field for `reason` (not context) | +| Utility errors → Error | **Excellent** | No change | +| spawn/exec → Resource (native) | **Moderate** | Need `resource.process` sub-object (command, args, exit_code, signal) | +| Metrics → View fields | **Good** | CPU unit alignment (% vs ticks) | + +**Important:** `context` attributes are customer-owned in production. For the prototype, we use context for process-specific data (reason, args, pid). For production, these need schema extensions in `rum-events-format`: + +- `resource.process` — command, args, exit_code, signal +- `error.process` — type, service_name, reason +- View-level process identity fields + +--- + +## Product Brief Alignment + +| Brief Recommendation | Our Mapping | Delta | +| ---------------------------------------- | -------------------------------------- | --------------------------------------------------------- | +| child-process-gone (clean-exit) → Action | Action | Aligned | +| child-process-gone (abnormal) → Error | Error | Aligned | +| spawn/exec → APM command_execution spans | Resource (native) for RUM side | Brief says APM; we add RUM mirror (resource/span duality) | +| Metrics → mini-timeseries on session | View-level aggregates (mobile pattern) | Different mechanism, same data | diff --git a/prototype-child-processes_04_rum-mapping-prototype.md b/prototype-child-processes_04_rum-mapping-prototype.md new file mode 100644 index 00000000..9a3a652c --- /dev/null +++ b/prototype-child-processes_04_rum-mapping-prototype.md @@ -0,0 +1,165 @@ +# Prototype Findings: Child Process RUM Telemetry + +Findings from implementing the RUM concept mapping (`_03_rum-mapping.md`) as a working prototype. + +## Architecture summary + +``` +ChildProcessCollection → patches require('node:child_process') + emits RawRumResource on completion + reentrant guard deduplicates exec→execFile→spawn chain + +UtilityProcessCollection → patches utilityProcess.fork() + emits RawRumView (process as view) + emits RawRumAction (clean exit) + emits RawRumError (abnormal exit / crash) + polls app.getAppMetrics() for memory + +RendererProcessCollection → detects renderers via web-contents-created + did-start-navigation + lazy creation from Assembly via getOrCreateRendererViewId + emits RawRumView (renderer as view) + emits RawRumError (render-process-gone) + polls app.getAppMetrics() for memory + +Assembly (modified) → uses senderPid + getRendererContainerViewId + to set container.view.id on renderer events + passes event date for renderer view backdating + +BridgeHandler (modified) → extracts sender pid from IpcMainEvent +``` + +All events flow through the existing pipeline: EventManager → Assembly → Transport → intake. + +## What was built + +| Step | Feature | Validated | +| ---- | ------------------------------------------------------------------ | -------------------------------------------- | +| 0 | Playground test harness (Playwright + mock intake) | Smoke test: view event arrives | +| 1 | `ChildProcessCollection` — spawn/exec/execFile as resources | 4 scenarios (success, echo, ENOENT, timeout) | +| 2 | `UtilityProcessCollection` — utility processes as views | Fork view + crash error | +| 3 | Performance metrics polling via `app.getAppMetrics()` | memory_average/memory_max on utility views | +| 4 | `RendererProcessCollection` — renderer views + container hierarchy | Renderer detection + crash error | + +11 Playwright scenarios total, all passing. + +## Findings + +### Monkey-patching + +Multiple issues reinforce the need to explore a proper instrumentation approach before going to production. + +**Rollup namespace wrapper breaks `Object.defineProperty`.** +`import * as mod from 'node:child_process'` produces a Rollup namespace wrapper (`_interopNamespaceDefault`). Patching properties on the wrapper does not affect the real module — other consumers still see the original functions. **Fix**: use `require()` to get the actual CommonJS module object. This applies to any future monkey-patching in the SDK. + +**exec/execFile/spawn call chain produces duplicate resource events.** +Node's `exec()` delegates to `execFile()` which delegates to `spawn()`. Since all three are patched, a single `exec()` call triggered instrumentation at every level. Additionally, a failed `spawn()` fires both `error` and `close` events. **Fix**: reentrant `insideHigherLevelCall` flag prevents lower-level patches from emitting when called from a higher-level wrapper; one-shot `emitted` flag prevents close+error double-emission on spawn. + +### Renderer process tracking + +**`webContents.getOSProcessId()` returns 0 after renderer crash.** +By the time `render-process-gone` fires, the OS process is already dead and the pid accessor returns 0. **Fix**: maintain a `webContents.id → pid` mapping proactively during detection, before any crash occurs. + +### Event pipeline + +**Container hierarchy requires sender identity.** +The bridge handler (`BridgeHandler`) had no notion of which renderer sent an event. To set `container.view.id` to the renderer's process view, we added `senderPid` to `RawRumEvent` and extracted it from `IpcMainEvent.sender.getOSProcessId()`. This is a production requirement — the main view ID was previously used for all renderers, which is incorrect for multi-window apps. + +**View date bug (pre-existing).** +`ViewCollection` did not set `date` on raw view events. The `commonContext` hook set `date: Date.now()` at assembly time, so every view update got the timestamp of the latest update instead of the view's creation time. Fixed by setting `date: startTime` on the raw event. + +### Renderer process view timing + +**Renderer view must predate browser-rum events.** +The renderer process view must start before (or at the same time as) the first browser-rum view event from that renderer. Otherwise the timeline shows the process view appearing after the events it contains. + +**Approach implemented (prototype):** + +1. **Early detection** — listen to `app.on('web-contents-created')` then `did-start-navigation` (fires before `did-finish-load`) for eager renderer tracking +2. **Lazy creation with backdating** — `getOrCreateRendererViewId(pid, eventDate)` creates the view on-demand from Assembly using the bridge event's `date` as `startTime`, guaranteeing the view predates the event + +**Alternate approach for production:** +Create the renderer view at the bridge level (`BridgeHandler`) rather than Assembly. The bridge receives IPC messages before Assembly processes them, making it the earliest possible interception point. The bridge would call `getOrCreateRendererViewId(senderPid, eventDate)` directly, so the view exists before the event even reaches the Assembly pipeline. + +### Memory and CPU metrics + +**Memory uses existing RUM view fields.** +The RUM view schema already defines `view.memory_average` and `view.memory_max` (used by mobile SDKs). The prototype uses these directly — no schema extension needed for memory. Data comes from `app.getAppMetrics()` → `memory.workingSetSize` (current physical RAM usage in KB), sampled at each poll interval. For `memory_max`, `memory.peakWorkingSetSize` from the last sample could also be used as the OS-level peak. + +**CPU metrics not prototyped — unit mismatch needs investigation.** +The RUM schema has `view.cpu_ticks_count` and `view.cpu_ticks_per_second` (mobile SDK fields). Electron provides `cpu.cumulativeCPUUsage` (total CPU seconds) and `cpu.percentCPUUsage` (% since last call). These measure the same concept (total CPU consumption and average intensity) but in different units (seconds vs ticks). + +**Both memory and CPU fields are mobile SDK fields** — Datadog does not display them for non-mobile platforms. Production will need backend/UI support to surface these metrics for Electron views. + +### Schema gaps + +The RUM mapping doc anticipated these, and the prototype confirms them: + +| Current location | What's stored | Production schema needed | +| ----------------------------- | --------------------------- | ----------------------------- | +| `resource.context.args` | Command arguments | `resource.process.args` | +| `resource.context.cwd` | Working directory | `resource.process.cwd` | +| `resource.context.error_code` | errno (e.g., ENOENT) | `resource.process.error_code` | +| `view.context.pid` | OS process ID | `view.process.pid` | +| `error.context.reason` | Gone reason (crashed, oom…) | `error.process.reason` | +| `error.context.exit_code` | Exit code | `error.process.exit_code` | + +### Self-instrumentation + +The SDK calls `execFile('sw_vers')` internally for user-agent detection. This is filtered via an allowlist (`SELF_INSTRUMENTATION_COMMANDS`). The allowlist approach is fragile — any new internal child_process call must be manually added. Production alternatives: + +- Thread a "skip instrumentation" flag through internal call sites +- Use the original (unpatched) function reference for SDK-internal calls + +### Polling model + +The prototype uses a 2s polling interval for metrics on both utility and renderer process views. Renderer detection itself is event-driven (`web-contents-created` + `did-start-navigation`), with lazy creation from bridge events as fallback. Tradeoffs for metrics polling: + +- **Faster polling** = fresher memory data, but more CPU and more view update events +- **Slower polling** = less overhead, but stale memory readings + +Before production, we should explore how mobile SDKs (iOS/Android) collect and report process metrics, and align the approach. The polling model and reporting frequency may already be established there. + +### exec error codes + +`exec` callback errors have `error.code` which can be a string (e.g., `'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'`) rather than a number. The prototype handles this with a fallback to `-1` for `status_code`, but production should distinguish errno strings from numeric exit codes. + +### Application path sanitization + +Events from all sources (main process, child process, renderer/browser-rum) can contain full filesystem paths to the Electron app in view names/URLs, resource URLs, and error stack traces. This leaks the local directory structure. + +**Prototype approach:** Global string replace on serialized JSON in `BatchProducer.writeData`, stripping `app.getAppPath()` from all string values. E.g., `file:///Users/.../playground/dist/index.html` → `file:///index.html`. + +**Key finding: `[APP_PATH]` placeholder breaks Datadog UI.** +The initial approach replaced the path with `[APP_PATH]`, but the resulting URL was not displayed in the Datadog RUM Explorer. It's unclear whether the issue is at ingestion, processing, or display — the URL may be rejected or dropped at any stage. Stripping the path entirely (empty replacement) produces URLs the UI displays correctly. + +**Production recommendations:** + +- **Assembly-level hook** — a dedicated sanitization step in Assembly, applied to specific fields (view.url, view.name, error.stack, resource.url) rather than brute-force string replace on serialized JSON. More targeted, avoids false positives. + +## Known issues and open questions + +### Renderer crash processing not investigated + +The prototype emits an error event on `render-process-gone`, but no investigation was done on whether the existing `CrashCollection` (minidump processing) correctly handles renderer crash dumps. Open questions: does the crash dump get associated to the correct view (renderer process view vs main view)? Does it need extra wiring? + +### Utility process crash dumps fail to process + +When a utility process crashes, the existing `CrashCollection` attempts to process the minidump but fails: + +``` +Failed to process crash dump: .../Crashpad/pending/8b1e6e13-....dmp +TypeError: Cannot read properties of undefined (reading 'crashing_thread') + at formatThreads (dist/index.cjs:1526:32) + at buildCrashErrorEvent (dist/index.cjs:1472:21) + at CrashCollection.processCrashFiles (dist/index.cjs:1436:27) +``` + +The WASM minidump processor likely expects a main-process crash dump format. Utility process crash dumps may have a different structure (missing `crashing_thread`). Needs investigation when we want to capture detailed crash reports for utility processes. + +### UI displays "Page" instead of "Process" for views + +The Datadog RUM Explorer labels all views as "Load Page ..." which is misleading for process views (e.g., "Load Page Utility: dd-demo-fork"). The `view.name` is set correctly but the UI prepends "Load Page" to all view events. This is a Datadog platform behavior — may need a different `view.type` or a schema extension to distinguish process views from page views. + +### UI does not display resource status codes clearly + +The RUM Explorer does not render `resource.status_code` prominently for native resources. Exit codes like `0`, `-1`, `-2` from child processes are not displayed as clearly as HTTP status codes (200, 404, etc.) would be. This is a display limitation — the data is correct in the events. diff --git a/prototype-child-processes_05_conclusion.md b/prototype-child-processes_05_conclusion.md new file mode 100644 index 00000000..5c96f49f --- /dev/null +++ b/prototype-child-processes_05_conclusion.md @@ -0,0 +1,300 @@ +# Child Process Monitoring — Conclusion & Next Steps + +The prototype (docs 00-04) validated feasibility across all three instrumentation mechanisms: child_process APIs, Electron utility processes, and renderer process tracking. This document synthesizes findings into concrete work items, organized by topic with value/complexity ratings, and proposes a sequencing that accounts for the in-progress dd-trace integration. + +## Overview + +| Topic | Value | Complexity | +| ------------------------------------------------- | ------------ | ----------- | +| Data model agreement | Prerequisite | Medium | +| dd-trace integration evaluation | Strategic | Easy | +| exec/spawn/execFile monkey-patching (or dd-trace) | Very High | Challenging | +| exec/spawn/execFile collection | Very High | Easy-Medium | +| Utility process monitoring | High | Medium | +| Renderer process views | High | Medium | +| Bugs identified | Medium | Easy | +| Playground improvements to extract | Low | Easy | + +--- + +## 1. Data Model Agreement + +**Value: Prerequisite | Complexity: Medium** + +Schema extensions are needed to move data out of `context.*` (customer-owned in production) into proper schema fields. This gates production implementation of all instrumentation topics. The prototype validated that the RUM event model can represent Electron processes — but the specific schema fields need agreement before implementation. + +### Processes as Views + +Every Electron process becomes a RUM View, providing built-in correlation (`view.id`), memory/CPU fields (from mobile SDK schema), error/resource/action counters, and container hierarchy for process nesting. + +**View naming**: `"{ProcessType}: {identifier}"` — e.g., `"Utility: dd-proto-worker"`, `"Renderer: {pageTitle or webContentsId}"`, `"GPU"` + +**Schema fields needed:** + +| Prototype (context) | Production schema needed | Purpose | +| ------------------- | ------------------------ | ------------------------------------- | +| `view.context.pid` | `view.process.pid` | Process identity on all process views | + +**Alternatives considered:** + +- Using RUM Sessions per process instead of Views — rejected because sessions lack the container hierarchy needed for renderer → page view nesting, and would break the single-session-per-app model +- Using custom Actions instead of Views — rejected because Actions don't support metrics polling, and while events can be attached via `action.id`, Views provide richer built-in semantics (duration, counters, container hierarchy) +- Using Feature Operations instead of Views — rejected because operations sit above views in the hierarchy (designed for user-facing workflows spanning multiple pages), while processes sit below/alongside views as infrastructure containers. Operations also lack metrics polling and container hierarchy + +**Open decisions:** + +- **CPU metrics unit mismatch**: RUM schema uses `cpu_ticks_per_second` (mobile SDK), Electron provides `percentCPUUsage` (%). Need conversion formula or new schema field +- **UI labels**: RUM Explorer shows "Load Page" for all views. Process views need a `view.type` distinction or backend/UI change to display correctly +- **Metrics polling frequency**: prototype uses 2s. Align with mobile SDK approach and evaluate overhead tradeoff + +### Command Execution as Resource + +Child process spawns (`spawn`, `exec`, `execFile`) map to RUM Resources, similar to how HTTP requests are tracked. This gives duration, status, and error correlation for free. + +**Resource mapping**: `type: "native"`, `url: "{method}://{command}"` (e.g., `spawn://ls`, `exec://git status`) + +**Schema fields needed:** + +| Prototype (context) | Production schema needed | Purpose | +| ----------------------------- | ----------------------------- | --------------------- | +| `resource.context.args` | `resource.process.args` | Command arguments | +| `resource.context.cwd` | `resource.process.cwd` | Working directory | +| `resource.context.error_code` | `resource.process.error_code` | Error code on failure | + +**Why Resources and not Views?** Unlike Electron processes (utility, renderer) which are long-lived with ongoing metrics (memory, CPU), command executions are short-lived operations with a fixed set of data points (command, args, duration, exit code). This maps naturally to Resources — similar to HTTP requests — rather than Views which are designed for ongoing observation with attached sub-events. + +**Alternatives considered:** + +- Using RUM Actions instead of Resources — rejected because Actions don't have duration/status_code semantics and would lose the parallel with HTTP request tracking +- Using RUM Errors for failed commands — rejected because the Resource already carries status_code and error context; a separate Error event would be redundant +- Using Feature Operations instead of Resources — rejected because operations are designed for user-facing workflows (checkout, profile loading), not high-frequency system-level commands. Every `git status` spawn would flood the operations catalog. Operations also lack `status_code` and `url` scheme that Resources provide + +**Open decisions:** + +- **Resource status codes**: exit codes (-1, -2, 0) not displayed as prominently as HTTP status codes in the UI. May need UI/backend support +- **Sensitive data scrubbing**: command args may contain secrets. Need scrubbing strategy before shipping + +### Other Schema Extensions + +| Prototype (context) | Production schema needed | Used by | +| ------------------------- | ------------------------- | --------------------------------------- | +| `error.context.reason` | `error.process.reason` | Crash/exit reason from lifecycle events | +| `error.context.exit_code` | `error.process.exit_code` | Exit code from lifecycle events | + +--- + +## 2. dd-trace Integration Evaluation + +**Value: Strategic | Complexity: Easy** + +A dd-trace integration is in progress ([electron-sdk#95](https://github.com/DataDog/electron-sdk/pull/95), [dd-trace-js#7002](https://github.com/DataDog/dd-trace-js/pull/7002)). The monkey-patching of `child_process` (topic 3) is the most challenging work item — dd-trace could eliminate it entirely. Worth exploring in parallel with schema discussions. + +### Current state of the integration (reviewed) + +The Electron dd-trace integration currently covers: + +- **HTTP spans**: `fetch()`, `net.request()`, `net.fetch()` → converted to RUM Resources via `ResourceConverter` +- **IPC spans**: `ipcMain` / `ipcRenderer` send/receive → `electron.main.send/receive`, `electron.renderer.send/receive` +- **BrowserWindow wrapping**: injects preload script for renderer IPC tracing (not for lifecycle monitoring) +- **Architecture**: dd-trace → ElectronExporter → diagnostics_channel `datadog:apm:electron:export` → SDK `ResourceConverter` → RUM events + +### What the integration does NOT currently cover + +- **child_process** (spawn/exec/execFile) — dd-trace has a separate `child_process` plugin with `command_execution` spans, but it is not enabled in the Electron integration. Could potentially be enabled. +- **utilityProcess** — Electron-specific, no dd-trace plugin exists +- **Lifecycle events** — `child-process-gone`, `render-process-gone`, `getAppMetrics()` — Electron-specific, not covered +- **Renderer process lifecycle** — BrowserWindow wrapping is for preload injection only, not creation/crash/metrics tracking + +### Remaining questions + +- Can the existing dd-trace `child_process` plugin be enabled in the Electron context to get `command_execution` spans? +- If so, does the diagnostics_channel export pattern work for these spans too? + +### Decision outcome + +- **If `child_process` plugin can be enabled**: skip standalone monkey-patching, convert `command_execution` spans → RUM Resources via the existing `ResourceConverter` pattern +- **If not viable**: implement standalone monkey-patching with RITM + shimmer (not raw `Object.defineProperty`) +- **Either way**: utility process, renderer views, and lifecycle events need standalone implementation — dd-trace doesn't cover these Electron-specific APIs + +--- + +## 3. exec/spawn/execFile Monkey-Patching (or dd-trace) + +**Value: Very High | Complexity: Challenging** + +Intercepting Node.js built-in `child_process` module requires monkey-patching with significant fragility and maintenance concerns. This complexity is **exclusive to `child_process`** — utility process and renderer instrumentation use simple Electron API wrapping and event listeners with no bundler issues. This is where dd-trace could help the most. Prerequisite for the collection topic below. + +### Tasks + +- Evaluate dd-trace integration first (see topic 2) — if it covers child_process, skip standalone monkey-patching +- If standalone: implement with RITM + shimmer instead of raw `Object.defineProperty` for bundler robustness +- Test with all bundler configurations (Webpack, esbuild, Vite) + +### Key findings to keep in mind + +- `import * as mod` (Rollup `__importStar`) creates non-configurable getters — patching has no effect. Must use `require()` to get the actual CommonJS module +- `diagnostics_channel` for child_process does NOT exist in Node.js v22.21.1 (Electron 39) +- Libraries like shimmer and RITM (require-in-the-middle) handle bundler edge cases that raw `Object.defineProperty` does not + +### dd-trace impact + +dd-trace has a `child_process` plugin that generates `command_execution` spans via RITM + shimmer. It is not currently enabled in the Electron integration, but could potentially be. If enabled, this entire topic is eliminated — the existing `ResourceConverter` pattern (diagnostics_channel → RUM events) would handle the conversion. **This topic should wait for the dd-trace evaluation.** + +--- + +## 4. exec/spawn/execFile Collection + +**Value: Very High | Complexity: Easy-Medium** + +High customer likelihood (VS Code spawns constantly), rich data (command, args, duration, exit code). The collection logic itself — wrapping calls, capturing data, emitting RUM Resources — is straightforward once the instrumentation hook is in place (topic 3). + +### Tasks + +- Wrap spawn/exec/execFile calls to capture: command, args, duration, exit code, errors +- Emit RUM Resources with `type: "native"`, `url: "{method}://{command}"`, `status_code` = exit code +- Deduplication: `exec` → `execFile` → internal spawn chain fires at every level. Prototype uses reentrant `insideHigherLevelCall` flag + one-shot `emitted` flag for close+error double-emission +- Handle `util.promisify(exec)` — bypasses wrapper and calls `execFile` directly, needs symbol copying +- Self-instrumentation filtering: SDK's own `execFile('sw_vers')` gets captured. Current allowlist is fragile — prefer keeping reference to original unpatched function for SDK-internal calls + +### Key findings to keep in mind + +- `exec` callback errors have string codes (e.g., `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`), not just numbers +- All async and sync variants need coverage: `spawn`, `exec`, `execFile`, `spawnSync`, `execSync`, `execFileSync` + +### dd-trace impact + +If dd-trace provides `command_execution` spans, the collection logic simplifies to a span → RUM Resource conversion, eliminating deduplication and self-instrumentation concerns entirely. + +--- + +## 5. Utility Process Monitoring + +**Value: High | Complexity: Medium** + +Growing Electron API adoption — VS Code has migrated its extension host, language servers, file watcher, and search from `child_process.fork` to `utilityProcess`. Fork wrapper is straightforward. Main-process-only collection captures ~80% of useful telemetry with near-zero overhead. + +### Tasks + +- Implement fork wrapper via `Object.defineProperty` on `utilityProcess` singleton +- Choose telemetry channel: dedicated MessagePort (recommended) over parentPort piggyback +- Wire `app.on('child-process-gone')` to emit crash errors on the utility process View +- Implement `getAppMetrics()` polling for memory metrics (memory works, CPU unit mismatch — see data model topic 1) +- Map utility process lifecycle to RUM View + Action (clean exit) / Error (abnormal exit) +- Investigate crash dump processing — WASM minidump processor fails on utility process dumps (missing `crashing_thread` field, expects main-process format) + +### Key findings to keep in mind + +- `child-process-gone` cannot distinguish crash from intentional `process.exit(1)` — both produce `reason: abnormal-exit` +- `serviceName` in `getAppMetrics()` shows `node.mojom.NodeService` (Chromium internal), not user's serviceName — use `name` field from fork options instead +- `exitCode` differs: `child-process-gone` reports 256 instead of 1 for `process.exit(1)` +- parentPort piggyback leaks `{ __dd: true }` messages into customer `message` handlers (EventEmitter delivers to all listeners) — dedicated MessagePort avoids this +- Crash error ordering: error message via parentPort arrives BEFORE `child-process-gone` event — useful for enrichment +- Electron always runs one default Utility process (`network.mojom.NetworkService`) — needs filtering or explicit handling + +### Instrumentation approach + +No monkey-patching needed here — `utilityProcess` is an Electron singleton, wrapping `fork()` via `Object.defineProperty` is straightforward and doesn't have the bundler/RITM concerns that `child_process` has. Lifecycle events (`child-process-gone`, `getAppMetrics`) are pure event listeners. + +dd-trace does not cover any of these Electron-specific APIs. Future consideration: if utility processes run dd-trace internally, their spans could flow to main process via the telemetry channel. + +--- + +## 6. Renderer Process Views + +**Value: High | Complexity: Medium** + +Enables container hierarchy (renderer view → browser-rum page views) and crash tracking for renderer processes. + +### Tasks + +- Detect renderer creation via `web-contents-created` + `did-start-navigation` +- Create RUM Views for each renderer with name `"Renderer: webContentsId={id}"` +- Wire `app.on('render-process-gone')` to emit crash errors on the renderer View +- Implement `getAppMetrics()` polling for renderer process metrics +- Implement container hierarchy: set `container.view.id` on browser-rum events to the renderer's process View +- Add `senderPid` to `RawRumEvent`, extracted from `IpcMainEvent.sender.getOSProcessId()` (BridgeHandler change) +- Maintain proactive `webContents.id → pid` mapping — `getOSProcessId()` returns 0 after crash +- Investigate renderer crash dump handling — existing `CrashCollection` may not handle them correctly, need to verify dump format and view association + +### Key findings to keep in mind + +- `render-process-gone` does NOT fire for normal window close — only abnormal terminations (crash, killed, OOM) +- `render-process-gone` provides `webContentsId` for window correlation +- `percentCPUUsage` returns 0 on first call, meaningful values only after second poll +- Renderer view must predate browser-rum events — use early detection + lazy creation from Assembly +- Alternative timing approach: create renderer view at bridge level before Assembly for earliest interception + +### Instrumentation approach + +No monkey-patching needed — renderer detection uses Electron event listeners (`web-contents-created`, `render-process-gone`, `getAppMetrics`). No bundler concerns. + +The dd-trace Electron integration wraps `BrowserWindow` to inject a preload script for IPC tracing, but does not provide renderer lifecycle data (creation, crash, metrics). Our standalone implementation is needed regardless. + +--- + +## 7. Bugs Identified + +**Value: Medium | Complexity: Easy** + +Pre-existing bugs found during the prototype that should be fixed independently. + +### Tasks + +- **View date bug**: `ViewCollection` does not set `date` on raw view events. `commonContext` hook overwrites with assembly-time timestamp instead of view creation time. Fix: set `date: startTime` on the raw event +- **Application path sanitization** ([RUM-15282](https://datadoghq.atlassian.net/browse/RUM-15282)): events leak full filesystem paths (e.g., `/Users/.../playground/dist/index.html`). Prototype findings: stripping path entirely (empty replacement) works; `[APP_PATH]` placeholder approach breaks Datadog UI. Should feed these findings back into the existing ticket + +--- + +## 8. Playground Improvements to Extract + +**Value: Low | Complexity: Easy** + +Small improvements made during prototyping that are worth keeping. + +### Tasks + +- Extract Playwright + mock intake test harness infrastructure (without prototype-specific scenarios) +- Extract "copy session id" button for debugging convenience + +--- + +## Sequencing + +### Phase 1 — No blockers, can start immediately and in parallel + +- **dd-trace evaluation** — determines Phase 2 approach for exec/spawn. Small effort, high impact on planning +- **Data model proposal** — draft schema extensions, submit for agreement. Gates Phase 2 production work +- **Bug fixes** — view date, path sanitization. Independent, low risk +- **Playground extraction** — test harness, copy session id. Independent + +### Phase 2 — After Phase 1 gates clear + +- **Utility process monitoring** — depends on data model agreement +- **Renderer process views** — depends on data model agreement +- **exec/spawn monkey-patching (or dd-trace)** — depends on dd-trace evaluation. If dd-trace covers it, skip entirely +- **exec/spawn collection** — depends on data model agreement + monkey-patching/dd-trace (topic 3) + +### Phase 3 — Deferred investigation + +- **Crash dump processing** — utility + renderer crash dumps need WASM processor investigation +- **CPU metrics alignment** — needs mobile SDK alignment + backend/UI support +- **worker_threads** — not prototyped, lower priority + +### Dependencies + +``` +dd-trace evaluation ──→ exec/spawn monkey-patching (or dd-trace) ─┐ + ├──→ exec/spawn collection +data model agreement ─────────────────────────────────────────────┘ + ├──→ utility process monitoring + └──→ renderer process views + +bug fixes, playground extraction ──→ (independent) +``` + +### Parallelization opportunities + +- All Phase 1 items are independent of each other +- In Phase 2, utility process and renderer views can be parallelized (different instrumentation targets, shared data model dependency) +- exec/spawn collection can be parallelized with utility/renderer once monkey-patching (or dd-trace) is resolved +- exec/spawn monkey-patching is only needed if dd-trace doesn't cover it diff --git a/prototype-child-processes_06_demo-slides.md b/prototype-child-processes_06_demo-slides.md new file mode 100644 index 00000000..862a03d7 --- /dev/null +++ b/prototype-child-processes_06_demo-slides.md @@ -0,0 +1,53 @@ +# Child Process Monitoring in Electron + +--- + +## The Blind Spot + +``` +┌─ Electron App ─────────────────────────────────────────────────┐ +│ │ +│ Main Process + Electron SDK (orchestrates everything) │ +│ ├── Renderer Process (each window = a process) │ +│ │ └── Web page + Browser SDK (HTML/JS, like a browser tab) │ +│ ├── Renderer Process │ +│ │ └── Web page + Browser SDK │ +│ ├── Utility Process (background work, sandboxed) │ +│ ├── GPU Process (compositing, hardware accel) │ +│ └── child_process.spawn() (external commands: git, ffmpeg) │ +│ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Prototyping with AI — 4 Sessions + +| Session | Focus | Outcome | +| ------- | ------------------------------------------ | ---------------------------------------------------------- | +| 1 | Landscape research + technical feasibility | Survey of 6 process mechanisms, 3 working prototypes | +| 2 | RUM data model design | Mapping available data points to RUM events | +| 3 | Full prototype implementation | Working SDK + playground with 12 Playwright test scenarios | +| 4 | Synthesis | Conclusion doc with prioritized next steps | + +--- + +## Demo + +--- + +## Next Steps + +``` +dd-trace evaluation ──→ exec/spawn monkey-patching (or dd-trace) ─┐ + ├──→ exec/spawn collection +data model agreement ─────────────────────────────────────────────┘ + ├──→ utility process monitoring + └──→ renderer process views +``` + +--- + +## AI Usage — Key Takeaways + +- **Self-validation infrastructure is the highest-leverage investment** — building a Playwright + mock intake harness let the AI agent iterate autonomously diff --git a/prototype-child-processes_07_utility_instrumentation.md b/prototype-child-processes_07_utility_instrumentation.md new file mode 100644 index 00000000..78976027 --- /dev/null +++ b/prototype-child-processes_07_utility_instrumentation.md @@ -0,0 +1,203 @@ +# Utility Process Instrumentation — Error Capture Prototype + +Prototype findings for capturing uncaught errors inside Electron utility processes and forwarding them to the main process as RUM error events with full stack traces. + +## Goal + +When an uncaught exception occurs inside a utility process, the main process currently only sees "exited with code 1" — no error message, no stack trace. This prototype adds error capture with full stack traces, using parentPort piggyback + child.emit interception. + +The approach should support future extensions: manual error API (`addError()`), dd-trace span forwarding, and other telemetry from utility processes. + +## Architecture + +``` +Main Process (UtilityProcessCollection) Utility Process ++--------------------------------------+ +--------------------------------------+ +| patchedFork(): | | Customer entry file: | +| 1. Call original fork() | | require('@datadog/electron-sdk/ | +| 2. Override child.emit('message') | | utility') | +| to intercept __dd messages | | ↳ registers uncaughtException | +| 3. On __dd msg: emitError() | | + unhandledRejection handlers | +| | | | +| child.emit('message', msg) | | On error: | +| ↳ msg.__dd? → handle + swallow | | parentPort.postMessage( | +| ↳ else → original emit <---+--------+ { __dd: true, type: 'error', | +| (customer sees it) | | message, stack }) | ++--------------------------------------+ +--------------------------------------+ +``` + +Error flow: uncaught error in utility process → SDK module catches it → sends `{ __dd: true, type, message, stack }` via `parentPort.postMessage()` → Electron delivers to main process → `child.emit('message')` intercepted → SDK swallows `__dd` message and emits `RawRumError` on the utility process view → EventManager → Assembly → Transport. Customer handlers never see the `__dd` message. + +## Injection approaches investigated + +### Approach 1: `execArgv` with `--require` — does NOT work + +**Hypothesis:** Pass `--require /path/to/preload.cjs` via the `execArgv` option of `utilityProcess.fork()` to transparently load the SDK's error handler before the customer's entry module. + +**Test result:** The `--require` flag appears in `process.execArgv` inside the utility process, but the required file never executes. + +**Root cause (confirmed at Electron source level):** + +Two independent blocking points prevent `--require` from working: + +1. **`IsAllowedOption` allowlist** ([`shell/common/node_bindings.cc` L425](https://github.com/electron/electron/blob/main/shell/common/node_bindings.cc)): Electron maintains an explicit allowlist of Node.js flags that are permitted. `--require` is not on it. Only debug/inspect flags, diagnostic flags, and a few others are allowed. V8 flags like `--max-old-space-size` bypass this because V8 processes them directly from the command line before Node.js initialization. + +2. **`exec_args` stored but never parsed** ([Node.js `env.cc` L813](https://github.com/nicolo-ribaudo/node/blob/main/src/env.cc)): Even if `--require` made it past the allowlist, Node.js's embedding API stores `exec_args` as `process.execArgv` but never parses them into the options object. When `loadPreloadModules()` calls `getOptionValue('--require')`, it reads from global options which know nothing about per-environment `exec_args`. + +**Reference:** [electron/electron#49252](https://github.com/electron/electron/issues/49252) confirms the `IsAllowedOption` mechanism. + +**Verdict:** Not viable. This is a deliberate Electron security restriction. + +### Approach 2: `NODE_OPTIONS` env var with `--require` — works in dev, blocked in packaged apps + +**Hypothesis:** Set `NODE_OPTIONS="--require /path/to/preload.cjs"` in the utility process environment via the `env` fork option. + +**Test result:** Works in development (unpackaged Electron). The preload executes, error capture works end-to-end. + +**Packaged apps — inconclusive:** When tested against VS Code, a log was observed: `ERROR:electron/shell/common/node_bindings.cc:484] Most NODE_OPTIONs are not supported in packaged apps.` The `SetNodeOptions()` function in `node_bindings.cc` gates `NODE_OPTIONS` processing behind `fuses::IsNodeOptionsEnabled()`, which is typically disabled in packaged apps. However, this error log may have been triggered by unrelated `NODE_OPTIONS` flags already present in the environment, not by our `--require` injection specifically. **We did not isolate the test to confirm that our `--require` flag is the one being rejected.** This needs a dedicated test: set `NODE_OPTIONS="--require /path/to/preload.cjs"` as the _only_ `NODE_OPTIONS` value in a packaged Electron app (e.g., VS Code) and verify whether the preload executes. + +**Additional finding — `process.parentPort` timing:** When `--require` runs (via `NODE_OPTIONS` in dev), `process.parentPort` is NOT yet available. It becomes available after Electron's utility process initialization, before the entry module executes. The preload must defer `parentPort` access using `process.nextTick()`. + +**Verdict:** Works in dev. Potentially viable for production if `NODE_OPTIONS` with `--require` is not stripped in packaged apps — **needs confirmation with an isolated test against a packaged Electron app.** If confirmed working, this would enable transparent injection without customer code changes. + +### Approach 3: Customer imports SDK module — works everywhere (chosen) + +**Design:** The customer adds one line at the top of their utility process entry file: + +```js +require('@datadog/electron-sdk/utility'); +``` + +The SDK exports this module via `package.json` exports map: + +```json +{ + "./utility": { + "require": "./dist/utility-preload.cjs" + } +} +``` + +**How it works:** + +1. The customer's `require()` call executes the SDK utility module synchronously, before any other customer code in the entry file. +2. The module registers `process.parentPort.on('message')` to receive the dedicated MessagePort from the main process. +3. The module registers `process.on('uncaughtException')` and `process.on('unhandledRejection')` to capture errors. +4. On the main process side, `UtilityProcessCollection.patchedFork()` creates a `MessageChannelMain` and transfers `port2` to the utility process on the `spawn` event. +5. When an uncaught error occurs, the utility module serializes `{ type: 'error', message, stack }` and sends it via the dedicated port. +6. The main process receives the error on `port1` and emits a `RawRumError` with the full stack trace. + +**Customer requirement:** One line of code in each utility process entry file. No other changes needed. + +**Tradeoffs:** + +- Not transparent — requires customer opt-in per utility process +- But compatible with packaged apps, no security restrictions +- Same pattern scales to manual API and dd-trace forwarding (future) + +**Verdict:** Chosen approach. Works in all Electron configurations. + +## VS Code compatibility findings + +### Port transfer on spawn blocks all parentPort messages + +**Bug:** When the SDK transfers a `MessagePortMain` via `child.postMessage({ __dd_port: true }, [port2])` on the `spawn` event, **all subsequent messages stop reaching `process.parentPort`** in the utility process. This was confirmed in VS Code where three utility processes (Extension Host, Shared Process, File Watcher) all received zero messages with the SDK enabled. + +**Root cause:** Transferring a `MessagePortMain` as a transferable via `child.postMessage()` during the `spawn` event appears to corrupt or lock Electron's internal message channel for the utility process. The exact Chromium-level mechanism is unclear, but the effect is reproducible: no messages reach `parentPort`, not even messages sent by VS Code's own `child.postMessage()` calls after fork. + +**Attempted fix 1 — pull-based handshake (rejected):** Instead of pushing the port on spawn, we tried a pull-based protocol where the utility process sends `{ __dd_ready: true }` via `parentPort.postMessage()` and the main process responds with the port transfer. This avoided the spawn-time corruption but introduced two new problems (see below). + +### Registering parentPort.on('message') drains the message buffer + +**Bug:** Electron's `parentPort` buffers incoming messages until the first `'message'` listener is registered (similar to `MessagePort.start()`). The SDK utility module runs at `require()` time (before the customer's async entry point loads), so its `parentPort.on('message')` registration drains the buffer before the application's handlers are set up. + +In VS Code, the bootstrap sequence is: + +1. `require('@datadog/electron-sdk/utility')` — SDK registers `parentPort.on('message')` → buffer drains +2. `await bootstrapESM()` — event loop yields, buffered messages delivered to SDK handler +3. `await import(entrypoint)` — app module loads +4. App registers `parentPort.on('message')` — too late, messages already consumed + +Verified by progressively disabling parts of the preload: an empty function body works; adding only `parentPort.on('message')` breaks the Extension Host (10s timeout); deferring the listener by 3s via `setTimeout` works but is fragile. + +### Overriding child.on/child.once breaks message delivery + +**Bug:** Wrapping `child.on('message')` and `child.once('message')` on the main process side to filter SDK-internal messages (`__dd_ready`) caused **zero messages** to reach `parentPort` in any utility process. The exact cause is unclear — Electron internals likely depend on the original prototype `on`/`once` methods for message dispatching. + +### Solution — parentPort piggyback + child.emit interception (chosen) + +**Validated with VS Code.** The approach avoids all three pitfalls: + +**Utility process side — send only, no listener:** + +- Errors are sent via `process.parentPort.postMessage({ __dd: true, type: 'error', message, stack })` +- **No** `parentPort.on('message')` listener is registered → no buffer drain +- No MessagePort transfer needed + +**Main process side — child.emit interception:** + +- Override `child.emit` (not `child.on`/`child.once`) to intercept `'message'` events +- Messages with `__dd: true` are swallowed before reaching customer handlers +- Non-SDK messages pass through to the original `emit` untouched + +``` +Electron dispatches message → child.emit('message', msg) + → SDK checks msg.__dd + → If __dd: handle internally (emit RawRumError), return true (swallowed) + → If not: call original emit → customer handlers see the message +``` + +**Why `child.emit` is safe to override while `child.on`/`child.once` are not:** + +Electron's `UtilityProcess` is a C++ object exposed to JavaScript as a Node.js `EventEmitter`. The message delivery path is: + +1. Chromium's IPC layer receives a message from the utility process +2. Electron's C++ code calls into JavaScript to dispatch the event +3. This triggers `child.emit('message', msg)` on the JS side +4. `emit` iterates the registered listeners and calls each one + +Overriding `on`/`once` (step 4's registration) breaks Electron because the C++ binding may use the original prototype methods to register internal handlers during object construction — before our override runs. When we replace `on`/`once` on the instance, these internal registrations may be affected or the C++ side may hold a reference to the original methods. + +Overriding `emit` (step 3's dispatch) is safe because: + +- It's called _after_ all internal setup is complete +- It's the final JavaScript entry point before listeners fire +- We call the original `emit` for non-SDK messages, so all listeners (including any internal ones) still receive their events +- We only suppress `emit` for `__dd` messages, which no internal code expects + +In effect, `emit` interception is a read-only filter on the output side of the event pipeline, while `on`/`once` overrides modify the input side where Electron's internals also operate. + +**Other benefits:** + +- No `parentPort` listener in the utility process — no buffer drain +- No MessagePort transfer — no channel corruption +- SDK messages are invisible to customer handlers — no leak + +## Build pipeline findings + +### Rollup emits `require('electron')` from ambient type references + +The utility preload source uses `process.parentPort` which is typed by Electron's ambient type augmentation of `NodeJS.Process`. Even though the source never imports `electron`, rollup detects the type reference and emits `require('electron')` in the CJS output. + +Inside a utility process, this `require('electron')` call resolves to Electron's npm helper package (which reads `path.txt` to find the binary) rather than the Electron runtime. This crashes the utility process silently. + +**Fix:** A custom rollup plugin (`strip-electron-require`) removes the side-effect-only `require('electron')` from the output: + +```js +{ + name: 'strip-electron-require', + renderChunk(code) { + return code.replace(/require\('electron'\);\n?/g, ''); + }, +} +``` + +The utility preload rollup entry also uses `external: ['electron']` to prevent rollup from bundling the npm `electron` package (which would be even worse — it includes the binary path resolution code). + +## Open questions + +- **`NODE_OPTIONS` in packaged apps:** The rejection of `NODE_OPTIONS --require` in packaged Electron apps was observed via an error log in VS Code, but the test was not isolated — the log may have been triggered by unrelated `NODE_OPTIONS` flags already in the environment. A dedicated test with `NODE_OPTIONS="--require /path/to/preload.cjs"` as the only value in a packaged app is needed. If confirmed working, this would enable transparent injection without customer code changes. +- **Error deduplication:** When an uncaught exception crashes the utility process, two error events are emitted: one from the parentPort piggyback (with stack trace) and one from the `exit` handler ("exited with code N", without stack trace). The piggyback error arrives first. Production implementation should deduplicate these. +- **`child.emit` override durability:** The `child.emit` interception works with VS Code (Electron 39.8.8) but relies on Electron dispatching messages via `emit('message', ...)`. If a future Electron version changes this dispatch mechanism, the interception would break silently (SDK messages would leak to customer handlers). Production implementation should include a test that validates the interception still works. +- **Unhandled rejections:** The current prototype forwards them but the process continues running. Should the SDK track these differently (e.g., different `handling` value)? +- **Future bidirectional communication:** The current approach is one-way (utility → main). If we need the main process to push config or commands to the utility process (e.g., session ID, sample rate), we would need a return channel. Options: (a) extend parentPort piggyback to be bidirectional (main sends `{ __dd: true, ... }` via `child.postMessage()`, utility process filters with its own emit-style interception on `parentPort`), or (b) fall back to a dedicated IPC socket for high-throughput scenarios like dd-trace span forwarding. diff --git a/prototype-child-processes_08_schemas.md b/prototype-child-processes_08_schemas.md new file mode 100644 index 00000000..d15beb17 --- /dev/null +++ b/prototype-child-processes_08_schemas.md @@ -0,0 +1,103 @@ +# Electron SDK — Architecture Schemas + +Diagrams for inclusion in the RFC document. + +## Schema 1: Current State (after dd-trace integration — PR #95) + +```mermaid +graph TB + subgraph Electron App + subgraph Renderer Process + BP[Browser SDK] + end + + subgraph Main Process + DDT[dd-trace] + SDK[Electron SDK] + end + end + + DD[(Datadog)] + + %% Browser SDK → Electron SDK via bridge + BP -->|"RUM events
(IPC bridge)"| SDK + + %% dd-trace → Electron SDK via diagnostic channel + DDT -->|"HTTP spans
IPC spans
(diagnostics_channel)"| SDK + + %% Electron SDK internal sources + SDK -->|"span → resource conversion
RUM data collection
RUM APIs
session + main process view"| SDK + + %% Electron SDK → Datadog + SDK -->|"enriched events
(HTTP)"| DD + + %% Styling + classDef sdk fill:#fce8e6,stroke:#d93025 + classDef trace fill:#e6f4ea,stroke:#137333 + classDef browser fill:#fef7e0,stroke:#e37400 + classDef ext fill:#f3e8fd,stroke:#7627bb + + class BP browser + class DDT trace + class SDK sdk + class DD ext +``` + +## Schema 2: Child Process Monitoring (proposed) + +```mermaid +graph TB + subgraph Electron App + subgraph Renderer Process + BP[Browser SDK] + end + + subgraph Main Process + DDT_MAIN[dd-trace] + SDK[Electron SDK] + end + + subgraph Utility Process + UE["Utility Export
(@datadog/electron-sdk/utility)"] + DDT_UTIL[dd-trace] + end + end + + DD[(Datadog)] + + %% Browser SDK → Electron SDK via bridge + BP -->|"RUM events
(IPC bridge)"| SDK + + %% Main process dd-trace → Electron SDK + DDT_MAIN -->|"HTTP spans
IPC spans
★ command execution spans
(diagnostics_channel)"| SDK + + %% Electron SDK internal sources + SDK -->|"span → resource conversion
RUM data collection
RUM APIs
session + ★ process views"| SDK + + %% Utility process internal: dd-trace → utility export + DDT_UTIL -->|"HTTP spans
command execution spans
(diagnostics_channel)"| UE + + %% Utility export collects its own events + UE -->|"RUM data collection
RUM APIs"| UE + + %% Utility export → Main process via parentPort + UE -->|"raw events
(parentPort)"| SDK + + %% Electron SDK → Datadog + SDK -->|"enriched events
(HTTP)"| DD + + %% Styling + classDef sdk fill:#fce8e6,stroke:#d93025 + classDef trace fill:#e6f4ea,stroke:#137333 + classDef browser fill:#fef7e0,stroke:#e37400 + classDef utility fill:#e8f0fe,stroke:#4285f4 + classDef ext fill:#f3e8fd,stroke:#7627bb + + class BP browser + class DDT_MAIN,DDT_UTIL trace + class SDK sdk + class UE utility + class DD ext + + %% ★ = new in this schema vs schema 1 +``` diff --git a/rollup.config.mjs b/rollup.config.mjs index 3ccc6fe5..29c0824c 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -60,6 +60,31 @@ const config = [ external: ['electron'], plugins: sharedPlugins, }, + // Utility process preload: self-contained CJS script injected via --require in utilityProcess.fork() + // Uses external: ['electron'] to prevent bundling the npm electron package, then strips the + // resulting require('electron') from the output since it's only a type-level dependency. + { + input: 'src/entries/utilityPreload.ts', + output: [ + { + file: 'dist/utility-preload.cjs', + format: 'cjs', + sourcemap: true, + banner: '/* utility-preload: runs inside Electron utility process via --require */', + }, + ], + external: ['electron'], + plugins: [ + ...sharedPlugins, + { + name: 'strip-electron-require', + renderChunk(code) { + // Remove the side-effect-only require('electron') that rollup emits from ambient type refs + return code.replace(/require\('electron'\);\n?/g, ''); + }, + }, + ], + }, // TypeScript declarations: main { input: 'src/index.ts', diff --git a/src/assembly/Assembly.ts b/src/assembly/Assembly.ts index 287063dc..3f3ee35d 100644 --- a/src/assembly/Assembly.ts +++ b/src/assembly/Assembly.ts @@ -17,10 +17,16 @@ import { TelemetryEvent } from '../domain/telemetry'; * overridden from the main process; the renderer's own view, source, * service, and other attributes are preserved. */ +export interface AssemblyOptions { + /** Resolve a renderer process OS pid to its process view ID for container hierarchy. Creates the view if needed. */ + getRendererContainerViewId?: (pid: number, eventDate?: number) => string; +} + export class Assembly { constructor( private eventManager: EventManager, - private hooks: FormatHooks + private hooks: FormatHooks, + private options: AssemblyOptions = {} ) { this.eventManager.registerHandler({ canHandle: (event) => event.kind === EventKind.RAW, @@ -59,10 +65,16 @@ export class Assembly { } const { session, application, view } = hookResult ?? {}; + + // Use renderer process view ID if available (container hierarchy), fall back to main view + const eventDate = (event.data as { date?: number }).date; + const containerViewId = + (event.senderPid && this.options.getRendererContainerViewId?.(event.senderPid, eventDate)) ?? view?.id; + const mainProcessAttributes = { session: { id: session?.id }, application: { id: application?.id }, - container: { view: { id: view?.id }, source: 'electron' }, + container: { view: { id: containerViewId }, source: 'electron' }, }; return { diff --git a/src/bridge/BridgeHandler.ts b/src/bridge/BridgeHandler.ts index 3d6cc062..4fb3ab41 100644 --- a/src/bridge/BridgeHandler.ts +++ b/src/bridge/BridgeHandler.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { ipcMain, type IpcMainEvent } from 'electron'; import { DefaultPrivacyLevel } from '@datadog/browser-core'; import { EventKind, EventSource, EventFormat } from '../event'; import type { EventManager, RawRumEvent } from '../event'; @@ -34,8 +34,9 @@ export class BridgeHandler { ) { ipcMain.on( BRIDGE_CHANNEL, - monitor((_ipcEvent: unknown, msg: string) => { - this.onBridgeMessage(msg); + monitor((ipcEvent: IpcMainEvent, msg: string) => { + const senderPid = ipcEvent.sender?.getOSProcessId(); + this.onBridgeMessage(msg, senderPid); }) ); @@ -47,7 +48,7 @@ export class BridgeHandler { ); } - private onBridgeMessage(msg: string): void { + private onBridgeMessage(msg: string, senderPid?: number): void { let bridgeEvent: BridgeEvent; try { bridgeEvent = JSON.parse(msg) as BridgeEvent; @@ -63,6 +64,7 @@ export class BridgeHandler { source: EventSource.RENDERER, format: EventFormat.RUM, data: bridgeEvent.event, + senderPid, } as RawRumEvent); break; case 'log': diff --git a/src/domain/rum/RumCollection.ts b/src/domain/rum/RumCollection.ts index 07c3ac81..e1af14ac 100644 --- a/src/domain/rum/RumCollection.ts +++ b/src/domain/rum/RumCollection.ts @@ -1,19 +1,37 @@ import { EventManager } from '../../event'; import type { FormatHooks } from '../../assembly'; +import { ChildProcessCollection } from './childProcess'; import { ErrorCollection, CrashCollection } from './error'; +import { RendererProcessCollection } from './rendererProcess'; +import { UtilityProcessCollection } from './utilityProcess'; import { ViewCollection } from './view'; export class RumCollection { private constructor( private readonly viewCollection: ViewCollection, - private readonly errorCollection: ErrorCollection + private readonly errorCollection: ErrorCollection, + private readonly childProcessCollection: ChildProcessCollection, + private readonly utilityProcessCollection: UtilityProcessCollection, + private readonly rendererProcessCollection: RendererProcessCollection ) {} - static async start(eventManager: EventManager, hooks: FormatHooks): Promise { + static async start( + eventManager: EventManager, + hooks: FormatHooks, + rendererProcessCollection: RendererProcessCollection + ): Promise { const viewCollection = await ViewCollection.start(eventManager, hooks); const errorCollection = new ErrorCollection(eventManager); + const childProcessCollection = new ChildProcessCollection(eventManager); + const utilityProcessCollection = new UtilityProcessCollection(eventManager); CrashCollection.start(eventManager); - return new RumCollection(viewCollection, errorCollection); + return new RumCollection( + viewCollection, + errorCollection, + childProcessCollection, + utilityProcessCollection, + rendererProcessCollection + ); } getApi() { @@ -25,5 +43,8 @@ export class RumCollection { stop(): void { this.viewCollection.stop(); this.errorCollection.stop(); + this.childProcessCollection.stop(); + this.utilityProcessCollection.stop(); + this.rendererProcessCollection.stop(); } } diff --git a/src/domain/rum/childProcess/ChildProcessCollection.ts b/src/domain/rum/childProcess/ChildProcessCollection.ts new file mode 100644 index 00000000..c71f07f0 --- /dev/null +++ b/src/domain/rum/childProcess/ChildProcessCollection.ts @@ -0,0 +1,349 @@ +import type { ChildProcess, ExecFileException, SpawnSyncReturns } from 'node:child_process'; + +// Use require() to get the real module object (not a Rollup namespace wrapper) +// so that Object.defineProperty patches are visible to all consumers. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const childProcessModule = require('node:child_process') as typeof import('node:child_process'); +import { elapsed, generateUUID, type TimeStamp, timeStampNow, toServerDuration } from '@datadog/browser-core'; +import { EventFormat, EventKind, EventManager, EventSource } from '../../../event'; +import type { RawRumResource } from '../rawRumData.types'; + +// Commands spawned by the SDK itself that should not be instrumented +const SELF_INSTRUMENTATION_COMMANDS = ['sw_vers']; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFunction = (...args: any[]) => any; + +/** + * Reentrant guard: when exec/execFile call spawn internally, we skip + * the spawn instrumentation to avoid duplicate resource events. + */ +let insideHigherLevelCall = false; + +/** + * Collect child_process spawn/exec/execFile calls as RUM resource events. + * + * Each completed child process produces a resource with: + * - type: "native" + * - url: "child_process://{command}" + * - duration: time from invocation to completion + * - status_code: exit code (0 = success, -1 for errors like ENOENT/timeout) + * - context: args, shell, cwd (temporary until schema extension) + */ +export class ChildProcessCollection { + private readonly originals = { + spawn: childProcessModule.spawn, + exec: childProcessModule.exec, + execFile: childProcessModule.execFile, + spawnSync: childProcessModule.spawnSync, + execSync: childProcessModule.execSync, + execFileSync: childProcessModule.execFileSync, + }; + + constructor(private readonly eventManager: EventManager) { + this.patchSpawn(); + this.patchExec(); + this.patchExecFile(); + this.patchSpawnSync(); + this.patchExecSync(); + this.patchExecFileSync(); + } + + stop(): void { + Object.defineProperty(childProcessModule, 'spawn', { value: this.originals.spawn, writable: true }); + Object.defineProperty(childProcessModule, 'exec', { value: this.originals.exec, writable: true }); + Object.defineProperty(childProcessModule, 'execFile', { value: this.originals.execFile, writable: true }); + Object.defineProperty(childProcessModule, 'spawnSync', { value: this.originals.spawnSync, writable: true }); + Object.defineProperty(childProcessModule, 'execSync', { value: this.originals.execSync, writable: true }); + Object.defineProperty(childProcessModule, 'execFileSync', { value: this.originals.execFileSync, writable: true }); + } + + private patchSpawn(): void { + const original = this.originals.spawn as AnyFunction; + const emitResource = this.emitResource.bind(this); + + Object.defineProperty(childProcessModule, 'spawn', { + value: function patchedSpawn(command: string, ...rest: unknown[]): ChildProcess { + const startTime = timeStampNow(); + const child = original.apply(childProcessModule, [command, ...rest]) as ChildProcess; + + if (!isSelfInstrumentation(command) && !insideHigherLevelCall) { + let emitted = false; + child.on('close', (code: number | null, signal: string | null) => { + if (emitted) return; + emitted = true; + emitResource( + 'spawn', + command, + startTime, + code ?? (signal ? -1 : 0), + extractArgs(rest), + extractSpawnOptions(rest) + ); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (emitted) return; + emitted = true; + emitResource('spawn', command, startTime, -1, extractArgs(rest), extractSpawnOptions(rest), err); + }); + } + + return child; + }, + writable: true, + }); + } + + private patchExec(): void { + const original = this.originals.exec as AnyFunction; + const emitResource = this.emitResource.bind(this); + + Object.defineProperty(childProcessModule, 'exec', { + value: function patchedExec(command: string, ...rest: unknown[]): ChildProcess { + const startTime = timeStampNow(); + + // Wrap the callback if provided + const lastArg = rest[rest.length - 1]; + if (typeof lastArg === 'function') { + const originalCallback = lastArg as (error: ExecFileException | null, stdout: string, stderr: string) => void; + rest[rest.length - 1] = (error: ExecFileException | null, stdout: string, stderr: string) => { + if (!isSelfInstrumentation(command)) { + const code = error ? ((error.code as number | undefined) ?? -1) : 0; + emitResource( + 'exec', + command, + startTime, + typeof code === 'number' ? code : -1, + undefined, + undefined, + error ?? undefined + ); + } + originalCallback(error, stdout, stderr); + }; + } + + insideHigherLevelCall = true; + const child = original.apply(childProcessModule, [command, ...rest]) as ChildProcess; + insideHigherLevelCall = false; + + // If no callback was provided, listen for close/error + if (typeof lastArg !== 'function' && !isSelfInstrumentation(command)) { + let emitted = false; + child.on('close', (code: number | null) => { + if (emitted) return; + emitted = true; + emitResource('exec', command, startTime, code ?? 0); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (emitted) return; + emitted = true; + emitResource('exec', command, startTime, -1, undefined, undefined, err); + }); + } + + return child; + }, + writable: true, + }); + } + + private patchExecFile(): void { + const original = this.originals.execFile as AnyFunction; + const emitResource = this.emitResource.bind(this); + + Object.defineProperty(childProcessModule, 'execFile', { + value: function patchedExecFile(file: string, ...rest: unknown[]): ChildProcess { + // Skip if called from within exec (which already instruments) + if (insideHigherLevelCall) { + return original.apply(childProcessModule, [file, ...rest]) as ChildProcess; + } + + const startTime = timeStampNow(); + + const lastArg = rest[rest.length - 1]; + if (typeof lastArg === 'function') { + const originalCallback = lastArg as (error: ExecFileException | null, stdout: string, stderr: string) => void; + rest[rest.length - 1] = (error: ExecFileException | null, stdout: string, stderr: string) => { + if (!isSelfInstrumentation(file)) { + const code = error ? ((error.code as number | undefined) ?? -1) : 0; + emitResource( + 'execFile', + file, + startTime, + typeof code === 'number' ? code : -1, + extractArgs(rest), + undefined, + error ?? undefined + ); + } + originalCallback(error, stdout, stderr); + }; + } + + insideHigherLevelCall = true; + const child = original.apply(childProcessModule, [file, ...rest]) as ChildProcess; + insideHigherLevelCall = false; + + if (typeof lastArg !== 'function' && !isSelfInstrumentation(file)) { + let emitted = false; + child.on('close', (code: number | null) => { + if (emitted) return; + emitted = true; + emitResource('execFile', file, startTime, code ?? 0, extractArgs(rest)); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (emitted) return; + emitted = true; + emitResource('execFile', file, startTime, -1, extractArgs(rest), undefined, err); + }); + } + + return child; + }, + writable: true, + }); + } + + private patchSpawnSync(): void { + const original = this.originals.spawnSync as AnyFunction; + const emitResource = this.emitResource.bind(this); + + Object.defineProperty(childProcessModule, 'spawnSync', { + value: function patchedSpawnSync(command: string, ...rest: unknown[]): SpawnSyncReturns { + const startTime = timeStampNow(); + const result = original.apply(childProcessModule, [command, ...rest]) as SpawnSyncReturns; + + if (!isSelfInstrumentation(command)) { + const code = result.status ?? (result.error ? -1 : 0); + emitResource( + 'spawnSync', + command, + startTime, + code, + extractArgs(rest), + extractSpawnOptions(rest), + result.error + ); + } + + return result; + }, + writable: true, + }); + } + + private patchExecSync(): void { + const original = this.originals.execSync as AnyFunction; + const emitResource = this.emitResource.bind(this); + + Object.defineProperty(childProcessModule, 'execSync', { + value: function patchedExecSync(command: string, ...rest: unknown[]): Buffer | string { + const startTime = timeStampNow(); + try { + const result = original.apply(childProcessModule, [command, ...rest]) as Buffer | string; + if (!isSelfInstrumentation(command)) { + emitResource('execSync', command, startTime, 0); + } + return result; + } catch (error) { + if (!isSelfInstrumentation(command)) { + const code = (error as { status?: number }).status ?? -1; + emitResource('execSync', command, startTime, code, undefined, undefined, error as Error); + } + throw error; + } + }, + writable: true, + }); + } + + private patchExecFileSync(): void { + const original = this.originals.execFileSync as AnyFunction; + const emitResource = this.emitResource.bind(this); + + Object.defineProperty(childProcessModule, 'execFileSync', { + value: function patchedExecFileSync(file: string, ...rest: unknown[]): Buffer | string { + const startTime = timeStampNow(); + try { + const result = original.apply(childProcessModule, [file, ...rest]) as Buffer | string; + if (!isSelfInstrumentation(file)) { + emitResource('execFileSync', file, startTime, 0, extractArgs(rest)); + } + return result; + } catch (error) { + if (!isSelfInstrumentation(file)) { + const code = (error as { status?: number }).status ?? -1; + emitResource('execFileSync', file, startTime, code, extractArgs(rest), undefined, error as Error); + } + throw error; + } + }, + writable: true, + }); + } + + private emitResource( + method: 'spawn' | 'exec' | 'execFile' | 'spawnSync' | 'execSync' | 'execFileSync', + command: string, + startTime: TimeStamp, + statusCode: number, + args?: string[], + options?: { shell?: boolean; cwd?: string }, + error?: Error + ): void { + const endTime = timeStampNow(); + + const context: Record = {}; + if (args?.length) context.args = args; + if (options?.shell !== undefined) context.shell = options.shell; + if (options?.cwd) context.cwd = options.cwd; + if (error) { + context.error_message = error.message; + context.error_code = (error as NodeJS.ErrnoException).code; + } + + const resourceEvent: RawRumResource = { + type: 'resource', + date: startTime, + resource: { + id: generateUUID(), + type: 'native', + url: `${method}://${command}`, + duration: toServerDuration(elapsed(startTime, endTime)), + status_code: statusCode, + }, + ...(Object.keys(context).length > 0 ? { context } : {}), + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data: resourceEvent, + startTime, + }); + } +} + +function isSelfInstrumentation(command: string): boolean { + return SELF_INSTRUMENTATION_COMMANDS.some((cmd) => command === cmd || command.endsWith(`/${cmd}`)); +} + +function extractArgs(rest: unknown[]): string[] | undefined { + const first = rest[0]; + return Array.isArray(first) ? (first as string[]) : undefined; +} + +function extractSpawnOptions(rest: unknown[]): { shell?: boolean; cwd?: string } | undefined { + for (const arg of rest) { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const opts = arg as Record; + return { + shell: typeof opts.shell === 'boolean' ? opts.shell : undefined, + cwd: typeof opts.cwd === 'string' ? opts.cwd : undefined, + }; + } + } + return undefined; +} diff --git a/src/domain/rum/childProcess/index.ts b/src/domain/rum/childProcess/index.ts new file mode 100644 index 00000000..91aec23a --- /dev/null +++ b/src/domain/rum/childProcess/index.ts @@ -0,0 +1 @@ +export { ChildProcessCollection } from './ChildProcessCollection'; diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index e5a6cd2e..a23359f4 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -1,17 +1,21 @@ import { RecursivePartial, ServerDuration } from '@datadog/browser-core'; -import { RumErrorEvent, RumViewEvent } from './rumEvent.types'; +import { RumActionEvent, RumErrorEvent, RumResourceEvent, RumViewEvent } from './rumEvent.types'; -export type RawRumData = RawRumView | RawRumError; +export type RawRumData = RawRumView | RawRumError | RawRumResource | RawRumAction; export interface RawRumView extends RecursivePartial { type: 'view'; + context?: Record; view: { id: string; + name?: string; time_spent: ServerDuration; is_active: boolean; action: { count: number }; error: { count: number }; resource: { count: number }; + memory_average?: number; + memory_max?: number; }; _dd: { document_version: number }; } @@ -51,3 +55,25 @@ export interface RawRumError extends RecursivePartial { }[]; }; } + +export interface RawRumAction extends RecursivePartial { + type: 'action'; + context?: Record; + action: { + id: string; + type: 'custom'; + target?: { name: string }; + }; +} + +export interface RawRumResource extends RecursivePartial { + type: 'resource'; + context?: Record; + resource: { + id: string; + type: 'native'; + url: string; + duration: ServerDuration; + status_code?: number; + }; +} diff --git a/src/domain/rum/rendererProcess/RendererProcessCollection.ts b/src/domain/rum/rendererProcess/RendererProcessCollection.ts new file mode 100644 index 00000000..7d44aac9 --- /dev/null +++ b/src/domain/rum/rendererProcess/RendererProcessCollection.ts @@ -0,0 +1,261 @@ +import { app, webContents as webContentsModule, type WebContents } from 'electron'; +import { + elapsed, + generateUUID, + ONE_SECOND, + type TimeStamp, + timeStampNow, + toServerDuration, +} from '@datadog/browser-core'; +import { EventFormat, EventKind, EventManager, EventSource } from '../../../event'; +import type { RawRumError, RawRumView } from '../rawRumData.types'; + +export const METRICS_POLL_INTERVAL = 2 * ONE_SECOND; + +interface RendererView { + viewId: string; + startTime: TimeStamp; + documentVersion: number; + isActive: boolean; + counters: { action: { count: number }; error: { count: number }; resource: { count: number } }; + pid: number; + title: string; + memorySamples: number[]; +} + +/** + * Track Electron renderer processes as RUM views. + * + * - Detect renderers immediately via app 'web-contents-created' + * - Poll app.getAppMetrics() for memory metrics updates + * - Handle render-process-gone → emit error on renderer view + * - Expose getRendererViewId(pid) for Assembly container hierarchy + */ +export class RendererProcessCollection { + private readonly rendererViews = new Map(); + /** Map webContents.id → OS pid, so we can look up the view after process death. */ + private readonly webContentsIdToPid = new Map(); + private readonly metricsIntervalId: ReturnType; + private readonly webContentsCreatedListener: (event: Electron.Event, wc: WebContents) => void; + private readonly renderProcessGoneListener: ( + event: Electron.Event, + webContents: WebContents, + details: Electron.RenderProcessGoneDetails + ) => void; + + constructor(private readonly eventManager: EventManager) { + this.webContentsCreatedListener = (_event, wc) => { + this.onWebContentsCreated(wc); + }; + app.on('web-contents-created', this.webContentsCreatedListener); + + this.renderProcessGoneListener = (_event, wc, details) => { + this.onRenderProcessGone(wc, details); + }; + app.on('render-process-gone', this.renderProcessGoneListener); + + // Detect renderers that were created before the SDK initialized + for (const wc of webContentsModule.getAllWebContents()) { + this.trackWebContents(wc); + } + + // Poll only for metrics updates, not for detection + this.metricsIntervalId = setInterval(() => this.pollMetrics(), METRICS_POLL_INTERVAL); + } + + stop(): void { + app.off('web-contents-created', this.webContentsCreatedListener); + app.off('render-process-gone', this.renderProcessGoneListener); + clearInterval(this.metricsIntervalId); + this.rendererViews.clear(); + } + + /** Get the view ID for a renderer process by its OS pid. Used by Assembly for container hierarchy. */ + getRendererViewId(pid: number): string | undefined { + return this.rendererViews.get(pid)?.viewId; + } + + private onWebContentsCreated(wc: WebContents): void { + // Try to track as early as possible — did-start-navigation fires before did-finish-load + wc.once('did-start-navigation', () => { + if (!this.trackWebContents(wc)) { + // pid not available yet — fall back to did-finish-load + wc.once('did-finish-load', () => { + this.trackWebContents(wc); + }); + } + }); + + // Page title is available only after the HTML is loaded — update the view name + wc.once('page-title-updated', () => { + this.trackWebContents(wc); + }); + } + + /** + * Try to create a view for the given webContents. + * Returns true if the renderer was successfully tracked (or already tracked). + */ + private trackWebContents(wc: WebContents): boolean { + if (wc.isDestroyed()) return false; + + const pid = wc.getOSProcessId(); + if (pid === 0) return false; + + this.webContentsIdToPid.set(wc.id, pid); + + const existingView = this.rendererViews.get(pid); + if (existingView) { + // Update title if the view was lazily created with a fallback name + const title = wc.getTitle(); + if (title && existingView.title !== title) { + existingView.title = title; + existingView.documentVersion++; + this.emitViewUpdate(existingView); + } + } else { + this.createRendererView(pid, wc.getTitle() || 'unknown'); + } + return true; + } + + /** + * Get or create a renderer view for the given pid. + * Called by Assembly to guarantee the view exists before the first bridge event. + */ + getOrCreateRendererViewId(pid: number, eventDate?: number): string { + let view = this.rendererViews.get(pid); + if (!view) { + view = this.createRendererView(pid, 'unknown', eventDate); + } else if (eventDate && eventDate < view.startTime) { + // Backdate if this event predates the current startTime + view.startTime = eventDate as TimeStamp; + view.documentVersion++; + this.emitViewUpdate(view); + } + return view.viewId; + } + + private createRendererView(pid: number, title: string, startDate?: number): RendererView { + const view: RendererView = { + viewId: generateUUID(), + startTime: (startDate ?? timeStampNow()) as TimeStamp, + documentVersion: 1, + isActive: true, + counters: { action: { count: 0 }, error: { count: 0 }, resource: { count: 0 } }, + pid, + title, + memorySamples: [], + }; + this.rendererViews.set(pid, view); + this.emitViewUpdate(view); + return view; + } + + private pollMetrics(): void { + const metrics = app.getAppMetrics(); + const allWebContents = webContentsModule.getAllWebContents(); + const activePids = new Set(); + + for (const wc of allWebContents) { + if (!wc.isDestroyed()) { + const pid = wc.getOSProcessId(); + if (pid !== 0) activePids.add(pid); + } + } + + for (const [pid, view] of this.rendererViews) { + if (!view.isActive) continue; + + // Mark views for pids that are no longer active + if (!activePids.has(pid)) { + view.isActive = false; + view.documentVersion++; + this.emitViewUpdate(view); + continue; + } + + // Update memory metrics + const metric = metrics.find((m) => m.pid === pid); + if (metric) { + view.memorySamples.push(metric.memory.workingSetSize * 1024); + view.documentVersion++; + this.emitViewUpdate(view); + } + } + } + + private onRenderProcessGone(wc: WebContents, details: Electron.RenderProcessGoneDetails): void { + // After crash, getOSProcessId() may return 0 — use our saved mapping + const pid = this.webContentsIdToPid.get(wc.id) ?? wc.getOSProcessId(); + const view = this.rendererViews.get(pid); + if (!view) return; + + view.isActive = false; + view.documentVersion++; + view.counters.error.count++; + + const isCrash = details.reason === 'crashed' || details.reason === 'oom'; + this.emitError(view, `Renderer process (pid ${pid}) gone: ${details.reason}`, { + reason: details.reason, + exit_code: details.exitCode, + is_crash: isCrash, + }); + + this.emitViewUpdate(view); + } + + private emitViewUpdate(view: RendererView): void { + const viewEvent: RawRumView = { + type: 'view', + date: view.startTime, + view: { + id: view.viewId, + name: `Renderer: ${view.title}`, + time_spent: toServerDuration(elapsed(view.startTime, timeStampNow())), + is_active: view.isActive, + ...view.counters, + ...(view.memorySamples.length > 0 + ? { + memory_average: Math.round(view.memorySamples.reduce((a, b) => a + b, 0) / view.memorySamples.length), + memory_max: Math.max(...view.memorySamples), + } + : {}), + }, + _dd: { document_version: view.documentVersion }, + context: { pid: view.pid }, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data: viewEvent, + startTime: view.startTime, + }); + } + + private emitError(view: RendererView, message: string, context: Record): void { + const errorEvent: RawRumError = { + type: 'error', + date: timeStampNow(), + view: { id: view.viewId }, + context, + error: { + id: generateUUID(), + message, + source: 'source', + handling: 'unhandled', + is_crash: context.is_crash ? true : undefined, + }, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data: errorEvent, + startTime: view.startTime, + }); + } +} diff --git a/src/domain/rum/rendererProcess/index.ts b/src/domain/rum/rendererProcess/index.ts new file mode 100644 index 00000000..6b0252e0 --- /dev/null +++ b/src/domain/rum/rendererProcess/index.ts @@ -0,0 +1 @@ +export { RendererProcessCollection } from './RendererProcessCollection'; diff --git a/src/domain/rum/utilityProcess/UtilityProcessCollection.ts b/src/domain/rum/utilityProcess/UtilityProcessCollection.ts new file mode 100644 index 00000000..4898fadc --- /dev/null +++ b/src/domain/rum/utilityProcess/UtilityProcessCollection.ts @@ -0,0 +1,266 @@ +import { app, utilityProcess, type UtilityProcess } from 'electron'; +import { + elapsed, + generateUUID, + ONE_SECOND, + type TimeStamp, + timeStampNow, + toServerDuration, +} from '@datadog/browser-core'; +import { EventFormat, EventKind, EventManager, EventSource } from '../../../event'; +import type { RawRumAction, RawRumError, RawRumView } from '../rawRumData.types'; + +export const METRICS_POLL_INTERVAL = 2 * ONE_SECOND; + +interface ProcessView { + viewId: string; + startTime: TimeStamp; + documentVersion: number; + isActive: boolean; + counters: { action: { count: number }; error: { count: number }; resource: { count: number } }; + serviceName: string; + pid?: number; + memorySamples: number[]; +} + +/** + * Track Electron utility processes as RUM views. + * + * - On utilityProcess.fork(): start a new view ("Utility: {serviceName}") + * - On spawn: update view with pid, transfer a dedicated MessagePort for error forwarding + * - On exit (code=0): end view + emit action (clean exit) + * - On exit (code≠0): end view + emit error (abnormal exit) + * - On app 'child-process-gone': enrich error with crash details + * + * For error capture inside utility processes, the customer must import + * '@datadog/electron-sdk/utility' at the top of their utility process entry file. + * That module listens for the MessagePort transfer and registers error handlers. + */ +export class UtilityProcessCollection { + private readonly processViews = new Map(); + private readonly originalFork = utilityProcess.fork.bind(utilityProcess); + private readonly childProcessGoneListener: (event: Electron.Event, details: Electron.Details) => void; + private readonly metricsIntervalId: ReturnType; + + constructor(private readonly eventManager: EventManager) { + this.patchFork(); + + this.childProcessGoneListener = (_event, details) => { + this.onChildProcessGone(details); + }; + app.on('child-process-gone', this.childProcessGoneListener); + + this.metricsIntervalId = setInterval(() => this.pollMetrics(), METRICS_POLL_INTERVAL); + } + + stop(): void { + Object.defineProperty(utilityProcess, 'fork', { + value: this.originalFork, + writable: true, + configurable: true, + }); + app.off('child-process-gone', this.childProcessGoneListener); + clearInterval(this.metricsIntervalId); + this.processViews.clear(); + } + + private patchFork(): void { + const emitViewUpdate = this.emitViewUpdate.bind(this); + const emitAction = this.emitAction.bind(this); + const emitError = this.emitError.bind(this); + const processViews = this.processViews; + const originalFork = this.originalFork; + + Object.defineProperty(utilityProcess, 'fork', { + value: function patchedFork(modulePath: string, args?: string[], options?: Electron.ForkOptions): UtilityProcess { + const child = originalFork(modulePath, args, options); + + const serviceName = options?.serviceName ?? 'Node Utility Process'; + const view: ProcessView = { + viewId: generateUUID(), + startTime: timeStampNow(), + documentVersion: 1, + isActive: true, + counters: { action: { count: 0 }, error: { count: 0 }, resource: { count: 0 } }, + serviceName, + memorySamples: [], + }; + processViews.set(child, view); + + // Emit initial view + emitViewUpdate(view); + + // Intercept child.emit('message') to capture SDK-internal messages (__dd) + // sent by the utility process via process.parentPort.postMessage(). + // This approach: + // - Does NOT override child.on/child.once (which breaks Electron internals) + // - Does NOT register parentPort.on('message') in the utility process (which drains the buffer) + // - Does NOT transfer MessagePorts on spawn (which blocks all messages) + // Instead, SDK messages are swallowed at emit time before reaching customer handlers. + const originalEmit = child.emit.bind(child); + child.emit = function (event: string, ...args: unknown[]): boolean { + if (event === 'message') { + const msg = args[0]; + if (msg && typeof msg === 'object' && (msg as Record).__dd) { + const data = msg as { __dd: true; type?: string; message?: string; stack?: string }; + if (data.type === 'error' && data.message) { + view.counters.error.count++; + view.documentVersion++; + emitError(view, data.message, { stack: data.stack }); + emitViewUpdate(view); + } + return true; // Swallow — don't propagate to customer handlers + } + } + return originalEmit(event, ...args); + } as typeof child.emit; + + child.once('spawn', () => { + view.pid = child.pid ?? undefined; + view.documentVersion++; + emitViewUpdate(view); + }); + + child.once('exit', (code: number) => { + view.isActive = false; + view.documentVersion++; + + if (code === 0) { + // Clean exit → action + view.counters.action.count++; + emitAction(view, 'process_exit'); + } else { + // Abnormal exit → error + view.counters.error.count++; + emitError(view, `Utility process "${serviceName}" exited with code ${code}`, { + exit_code: code, + }); + } + + emitViewUpdate(view); + + // Keep in map briefly for child-process-gone enrichment, then clean up + setTimeout(() => processViews.delete(child), 5000); + }); + + return child; + }, + writable: true, + configurable: true, + }); + } + + private pollMetrics(): void { + const metrics = app.getAppMetrics(); + for (const [, view] of this.processViews) { + if (!view.isActive || view.pid === undefined) continue; + + const metric = metrics.find((m) => m.pid === view.pid); + if (metric) { + // workingSetSize is in KB, store as bytes + view.memorySamples.push(metric.memory.workingSetSize * 1024); + view.documentVersion++; + this.emitViewUpdate(view); + } + } + } + + private onChildProcessGone(details: Electron.Details): void { + if (details.type !== 'Utility') return; + + // Find matching process view by serviceName + for (const [, view] of this.processViews) { + if (view.serviceName === details.serviceName && view.isActive) { + view.isActive = false; + view.documentVersion++; + view.counters.error.count++; + + const isCrash = details.reason === 'crashed' || details.reason === 'oom'; + this.emitError(view, `Utility process "${view.serviceName}" gone: ${details.reason}`, { + reason: details.reason, + exit_code: details.exitCode, + is_crash: isCrash, + }); + + this.emitViewUpdate(view); + break; + } + } + } + + private emitViewUpdate(view: ProcessView): void { + const viewEvent: RawRumView = { + type: 'view', + date: view.startTime, + view: { + id: view.viewId, + name: `Utility: ${view.serviceName}`, + time_spent: toServerDuration(elapsed(view.startTime, timeStampNow())), + is_active: view.isActive, + ...view.counters, + ...(view.memorySamples.length > 0 + ? { + memory_average: Math.round(view.memorySamples.reduce((a, b) => a + b, 0) / view.memorySamples.length), + memory_max: Math.max(...view.memorySamples), + } + : {}), + }, + _dd: { document_version: view.documentVersion }, + ...(view.pid !== undefined ? { context: { pid: view.pid } } : {}), + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data: viewEvent, + startTime: view.startTime, + }); + } + + private emitAction(view: ProcessView, targetName: string): void { + const actionEvent: RawRumAction = { + type: 'action', + date: timeStampNow(), + view: { id: view.viewId, name: `Utility: ${view.serviceName}` }, + action: { + id: generateUUID(), + type: 'custom', + target: { name: targetName }, + }, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data: actionEvent, + startTime: view.startTime, + }); + } + + private emitError(view: ProcessView, message: string, context: Record): void { + const errorEvent: RawRumError = { + type: 'error', + date: timeStampNow(), + view: { id: view.viewId, name: `Utility: ${view.serviceName}` }, + context, + error: { + id: generateUUID(), + message, + stack: typeof context.stack === 'string' ? context.stack : undefined, + source: 'source', + handling: 'unhandled', + is_crash: context.is_crash ? true : undefined, + }, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data: errorEvent, + startTime: view.startTime, + }); + } +} diff --git a/src/domain/rum/utilityProcess/index.ts b/src/domain/rum/utilityProcess/index.ts new file mode 100644 index 00000000..b0e76d8e --- /dev/null +++ b/src/domain/rum/utilityProcess/index.ts @@ -0,0 +1 @@ +export { UtilityProcessCollection } from './UtilityProcessCollection'; diff --git a/src/domain/rum/view/ViewCollection.spec.ts b/src/domain/rum/view/ViewCollection.spec.ts index 7d3bf6ab..e3031e71 100644 --- a/src/domain/rum/view/ViewCollection.spec.ts +++ b/src/domain/rum/view/ViewCollection.spec.ts @@ -179,6 +179,10 @@ describe('ViewCollection', () => { }); describe('event counters', () => { + function currentViewId() { + return (rawRumEvents[rawRumEvents.length - 1].data as RawRumView).view.id; + } + it.each(['action', 'error', 'resource'] as const)( 'increments %s counter on corresponding ServerRumEvent', (type) => { @@ -186,7 +190,7 @@ describe('ViewCollection', () => { kind: EventKind.SERVER, track: EventTrack.RUM, source: EventSource.MAIN, - data: createServerRumEvent(type), + data: createServerRumEvent(type, { view: { id: currentViewId() } }), }); expect(rawRumEvents).toHaveLength(2); @@ -201,7 +205,7 @@ describe('ViewCollection', () => { kind: EventKind.SERVER, track: EventTrack.RUM, source: EventSource.MAIN, - data: createServerRumView(), + data: createServerRumView({ view: { id: currentViewId() } }), }); // Only the initial event, no update @@ -213,7 +217,19 @@ describe('ViewCollection', () => { kind: EventKind.SERVER, track: EventTrack.RUM, source: EventSource.RENDERER, - data: createServerRumEvent('error'), + data: createServerRumEvent('error', { view: { id: currentViewId() } }), + }); + + // Only the initial event, no update + expect(rawRumEvents).toHaveLength(1); + }); + + it('does not count events from other views', () => { + eventManager.notify({ + kind: EventKind.SERVER, + track: EventTrack.RUM, + source: EventSource.MAIN, + data: createServerRumEvent('action', { view: { id: 'other-view-id' } }), }); // Only the initial event, no update @@ -235,11 +251,12 @@ describe('ViewCollection', () => { describe('throttled view updates', () => { function notifyServerRumEvent(type: 'action' | 'error' | 'resource') { + const viewId = (rawRumEvents[rawRumEvents.length - 1].data as RawRumView).view.id; eventManager.notify({ kind: EventKind.SERVER, track: EventTrack.RUM, source: EventSource.MAIN, - data: createServerRumEvent(type), + data: createServerRumEvent(type, { view: { id: viewId } }), }); } diff --git a/src/domain/rum/view/ViewCollection.ts b/src/domain/rum/view/ViewCollection.ts index b70b60fa..e0560a31 100644 --- a/src/domain/rum/view/ViewCollection.ts +++ b/src/domain/rum/view/ViewCollection.ts @@ -114,6 +114,7 @@ export class ViewCollection { private emitViewUpdate(): void { const viewEvent: RawRumView = { type: 'view', + date: this.currentView.startTime, view: { id: this.currentView.id, time_spent: toServerDuration(elapsed(this.currentView.startTime, timeStampNow())), @@ -153,6 +154,10 @@ export class ViewCollection { const type = event.data.type; if (type === 'action' || type === 'error' || type === 'resource') { + // Only count events belonging to the current main process view + if (event.data.view.id !== this.currentView.id) { + return; + } this.currentView.counters[type].count++; this.currentView.documentVersion++; this.scheduleViewUpdate(); diff --git a/src/entries/utilityPreload.ts b/src/entries/utilityPreload.ts new file mode 100644 index 00000000..d762fb77 --- /dev/null +++ b/src/entries/utilityPreload.ts @@ -0,0 +1,3 @@ +import { setupUtilityPreload } from '../preload'; + +setupUtilityPreload(); diff --git a/src/event/event.types.ts b/src/event/event.types.ts index a7e09532..c402a432 100644 --- a/src/event/event.types.ts +++ b/src/event/event.types.ts @@ -11,6 +11,8 @@ export interface RawRumEvent { format: typeof EventFormat.RUM; data: RawRumData; startTime?: TimeStamp; + /** OS pid of the renderer that sent this event (only for RENDERER source). */ + senderPid?: number; } export interface RawTelemetryEvent { diff --git a/src/index.ts b/src/index.ts index 4be65050..ca8f7b49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Assembly, createFormatHooks, registerCommonContext } from './assembly'; import type { InitConfiguration } from './config'; import { buildConfiguration } from './config'; import { RumCollection } from './domain/rum'; +import { RendererProcessCollection } from './domain/rum/rendererProcess'; import { SessionManager } from './domain/session'; import { UserActivityTracker } from './domain/UserActivityTracker'; import type { ErrorOptions } from './domain/rum'; @@ -32,13 +33,16 @@ export async function init(configuration: InitConfiguration): Promise { startTelemetry(eventManager, config); sessionManager = await SessionManager.start(eventManager, hooks); - new Assembly(eventManager, hooks); + const rendererProcessCollection = new RendererProcessCollection(eventManager); + new Assembly(eventManager, hooks, { + getRendererContainerViewId: (pid, eventDate) => rendererProcessCollection.getOrCreateRendererViewId(pid, eventDate), + }); new BridgeHandler(eventManager, config); new UserActivityTracker(eventManager); registerPreload(); transport = await Transport.create(config, eventManager); - const rum = await RumCollection.start(eventManager, hooks); + const rum = await RumCollection.start(eventManager, hooks, rendererProcessCollection); rumApi = rum.getApi(); return true; @@ -85,7 +89,7 @@ export function _generateTelemetryError() { } export type { InitConfiguration } from './config'; -export type { RumErrorEvent, RumViewEvent } from './domain/rum'; +export type { RumActionEvent, RumErrorEvent, RumResourceEvent, RumViewEvent } from './domain/rum'; export type { TelemetryErrorEvent } from './domain/telemetry'; export { SESSION_TIME_OUT_DELAY } from './domain/session'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 19d96d13..63e04723 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1 +1,2 @@ export { setupRendererBridge } from './bridge'; +export { setupUtilityPreload } from './utilityPreload'; diff --git a/src/preload/utilityPreload.ts b/src/preload/utilityPreload.ts new file mode 100644 index 00000000..df635d45 --- /dev/null +++ b/src/preload/utilityPreload.ts @@ -0,0 +1,45 @@ +/** + * Utility process SDK module. + * + * Imported by the customer at the top of their utility process entry file: + * require('@datadog/electron-sdk/utility') + * + * Registers error listeners and forwards uncaught exceptions/rejections + * to the main process via process.parentPort.postMessage(). + * + * This module does NOT register any parentPort.on('message') listener. + * Electron buffers parentPort messages until the first listener is registered, + * so adding a listener here would drain the buffer before the customer's + * handlers are set up — breaking apps like VS Code that expect to receive + * their first message in their own handler. + * + * Instead, errors are sent as { __dd: true, type: 'error', ... } messages + * via parentPort.postMessage(). The main process intercepts these at the + * child.emit('message') level, swallowing them before customer handlers see them. + */ + +export function setupUtilityPreload(): void { + if (!process.parentPort) return; + + const parentPort = process.parentPort; + + function sendError(error: unknown): void { + const err = error instanceof Error ? error : new Error(String(error)); + parentPort.postMessage({ + __dd: true, + type: 'error', + message: err.message, + stack: err.stack, + }); + } + + process.on('uncaughtException', (error: Error) => { + sendError(error); + process.removeAllListeners('uncaughtException'); + throw error; + }); + + process.on('unhandledRejection', (reason: unknown) => { + sendError(reason); + }); +} diff --git a/src/transport/batch/BatchProducer.ts b/src/transport/batch/BatchProducer.ts index 5d37f84a..c9490f64 100644 --- a/src/transport/batch/BatchProducer.ts +++ b/src/transport/batch/BatchProducer.ts @@ -1,3 +1,4 @@ +import { app } from 'electron'; import { dateNow } from '@datadog/browser-core'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -95,7 +96,7 @@ export class BatchProducer { private async writeData(data: unknown) { await this.ensureTrackDirectoryExists(); - const serialized = `${JSON.stringify(data)}\n`; + const serialized = `${sanitizeAppPaths(JSON.stringify(data))}\n`; const dataSize = Buffer.byteLength(serialized, 'utf8'); if (this.currentBatchSize + dataSize > this.batchSize && this.currentBatchSize > 0) { @@ -107,3 +108,19 @@ export class BatchProducer { this.currentBatchSize += dataSize; } } + +let appPathPattern: RegExp | undefined; + +function sanitizeAppPaths(json: string): string { + if (!appPathPattern) { + try { + // Escape special regex chars in the path, match both raw and JSON-escaped separators + const appPath = app.getAppPath().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = appPath.split('/').join('[\\\\/]'); + appPathPattern = new RegExp(pattern, 'g'); + } catch { + return json; + } + } + return json.replace(appPathPattern, ''); +} diff --git a/tsconfig.json b/tsconfig.json index ff332d50..42555e37 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "./e2e/app" }, { "path": "./e2e/app/tsconfig.renderer.json" }, { "path": "./playground" }, - { "path": "./playground/tsconfig.renderer.json" } + { "path": "./playground/tsconfig.renderer.json" }, + { "path": "./playground/test/tsconfig.json" } ] }