From 10244b5bd00ad9a7a043b7acd6d7b82ec99778d7 Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Tue, 21 Jul 2026 11:21:47 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20add=20beforeSend=20event=20filt?= =?UTF-8?q?ering=20and=20scrubbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +++ docs/ARCHITECTURE.md | 7 +- e2e/app/src/global.d.ts | 2 +- e2e/app/src/main.ts | 24 +- e2e/app/src/preload.ts | 3 +- e2e/lib/helpers.ts | 9 +- e2e/lib/mainPage.ts | 9 +- e2e/scenarios/before-send.scenario.ts | 28 +++ playground/src/index.html | 6 + playground/src/main.ts | 17 ++ playground/src/preload.ts | 1 + playground/src/renderer.ts | 6 + playground/test/before-send.scenario.ts | 16 ++ src/assembly/MainAssembly.spec.ts | 33 ++- src/assembly/MainAssembly.ts | 13 +- src/assembly/RendererPipeline.spec.ts | 70 +++++- src/assembly/RendererPipeline.ts | 12 +- src/assembly/RumEventMapper.spec.ts | 280 ++++++++++++++++++++++++ src/assembly/RumEventMapper.ts | 160 ++++++++++++++ src/assembly/index.ts | 1 + src/config.spec.ts | 22 ++ src/config.ts | 41 ++++ src/index.ts | 10 +- 23 files changed, 774 insertions(+), 25 deletions(-) create mode 100644 e2e/scenarios/before-send.scenario.ts create mode 100644 playground/test/before-send.scenario.ts create mode 100644 src/assembly/RumEventMapper.spec.ts create mode 100644 src/assembly/RumEventMapper.ts diff --git a/README.md b/README.md index a9480af9..6eb535dc 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,34 @@ await esbuild.build({ - **Renderer Profiling** — Collect JS Self-Profiling data from renderer pages and correlate it with RUM - **User & Account Info** — Attach user and account identity to all RUM events and traces - **Operation Monitoring** _(experimental)_ — Track start / succeed / fail steps of critical user-facing workflows +- **Event Filtering and Scrubbing** — Modify or discard RUM events with `beforeSend` + +### Event Filtering and Scrubbing + +Use `beforeSend` to inspect fully assembled RUM events from both the main and renderer processes before they are sent to Datadog: + +```ts +await init({ + // ... + beforeSend: (event) => { + if (event.type === 'error') { + event.error.message = event.error.message.replace(/token=[^&\s]+/g, 'token=[REDACTED]'); + } + if (event.context?.internal === true) { + return false; + } + return true; + }, +}); +``` + +Returning `false` discards the event. View and native crash events cannot be discarded. Editable fields follow the [Browser SDK `beforeSend` allowlist](https://docs.datadoghq.com/real_user_monitoring/guide/enrich-and-control-rum-data/); event identity, session, application, and other protected fields remain unchanged. Callback errors are logged and the event is still sent. + +The callback is synchronous and should remain fast. It does not automatically detect PII. Unlike Browser SDK `beforeSend`, it receives no raw DOM, XHR, or original error context because those renderer objects cannot cross the process boundary. + +Renderer events have already passed through any `beforeSend` configured in the renderer's Browser SDK. The Electron callback runs afterward, once main-process context is added. It can run multiple times for the same view as its metrics change; internal `view_update` payloads are not exposed. + +Renderer view counters are computed by the Browser SDK before events reach the main process, so filtering a renderer event here does not retroactively adjust those counters. ### Custom Duration Vitals @@ -390,3 +418,4 @@ interface FeatureOperationOptions { | `uploadFrequency` | `'RARE' \| 'NORMAL' \| 'FREQUENT'` | No | — | Upload frequency for event batches | | `defaultPrivacyLevel` | `'mask' \| 'allow' \| 'mask-user-input'` | No | `'mask'` | Default privacy level for renderer session replay | | `allowedWebViewHosts` | `string[]` | No | `[]` | Hostnames allowed for the renderer bridge | +| `beforeSend` | `(event: RumEvent) => boolean` | No | — | Modify or discard fully assembled RUM events before they are sent | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4032c8d2..c0ab99bf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -66,6 +66,7 @@ flowchart LR subgraph Assembly HOOKS{Format Hooks} COMBINE[combine] + MAPPER[beforeSend mapper] end subgraph "Hook Providers" @@ -86,7 +87,9 @@ flowchart LR SC -. "session.id" .-> HOOKS VC -. "view.id, view.name, ..." .-> HOOKS HOOKS --> COMBINE - COMBINE -- ServerEvent --> BM + COMBINE -- RUM event --> MAPPER + MAPPER -- ServerEvent --> BM + COMBINE -- Other ServerEvent --> BM BM --> BP BM --> BC BP -. "write" .-> DISK[Disk] @@ -117,6 +120,8 @@ Two handlers transform events into `ServerEvent`s: - **`MainAssembly`**: handles main-process `RawEvent`s (excluding profile events), enriches them via `triggerRum` / `triggerTelemetry` hooks, and emits `ServerEvent`s with `source: MAIN`. - **`RendererPipeline`**: owns the renderer IPC channel, receives pre-assembled RUM events from the Browser SDK, enriches them via `triggerRum` with `source: EventSource.RENDERER`, and emits `ServerRumEvent`s with `source: RENDERER` directly, bypassing the `RawEvent` pipeline entirely. +Both paths apply the shared `RumEventMapper` after enrichment and before emitting the final `ServerRumEvent`. This lets `beforeSend` inspect the complete event and ensures filtered events never reach transport or main-view counters. Telemetry, profiles, and spans are not mapped; internal renderer `view_update` events are not exposed to the callback. + #### Format Hooks `createFormatHooks()` creates per-format hook pairs (`registerRum`/`triggerRum`, `registerTelemetry`/`triggerTelemetry`, `registerSpan`/`triggerSpan`). Each hook callback receives a `source: EventSource` param (MAIN or RENDERER) and can return: diff --git a/e2e/app/src/global.d.ts b/e2e/app/src/global.d.ts index 8a841b9c..b9f463df 100644 --- a/e2e/app/src/global.d.ts +++ b/e2e/app/src/global.d.ts @@ -10,7 +10,7 @@ declare global { stopSession: () => Promise; generateUncaughtException: () => Promise; generateUnhandledRejection: () => Promise; - generateManualError: (startTime?: number) => Promise; + generateManualError: (startTime?: number, context?: Record) => Promise; crash: () => Promise; ping: () => Promise; openBridgeFileWindow: () => Promise; diff --git a/e2e/app/src/main.ts b/e2e/app/src/main.ts index bbe705ec..b28edd74 100644 --- a/e2e/app/src/main.ts +++ b/e2e/app/src/main.ts @@ -147,8 +147,8 @@ void app.whenReady().then(async () => { void Promise.reject(new Error('test unhandled rejection')); }); - ipcMain.handle('generateManualError', (_event, startTime?: number) => { - addError(new Error('test manual error'), { context: { foo: 'bar' }, startTime }); + ipcMain.handle('generateManualError', (_event, startTime?: number, context?: Record) => { + addError(new Error('test manual error'), { context: context ?? { foo: 'bar' }, startTime }); }); ipcMain.handle('addDurationVital', (_event, name: string, options: AddDurationVitalOptions) => { @@ -348,7 +348,25 @@ function createWindow() { function getConfiguration(): InitConfiguration { if (process.env.DD_ELECTRON_SDK_CONFIG) { - return JSON.parse(process.env.DD_ELECTRON_SDK_CONFIG) as InitConfiguration; + const config = JSON.parse(process.env.DD_ELECTRON_SDK_CONFIG) as InitConfiguration; + if (process.env.DD_E2E_BEFORE_SEND_MODE === 'scrub-and-filter') { + config.beforeSend = (event) => { + if (event.type !== 'error') { + return true; + } + if (event.context?.beforeSend === 'drop' || event.error.message === 'beforeSend renderer drop') { + return false; + } + if (event.context?.beforeSend === 'scrub') { + event.error.message = 'redacted main error'; + event.context = { ...event.context, beforeSend: undefined, secret: '[REDACTED]' }; + } else if (event.error.message === 'beforeSend renderer secret') { + event.error.message = 'redacted renderer error'; + } + return true; + }; + } + return config; } throw new Error('DD_ELECTRON_SDK_CONFIG environment variable is not set'); } diff --git a/e2e/app/src/preload.ts b/e2e/app/src/preload.ts index 29305631..792ce25f 100644 --- a/e2e/app/src/preload.ts +++ b/e2e/app/src/preload.ts @@ -10,7 +10,8 @@ contextBridge.exposeInMainWorld('electronAPI', { stopSession: () => ipcRenderer.invoke('stopSession'), generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'), generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'), - generateManualError: (startTime?: number) => ipcRenderer.invoke('generateManualError', startTime), + generateManualError: (startTime?: number, context?: Record) => + ipcRenderer.invoke('generateManualError', startTime, context), addDurationVital: (name: string, options: Record) => ipcRenderer.invoke('addDurationVital', name, options), startDurationVital: (name: string, options?: Record) => diff --git a/e2e/lib/helpers.ts b/e2e/lib/helpers.ts index 76832167..97cfaefe 100644 --- a/e2e/lib/helpers.ts +++ b/e2e/lib/helpers.ts @@ -40,6 +40,7 @@ export interface TestFixtures { rumBrowserSdk: Record | null; initialIntakeQuotaDecision: 'quota_ok' | 'quota_ko'; sdkConfigOverrides: Partial | null; + beforeSendMode: 'scrub-and-filter' | null; } /** @@ -71,9 +72,11 @@ export const test = base.extend({ { option: true }, ], - electronApp: async ({ intake, rumBrowserSdk, sdkConfigOverrides }, use) => { + electronApp: async ({ intake, rumBrowserSdk, sdkConfigOverrides, beforeSendMode }, use) => { const userDataDir = await createUserDataDir(); - const electronApp = await launchApp(intake, userDataDir, rumBrowserSdk, sdkConfigOverrides); + const electronApp = await launchApp(intake, userDataDir, rumBrowserSdk, sdkConfigOverrides, { + ...(beforeSendMode && { DD_E2E_BEFORE_SEND_MODE: beforeSendMode }), + }); await use(electronApp); await electronApp.close(); await cleanupUserDataDir(userDataDir); @@ -94,6 +97,8 @@ export const test = base.extend({ rumBrowserSdk: [null, { option: true }], sdkConfigOverrides: [null, { option: true }], + + beforeSendMode: [null, { option: true }], }); async function launchApp( diff --git a/e2e/lib/mainPage.ts b/e2e/lib/mainPage.ts index 00a66f76..e69a8b8f 100644 --- a/e2e/lib/mainPage.ts +++ b/e2e/lib/mainPage.ts @@ -13,7 +13,7 @@ import { BridgeWindowPage } from './bridgeWindowPage'; interface ElectronAppWindow { electronAPI: { generateTelemetryErrors: (count: number) => Promise; - generateManualError: (startTime?: number) => Promise; + generateManualError: (startTime?: number, context?: Record) => Promise; addDurationVital: (name: string, options: AddDurationVitalOptions) => Promise; startDurationVital: (name: string, options?: DurationVitalOptions) => Promise; stopDurationVital: (name: string, options?: DurationVitalOptions) => Promise; @@ -86,10 +86,11 @@ export class MainPage { await this.page.locator('#generate-unhandled-rejection').click(); } - async generateManualError(startTime?: number) { + async generateManualError(startTime?: number, context?: Record) { await this.page.evaluate( - (ts) => (globalThis as unknown as ElectronAppWindow).electronAPI.generateManualError(ts), - startTime + ({ startTime, context }) => + (globalThis as unknown as ElectronAppWindow).electronAPI.generateManualError(startTime, context), + { startTime, context } ); } diff --git a/e2e/scenarios/before-send.scenario.ts b/e2e/scenarios/before-send.scenario.ts new file mode 100644 index 00000000..4a858682 --- /dev/null +++ b/e2e/scenarios/before-send.scenario.ts @@ -0,0 +1,28 @@ +import type { RumErrorEvent } from '@datadog/electron-sdk'; +import { expect, test } from '../lib/helpers'; + +test.use({ beforeSendMode: 'scrub-and-filter' }); + +test('beforeSend scrubs and filters main and renderer RUM events', async ({ electronApp, intake, mainPage }) => { + await mainPage.flushTransport(); + intake.clear(); + + await mainPage.generateManualError(undefined, { beforeSend: 'scrub', secret: 'main secret' }); + await mainPage.generateManualError(undefined, { beforeSend: 'drop' }); + + const bridgeWindow = await mainPage.openBridgeFileWindow(electronApp); + await bridgeWindow.generateError('beforeSend renderer secret'); + await bridgeWindow.generateError('beforeSend renderer drop'); + await mainPage.flushTransport(); + + const errorEvents = await intake.waitForEventCount('error', 2); + const errors = errorEvents.map(({ body }) => body as RumErrorEvent); + + expect(errors).toHaveLength(2); + expect(errors.map(({ error }) => error.message)).toEqual( + expect.arrayContaining(['redacted main error', 'redacted renderer error']) + ); + expect(errors.find(({ error }) => error.message === 'redacted main error')?.context).toEqual({ + secret: '[REDACTED]', + }); +}); diff --git a/playground/src/index.html b/playground/src/index.html index 3f0b0d33..4bce6138 100644 --- a/playground/src/index.html +++ b/playground/src/index.html @@ -227,6 +227,12 @@

Session ID:

+ +
+ + +
+

IPC Activity Log:

diff --git a/playground/src/main.ts b/playground/src/main.ts index 57e2ed4c..767539d9 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs'; import * as https from 'node:https'; import { init, + addError, stopSession, _flushTransport, getInternalContext, @@ -117,6 +118,12 @@ ipcMain.handle('generateUncaughtException', () => { ipcMain.handle('generateUnhandledRejection', () => { void Promise.reject(new Error('test unhandled rejection')); }); + +ipcMain.handle('main:before-send-error', (_event, behavior: 'scrub' | 'filter') => { + addError(new Error('Sensitive error for beforeSend'), { + context: { beforeSend: behavior, email: 'customer@example.com' }, + }); +}); // --- IPC demo handlers (each one becomes a captured IPC resource) --- ipcMain.handle('main:fetch-api', async () => { @@ -240,6 +247,16 @@ void app.whenReady().then(async () => { service: 'electron-playground', env: 'dev', profilingSampleRate: 100, + beforeSend: (event) => { + if (event.context?.beforeSend === 'filter') { + return false; + } + if (event.type === 'error' && event.context?.beforeSend === 'scrub') { + event.error.message = '[REDACTED by beforeSend]'; + event.context = { email: '[REDACTED]' }; + } + return true; + }, ...(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 c9178d3f..74de0862 100644 --- a/playground/src/preload.ts +++ b/playground/src/preload.ts @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('electronAPI', { generateTelemetryError: () => ipcRenderer.invoke('generateTelemetryError'), generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'), generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'), + generateBeforeSendError: (behavior: 'scrub' | 'filter') => ipcRenderer.invoke('main:before-send-error', behavior), crash: () => ipcRenderer.invoke('crash'), mainFetchApi: () => ipcRenderer.invoke('main:fetch-api'), addDurationVital: ( diff --git a/playground/src/renderer.ts b/playground/src/renderer.ts index 356fa057..9718a594 100644 --- a/playground/src/renderer.ts +++ b/playground/src/renderer.ts @@ -34,6 +34,7 @@ interface ElectronAPI { generateTelemetryError: () => Promise; generateUncaughtException: () => Promise; generateUnhandledRejection: () => Promise; + generateBeforeSendError: (behavior: 'scrub' | 'filter') => Promise; crash: () => Promise; mainFetchApi: () => Promise; addDurationVital: (name: string, options: AddDurationVitalOptions) => Promise; @@ -284,6 +285,11 @@ setupDemoButton('vital-stop', 'main:stop-duration-vital(document.open)', () => }) ); +// --- beforeSend demo buttons --- + +setupDemoButton('before-send-scrub', 'beforeSend(scrub)', () => window.electronAPI.generateBeforeSendError('scrub')); +setupDemoButton('before-send-filter', 'beforeSend(filter)', () => window.electronAPI.generateBeforeSendError('filter')); + // --- Operation Monitoring demo buttons --- setupDemoButton('op-start', 'main:start-operation(checkout)', () => window.electronAPI.startOperation('checkout')); diff --git a/playground/test/before-send.scenario.ts b/playground/test/before-send.scenario.ts new file mode 100644 index 00000000..66adc93f --- /dev/null +++ b/playground/test/before-send.scenario.ts @@ -0,0 +1,16 @@ +import type { RumErrorEvent } from '@datadog/electron-sdk'; +import { expect, flushTransport, test } from './helpers'; + +test('playground demonstrates beforeSend scrubbing and filtering', async ({ intake, window }) => { + await window.click('#before-send-scrub'); + await window.click('#before-send-filter'); + await flushTransport(window); + + const errorEvents = await intake.getEventsByType('error'); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].body as RumErrorEvent).toMatchObject({ + error: { message: '[REDACTED by beforeSend]' }, + context: { email: '[REDACTED]' }, + }); +}); diff --git a/src/assembly/MainAssembly.spec.ts b/src/assembly/MainAssembly.spec.ts index 70648772..be166787 100644 --- a/src/assembly/MainAssembly.spec.ts +++ b/src/assembly/MainAssembly.spec.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, it, expect } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import { type TimeStamp } from '@datadog/js-core/time'; import { DISCARDED } from '@datadog/js-core/assembly'; import { MainAssembly } from './MainAssembly'; +import { RumEventMapper } from './RumEventMapper'; import { createFormatHooks, type FormatHooks } from './hooks'; import { EventFormat, @@ -30,6 +31,7 @@ const RAW_TELEMETRY_DATA: RawTelemetryData = { describe('MainAssembly', () => { let eventManager: EventManager; let hooks: FormatHooks; + let rumEventMapper: RumEventMapper; let serverEvents: ServerEvent[]; function notifyRawRumEvent(overrides?: Partial) { @@ -53,6 +55,7 @@ describe('MainAssembly', () => { beforeEach(() => { eventManager = new EventManager(); hooks = createFormatHooks(); + rumEventMapper = new RumEventMapper(); serverEvents = []; eventManager.registerHandler({ @@ -60,7 +63,7 @@ describe('MainAssembly', () => { handle: (event) => serverEvents.push(event), }); - new MainAssembly(eventManager, hooks); + new MainAssembly(eventManager, hooks, rumEventMapper); }); it('favors raw event attributes over hook attributes', () => { @@ -111,14 +114,40 @@ describe('MainAssembly', () => { expect((serverEvents[0] as ServerRumEvent).source).toBe(EventSource.MAIN); }); + it('maps fully assembled RUM events after hooks', () => { + hooks.registerRum(() => ({ session: { id: 'hook-session' } })); + vi.spyOn(rumEventMapper, 'map').mockImplementation((event) => { + expect(event.session.id).toBe('hook-session'); + if (event.type === 'error') { + event.error.message = 'mapped'; + } + return event; + }); + + notifyRawRumEvent(); + + expect(serverEvents[0].data).toMatchObject({ error: { message: 'mapped' } }); + }); + + it('does not emit RUM events discarded by the mapper', () => { + hooks.registerRum(() => ({})); + vi.spyOn(rumEventMapper, 'map').mockReturnValue(undefined); + + notifyRawRumEvent(); + + expect(serverEvents).toHaveLength(0); + }); + describe('TELEMETRY events', () => { it('emits ServerTelemetryEvent with source MAIN', () => { hooks.registerTelemetry(() => ({})); + const mapSpy = vi.spyOn(rumEventMapper, 'map'); notifyRawTelemetryEvent(); expect(serverEvents).toHaveLength(1); expect((serverEvents[0] as ServerTelemetryEvent).source).toBe(EventSource.MAIN); + expect(mapSpy).not.toHaveBeenCalled(); }); it('discards telemetry events when hook returns DISCARDED', () => { diff --git a/src/assembly/MainAssembly.ts b/src/assembly/MainAssembly.ts index e9a866a1..c38d18c6 100644 --- a/src/assembly/MainAssembly.ts +++ b/src/assembly/MainAssembly.ts @@ -14,18 +14,21 @@ import { import type { FormatHooks } from './hooks'; import { RumEvent } from '../domain/rum'; import { TelemetryEvent } from '../domain/telemetry'; +import { RumEventMapper } from './RumEventMapper'; // Raw events assembled through the standard main-process hook pipeline. type StandardRawEvent = Exclude; /** * Transforms main-process RawEvents into ServerEvents by enriching them with - * contextual attributes (session, application, view, etc.) via format hooks. + * contextual attributes (session, application, view, etc.) via format hooks, + * then applies beforeSend to fully assembled RUM events. */ export class MainAssembly { constructor( private eventManager: EventManager, - private hooks: FormatHooks + private hooks: FormatHooks, + private rumEventMapper = new RumEventMapper() ) { this.eventManager.registerHandler({ canHandle: (event): event is StandardRawEvent => @@ -50,11 +53,15 @@ export class MainAssembly { source, }); if (hookResult !== DISCARDED) { + const data = this.rumEventMapper.map(assembleData(event.data, hookResult)); + if (!data) { + return DISCARDED; + } return { kind: EventKind.SERVER, track: EventTrack.RUM, source: EventSource.MAIN, - data: assembleData(event.data, hookResult), + data, }; } } diff --git a/src/assembly/RendererPipeline.spec.ts b/src/assembly/RendererPipeline.spec.ts index 0b5e0aa6..7fd6c648 100644 --- a/src/assembly/RendererPipeline.spec.ts +++ b/src/assembly/RendererPipeline.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type TimeStamp } from '@datadog/js-core/time'; import { DISCARDED } from '@datadog/js-core/assembly'; import { RendererPipeline } from './RendererPipeline'; +import { RumEventMapper } from './RumEventMapper'; import type { BridgeOptions } from '../common'; import { createFormatHooks, type FormatHooks } from './hooks'; import { @@ -18,6 +19,7 @@ import { type ServerRumEvent, } from '../event'; import { BRIDGE_CHANNEL, CONFIG_CHANNEL } from '../common'; +import type { RumEvent } from '../domain/rum'; import { createTestConfiguration } from '../mocks.specUtil'; const { mockIpcMainOn, mockAddError, mockSetBridgeConfig } = vi.hoisted(() => { @@ -79,12 +81,14 @@ describe('RendererPipeline', () => { let eventManager: EventManager; let hooks: FormatHooks; let simulateIpcMessage: (msg: string) => void; + let rumEventMapper: RumEventMapper; let serverEvents: ServerRumEvent[]; beforeEach(() => { vi.clearAllMocks(); eventManager = new EventManager(); hooks = createFormatHooks(); + rumEventMapper = new RumEventMapper(); serverEvents = []; mockIpcMainOn.mockImplementation((channel: string, callback: (_event: unknown, msg: string) => void) => { @@ -98,7 +102,7 @@ describe('RendererPipeline', () => { handle: (event) => serverEvents.push(event), }); - new RendererPipeline(eventManager, hooks, DEFAULT_CONFIG); + new RendererPipeline(eventManager, hooks, DEFAULT_CONFIG, rumEventMapper); }); it('registers a listener on BRIDGE_CHANNEL', () => { @@ -210,6 +214,52 @@ describe('RendererPipeline', () => { expect(serverEvents).toHaveLength(0); }); + + it('maps events after injecting main-process context', () => { + hooks.registerRum(() => ({ session: { id: 'main-session' }, application: { id: 'main-app' } })); + vi.spyOn(rumEventMapper, 'map').mockImplementation((event) => { + expect(event.session.id).toBe('main-session'); + event.context = { mapped: true }; + return event; + }); + + simulateIpcMessage(JSON.stringify({ eventType: 'rum', event: RENDERER_RUM_DATA })); + + expect(serverEvents[0].data.context).toEqual({ mapped: true }); + }); + + it('does not emit events discarded by the mapper', () => { + hooks.registerRum(() => ({ session: { id: 'main-session' } })); + vi.spyOn(rumEventMapper, 'map').mockReturnValue(undefined); + + simulateIpcMessage(JSON.stringify({ eventType: 'rum', event: RENDERER_RUM_DATA })); + + expect(serverEvents).toHaveLength(0); + }); + + it('maps unknown renderer event types using common fields', () => { + hooks.registerRum(() => ({ session: { id: 'main-session' }, application: { id: 'main-app' } })); + const beforeSend = vi.fn((event: RumEvent) => { + event.context = { secret: '[REDACTED]' }; + return true; + }); + new RendererPipeline(eventManager, hooks, DEFAULT_CONFIG, new RumEventMapper(beforeSend)); + + simulateIpcMessage( + JSON.stringify({ + eventType: 'rum', + event: { ...RENDERER_RUM_DATA, type: 'future_event', context: { secret: 'token' } }, + }) + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(serverEvents).toHaveLength(1); + expect(serverEvents[0].data).toMatchObject({ + type: 'future_event', + session: { id: 'main-session' }, + context: { secret: '[REDACTED]' }, + }); + }); }); describe('usr and account context', () => { @@ -328,6 +378,24 @@ describe('RendererPipeline', () => { expect(serverEvents).toHaveLength(0); }); + it('emits END_USER_ACTIVITY for click actions even when beforeSend discards the event', () => { + const lifecycleEvents: unknown[] = []; + eventManager.registerHandler({ + canHandle: (e): e is EndUserActivityEvent => e.kind === EventKind.LIFECYCLE, + handle: (e) => lifecycleEvents.push(e), + }); + hooks.registerRum(() => ({ session: { id: 'session' } })); + vi.spyOn(rumEventMapper, 'map').mockReturnValue(undefined); + + simulateIpcMessage(JSON.stringify({ eventType: 'rum', event: RENDERER_CLICK_DATA })); + + expect(lifecycleEvents).toContainEqual({ + kind: EventKind.LIFECYCLE, + lifecycle: LifecycleKind.END_USER_ACTIVITY, + }); + expect(serverEvents).toHaveLength(0); + }); + it('does not emit END_USER_ACTIVITY for non-click events', () => { const lifecycleEvents: unknown[] = []; eventManager.registerHandler({ diff --git a/src/assembly/RendererPipeline.ts b/src/assembly/RendererPipeline.ts index 6145d7fb..b22b47a0 100644 --- a/src/assembly/RendererPipeline.ts +++ b/src/assembly/RendererPipeline.ts @@ -10,6 +10,7 @@ import { BRIDGE_CHANNEL, setBridgeConfig, type BridgeOptions } from '../common'; import type { FormatHooks } from './hooks'; import type { RumEvent } from '../domain/rum'; import { Configuration } from '../config'; +import { RumEventMapper } from './RumEventMapper'; type BridgeEventType = 'rum' | 'log' | 'internal_telemetry' | 'profile'; @@ -23,7 +24,7 @@ interface BridgeEvent { * * Receives pre-assembled events from the browser RUM SDK via the DatadogEventBridge, * injects main-process context (session.id, application.id, container.view.id) via - * triggerRum with source RENDERER, and emits ServerEvents directly. + * triggerRum with source RENDERER, applies beforeSend, and emits ServerEvents directly. * * Also emits END_USER_ACTIVITY for click actions before the session check, so a click * after session inactivity expiry can create a new session even though the event itself @@ -34,7 +35,8 @@ export class RendererPipeline { constructor( private readonly eventManager: EventManager, private readonly hooks: FormatHooks, - config: Configuration + config: Configuration, + private readonly rumEventMapper = new RumEventMapper() ) { this.bridgeOptions = { defaultPrivacyLevel: config.defaultPrivacyLevel, @@ -120,12 +122,16 @@ export class RendererPipeline { } const overrides = resolveCustomerContextOverrides(data, hookResult); + const mappedData = this.rumEventMapper.map(combine(data, overrides)); + if (!mappedData) { + return; + } const serverEvent: ServerRumEvent = { kind: EventKind.SERVER, track: EventTrack.RUM, source: EventSource.RENDERER, - data: combine(data, overrides), + data: mappedData, }; this.eventManager.notify(serverEvent); diff --git a/src/assembly/RumEventMapper.spec.ts b/src/assembly/RumEventMapper.spec.ts new file mode 100644 index 00000000..8cbd6cc6 --- /dev/null +++ b/src/assembly/RumEventMapper.spec.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RumEvent, RumLongTaskEvent, RumViewUpdateEvent } from '../domain/rum'; +import { + createServerRumAction, + createServerRumError, + createServerRumResource, + createServerRumView, +} from '../mocks.specUtil'; +import { display } from '../tools/display'; +import { RumEventMapper } from './RumEventMapper'; + +vi.mock('../tools/display', () => ({ + display: { error: vi.fn(), warn: vi.fn() }, +})); + +describe('RumEventMapper', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns the event unchanged when beforeSend is not configured', () => { + const event = createServerRumError(); + + expect(new RumEventMapper().map(event)).toBe(event); + }); + + it('lets beforeSend modify supported fields on a fully assembled event', () => { + const event = createServerRumError({ + service: 'original-service', + context: { secret: 'token' }, + error: { message: 'secret message', stack: 'secret stack' }, + }); + const mapper = new RumEventMapper((modifiableEvent) => { + expect(modifiableEvent.session.id).toBe('2'); + if (modifiableEvent.type === 'error') { + modifiableEvent.service = 'mapped-service'; + modifiableEvent.view.name = 'mapped-view'; + modifiableEvent.context = { scrubbed: true }; + modifiableEvent.error.message = 'redacted'; + modifiableEvent.error.stack = 'redacted'; + } + return true; + }); + + expect(mapper.map(event)).toMatchObject({ + service: 'mapped-service', + view: { name: 'mapped-view' }, + context: { scrubbed: true }, + error: { message: 'redacted', stack: 'redacted' }, + }); + }); + + it('ignores modifications to protected fields', () => { + const event = createServerRumError(); + const originalDate = event.date; + const mapper = new RumEventMapper((modifiableEvent) => { + if (modifiableEvent.type === 'error') { + Object.assign(modifiableEvent, { type: 'view', date: 42 }); + Object.assign(modifiableEvent.session, { id: 'changed-session' }); + Object.assign(modifiableEvent.error, { source: 'network' }); + } + return true; + }); + + const mappedEvent = mapper.map(event); + + expect(mappedEvent?.type).toBe('error'); + expect(mappedEvent?.date).toBe(originalDate); + expect(mappedEvent?.session.id).toBe('2'); + expect(mappedEvent).toMatchObject({ error: { source: 'source' } }); + }); + + it('ignores wrong-type modifications to allowlisted fields', () => { + const event = createServerRumError({ + service: 'original-service', + context: { secret: 'keep' }, + error: { message: 'original-message' }, + }); + const mapper = new RumEventMapper((modifiableEvent) => { + Object.assign(modifiableEvent, { service: 42, context: ['invalid'] }); + if (modifiableEvent.type === 'error') { + Object.assign(modifiableEvent.error, { message: { invalid: true } }); + } + return true; + }); + + expect(mapper.map(event)).toMatchObject({ + service: 'original-service', + context: { secret: 'keep' }, + error: { message: 'original-message' }, + }); + }); + + it('supports event-specific modifiable fields', () => { + const mapper = new RumEventMapper((event) => { + if (event.type === 'view' && event.view.performance?.lcp) { + event.view.performance.lcp.resource_url = 'redacted.png'; + } else if (event.type === 'error') { + event.error.message = 'redacted message'; + event.error.stack = 'redacted stack'; + event.error.handling_stack = 'redacted handling stack'; + if (event.error.resource) { + event.error.resource.url = 'https://redacted.example/error'; + } + event.error.fingerprint = 'redacted fingerprint'; + if (event._dd?.debug_ids) { + event._dd.debug_ids[0].url = 'redacted-error.js'; + } + } else if (event.type === 'resource') { + event.resource.url = 'https://redacted.example'; + if (event.resource.graphql) { + event.resource.graphql.variables = '{"redacted":true}'; + } + if (event.resource.request?.headers) { + event.resource.request.headers.authorization = '[REDACTED]'; + } + if (event.resource.response?.headers) { + event.resource.response.headers['set-cookie'] = '[REDACTED]'; + } + const websocket = event.resource.websocket as { close_reason?: string } | undefined; + if (websocket) { + websocket.close_reason = 'redacted reason'; + } + } else if (event.type === 'action' && event.action.target) { + event.action.target.name = 'redacted target'; + } else if (event.type === 'long_task' && event.long_task.scripts) { + event.long_task.scripts[0].source_url = 'redacted.js'; + event.long_task.scripts[0].invoker = 'redacted invoker'; + if (event._dd?.debug_ids) { + event._dd.debug_ids[0].url = 'redacted-long-task.js'; + } + } + return true; + }); + const view = createServerRumView({ + view: { performance: { lcp: { resource_url: 'secret.png' } } }, + }); + const error = createServerRumError({ + error: { + message: 'secret message', + stack: 'secret stack', + handling_stack: 'secret handling stack', + resource: { url: 'https://secret.example/error' }, + fingerprint: 'secret fingerprint', + }, + _dd: { debug_ids: [{ url: 'secret-error.js', id: 'error-debug-id' }] }, + }); + const resource = createServerRumResource({ + resource: { + graphql: { variables: '{"secret":true}' }, + request: { headers: { authorization: 'secret' } }, + response: { headers: { 'set-cookie': 'secret' } }, + }, + }); + Object.assign(resource.resource, { websocket: { close_reason: 'secret reason' } }); + const action = createServerRumAction({ action: { target: { name: 'secret target' } } }); + const longTask: RumLongTaskEvent = { + type: 'long_task', + date: 1, + application: { id: 'app-id' }, + session: { id: 'session-id', type: 'user' }, + view: { id: 'view-id', url: 'app://index' }, + _dd: { format_version: 2, debug_ids: [{ url: 'secret-long-task.js', id: 'long-task-debug-id' }] }, + long_task: { + duration: 1, + scripts: [{ source_url: 'secret.js', invoker: 'secret invoker' }], + }, + }; + + expect(mapper.map(view)).toMatchObject({ + view: { performance: { lcp: { resource_url: 'redacted.png' } } }, + }); + expect(mapper.map(error)).toMatchObject({ + error: { + message: 'redacted message', + stack: 'redacted stack', + handling_stack: 'redacted handling stack', + resource: { url: 'https://redacted.example/error' }, + fingerprint: 'redacted fingerprint', + }, + _dd: { debug_ids: [{ url: 'redacted-error.js', id: 'error-debug-id' }] }, + }); + expect(mapper.map(resource)).toMatchObject({ + resource: { + url: 'https://redacted.example', + graphql: { variables: '{"redacted":true}' }, + request: { headers: { authorization: '[REDACTED]' } }, + response: { headers: { 'set-cookie': '[REDACTED]' } }, + websocket: { close_reason: 'redacted reason' }, + }, + }); + expect(mapper.map(action)).toMatchObject({ action: { target: { name: 'redacted target' } } }); + expect(mapper.map(longTask)).toMatchObject({ + long_task: { scripts: [{ source_url: 'redacted.js', invoker: 'redacted invoker' }] }, + _dd: { debug_ids: [{ url: 'redacted-long-task.js', id: 'long-task-debug-id' }] }, + }); + }); + + it('sanitizes context changes', () => { + const mapper = new RumEventMapper((event) => { + const context: Record = { secret: 'token' }; + context.circular = context; + event.context = context; + return true; + }); + + expect(mapper.map(createServerRumError())?.context).toEqual({ + secret: 'token', + circular: '[Reference seen at $]', + }); + }); + + it('removes an empty context after beforeSend', () => { + const mapper = new RumEventMapper((event) => { + expect(event.context).toEqual({}); + return true; + }); + + expect(mapper.map(createServerRumError())?.context).toBeUndefined(); + }); + + it('removes context cleared by beforeSend', () => { + const mapper = new RumEventMapper((event) => { + delete event.context; + return true; + }); + + expect(mapper.map(createServerRumError({ context: { secret: 'remove' } }))?.context).toBeUndefined(); + }); + + it('drops an event only when beforeSend returns false', () => { + expect(new RumEventMapper(() => false).map(createServerRumError())).toBeUndefined(); + expect(new RumEventMapper(() => undefined as unknown as boolean).map(createServerRumError())).toBeDefined(); + }); + + it('does not drop view events', () => { + const event = createServerRumView(); + + expect(new RumEventMapper(() => false).map(event)).toBe(event); + expect(display.warn).toHaveBeenCalledWith("Can't dismiss view events using beforeSend!"); + }); + + it('does not drop crash events', () => { + const event = createServerRumError({ error: { is_crash: true } }); + + expect(new RumEventMapper(() => false).map(event)).toBe(event); + expect(display.warn).toHaveBeenCalledWith("Can't dismiss crash events using beforeSend!"); + }); + + it('fails open when beforeSend throws and keeps supported changes made before the error', () => { + const event = createServerRumError(); + const mapper = new RumEventMapper((modifiableEvent) => { + if (modifiableEvent.type === 'error') { + modifiableEvent.error.message = 'redacted before throw'; + } + throw new Error('customer callback failed'); + }); + + expect(mapper.map(event)).toMatchObject({ error: { message: 'redacted before throw' } }); + expect(display.error).toHaveBeenCalledWith('beforeSend threw an error:', expect.any(Error)); + }); + + it('invokes beforeSend without binding a this value', () => { + const beforeSend = vi.fn(function (_event: RumEvent) { + return true; + }); + + new RumEventMapper(beforeSend).map(createServerRumError()); + + expect(beforeSend.mock.contexts[0]).toBeUndefined(); + }); + + it('does not expose internal view updates to beforeSend', () => { + const beforeSend = vi.fn(() => false); + const event = { ...createServerRumView(), type: 'view_update' } as RumViewUpdateEvent; + + expect(new RumEventMapper(beforeSend).map(event as RumEvent)).toBe(event); + expect(beforeSend).not.toHaveBeenCalled(); + }); +}); diff --git a/src/assembly/RumEventMapper.ts b/src/assembly/RumEventMapper.ts new file mode 100644 index 00000000..f9436d17 --- /dev/null +++ b/src/assembly/RumEventMapper.ts @@ -0,0 +1,160 @@ +import { isEmptyObject, objectEntries, sanitize } from '@datadog/browser-core'; +import { deepClone, getType, isIndexableObject } from '@datadog/js-core/util'; +import type { RumBeforeSend } from '../config'; +import type { RumEvent } from '../domain/rum'; +import { display } from '../tools/display'; + +type ModifiableFieldPaths = Record; + +const COMMON_MODIFIABLE_FIELD_PATHS: ModifiableFieldPaths = { + 'view.name': 'string', + 'view.url': 'string', + 'view.referrer': 'string', + context: 'object', + service: 'string', + version: 'string', +}; + +const MODIFIABLE_FIELD_PATHS_BY_EVENT: Record = { + view: { + ...COMMON_MODIFIABLE_FIELD_PATHS, + 'view.performance.lcp.resource_url': 'string', + }, + error: { + ...COMMON_MODIFIABLE_FIELD_PATHS, + 'error.message': 'string', + 'error.stack': 'string', + 'error.handling_stack': 'string', + 'error.resource.url': 'string', + 'error.fingerprint': 'string', + '_dd.debug_ids': 'array', + }, + resource: { + ...COMMON_MODIFIABLE_FIELD_PATHS, + 'resource.url': 'string', + 'resource.graphql.variables': 'string', + 'resource.request.headers': 'object', + 'resource.response.headers': 'object', + 'resource.websocket.close_reason': 'string', + }, + action: { + ...COMMON_MODIFIABLE_FIELD_PATHS, + 'action.target.name': 'string', + }, + long_task: { + ...COMMON_MODIFIABLE_FIELD_PATHS, + 'long_task.scripts[].source_url': 'string', + 'long_task.scripts[].invoker': 'string', + '_dd.debug_ids': 'array', + }, + vital: COMMON_MODIFIABLE_FIELD_PATHS, + transition: COMMON_MODIFIABLE_FIELD_PATHS, + view_update: COMMON_MODIFIABLE_FIELD_PATHS, +}; + +/** + * Applies beforeSend after RUM events have been fully assembled. Customer changes are isolated in a clone, + * then only supported fields are sanitized and copied back. Filtering fails open for callback errors, + * and view or crash events remain protected. + */ +export class RumEventMapper { + constructor(private readonly beforeSend?: RumBeforeSend) {} + + map(event: RumEvent): RumEvent | undefined { + const beforeSend = this.beforeSend; + // Internal view updates bypass beforeSend. + if (!beforeSend || event.type === 'view_update') { + return event; + } + + event.context ??= {}; + // Unknown event types only expose common modifiable fields. + const modifiableFieldPaths = MODIFIABLE_FIELD_PATHS_BY_EVENT[event.type] ?? COMMON_MODIFIABLE_FIELD_PATHS; + const result = limitModification(event, modifiableFieldPaths, (modifiableEvent) => { + try { + return beforeSend(modifiableEvent); + } catch (error) { + display.error('beforeSend threw an error:', error); + return undefined; + } + }); + + if (event.context && isEmptyObject(event.context)) { + delete event.context; + } + + if (result !== false) { + return event; + } + if (event.type === 'view') { + display.warn("Can't dismiss view events using beforeSend!"); + return event; + } + if (event.type === 'error' && event.error.is_crash) { + display.warn("Can't dismiss crash events using beforeSend!"); + return event; + } + return undefined; + } +} + +function limitModification, Result>( + object: T, + modifiableFieldPaths: ModifiableFieldPaths, + modifier: (object: T) => Result +): Result { + const clone = deepClone(object); + const result = modifier(clone); + + objectEntries(modifiableFieldPaths).forEach(([fieldPath, fieldType]) => + setValueAtPath(object, clone, fieldPath.split(/\.|(?=\[\])/), fieldType) + ); + + return result; +} + +function setValueAtPath( + object: unknown, + clone: unknown, + pathSegments: string[], + fieldType: 'string' | 'object' | 'array' +): void { + const [field, ...restPathSegments] = pathSegments; + + if (field === '[]') { + if (Array.isArray(object) && Array.isArray(clone)) { + object.forEach((item, index) => setValueAtPath(item, clone[index], restPathSegments, fieldType)); + } + return; + } + + if (!isIndexableObject(object) || !isIndexableObject(clone)) { + return; + } + if (restPathSegments.length > 0) { + setValueAtPath(object[field], clone[field], restPathSegments, fieldType); + return; + } + + setNestedValue(object, field, clone[field], fieldType); +} + +function setNestedValue( + object: Record, + field: string, + value: unknown, + fieldType: 'string' | 'object' | 'array' +): void { + if (object[field] === value) { + return; + } + + const newType = getType(value); + if (newType === fieldType) { + object[field] = sanitize(value); + } else if (fieldType === 'object' && (newType === 'undefined' || newType === 'null')) { + object[field] = {}; + } else if (fieldType === 'array' && (newType === 'undefined' || newType === 'null')) { + object[field] = []; + } +} diff --git a/src/assembly/index.ts b/src/assembly/index.ts index 0e388734..198984ea 100644 --- a/src/assembly/index.ts +++ b/src/assembly/index.ts @@ -1,5 +1,6 @@ export { MainAssembly } from './MainAssembly'; export { RendererPipeline } from './RendererPipeline'; +export { RumEventMapper } from './RumEventMapper'; export type { BridgeOptions } from '../common'; export { registerCommonContext } from './commonContext'; export { createFormatHooks } from './hooks'; diff --git a/src/config.spec.ts b/src/config.spec.ts index 29921c06..79e8533b 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -319,6 +319,28 @@ describe('buildConfiguration', () => { }); }); + describe('beforeSend validation', () => { + it('preserves a valid callback', () => { + const beforeSend = vi.fn(() => true); + + expect(buildConfiguration({ ...DEFAULT_CONFIG, beforeSend })?.beforeSend).toBe(beforeSend); + }); + + it.each([undefined, null])('disables beforeSend without an error when set to %s', (beforeSend) => { + const config = { ...DEFAULT_CONFIG, beforeSend } as unknown as InitConfiguration; + + expect(buildConfiguration(config)?.beforeSend).toBeUndefined(); + expect(display.error).not.toHaveBeenCalled(); + }); + + it.each(['not-a-function', 42, {}])('logs an error and disables beforeSend when set to %o', (beforeSend) => { + const config = { ...DEFAULT_CONFIG, beforeSend } as unknown as InitConfiguration; + + expect(buildConfiguration(config)?.beforeSend).toBeUndefined(); + expect(display.error).toHaveBeenCalledWith("Configuration error: 'beforeSend' must be a function"); + }); + }); + describe('sessionSampleRate validation', () => { it('defaults to 100 when not provided', () => { const result = buildConfiguration({ ...DEFAULT_CONFIG }); diff --git a/src/config.ts b/src/config.ts index 83f3c275..3cce62aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { ONE_SECOND } from '@datadog/js-core/time'; import { ONE_KIBI_BYTE, ONE_MEBI_BYTE, DefaultPrivacyLevel } from '@datadog/browser-core'; import { display } from './tools/display'; +import type { RumEvent } from './domain/rum'; const VALID_DATADOG_SITES = [ 'datadoghq.com', @@ -28,6 +29,23 @@ export const BatchUploadFrequencies = { export type BatchSize = 'SMALL' | 'MEDIUM' | 'LARGE'; export type UploadFrequency = 'RARE' | 'NORMAL' | 'FREQUENT'; +/** + * Synchronous function called before a fully assembled RUM event is sent to Datadog. + * Keep this callback fast. Only supported field changes are applied; other mutations are ignored. + * Return false to discard the event. View and crash events cannot be discarded. + * + * @example + * ```ts + * beforeSend: (event) => { + * if (event.type === 'error') { + * event.error.message = '[REDACTED]'; + * } + * return true; + * } + * ``` + */ +export type RumBeforeSend = (event: RumEvent) => boolean; + export interface InitConfiguration { site: string; proxy?: string; @@ -43,6 +61,16 @@ export interface InitConfiguration { uploadFrequency?: UploadFrequency; defaultPrivacyLevel?: DefaultPrivacyLevel; allowedWebViewHosts?: string[]; + /** + * Synchronously modify supported fields on fully assembled RUM events, or return false to discard an event. + * Other mutations are ignored. View and crash events cannot be discarded. + * + * @example + * ```ts + * beforeSend: (event) => event.context?.internal !== true + * ``` + */ + beforeSend?: RumBeforeSend; } export interface Configuration { @@ -60,6 +88,7 @@ export interface Configuration { uploadFrequency?: UploadFrequency; defaultPrivacyLevel: DefaultPrivacyLevel; allowedWebViewHosts: string[]; + beforeSend?: RumBeforeSend; } function validateRequiredString(value: unknown, fieldName: string): string | undefined { @@ -151,6 +180,17 @@ function validateAllowedWebViewHosts(value: unknown): string[] { return value; } +function validateBeforeSend(value: unknown): RumBeforeSend | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'function') { + display.error("Configuration error: 'beforeSend' must be a function"); + return undefined; + } + return value as RumBeforeSend; +} + export function buildConfiguration(initConfig: InitConfiguration): Configuration | undefined { const service = validateRequiredString(initConfig.service, 'service'); const clientToken = validateRequiredString(initConfig.clientToken, 'clientToken'); @@ -183,5 +223,6 @@ export function buildConfiguration(initConfig: InitConfiguration): Configuration telemetrySampleRate, defaultPrivacyLevel: validateDefaultPrivacyLevel(initConfig.defaultPrivacyLevel), allowedWebViewHosts: validateAllowedWebViewHosts(initConfig.allowedWebViewHosts), + beforeSend: validateBeforeSend(initConfig.beforeSend), }; } diff --git a/src/index.ts b/src/index.ts index 501cbd7e..87d3deea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { MainAssembly, RendererPipeline, createFormatHooks, registerCommonContext } from './assembly'; +import { MainAssembly, RendererPipeline, RumEventMapper, createFormatHooks, registerCommonContext } from './assembly'; import { setDurationVitalApi } from './api'; import type { AccountInfo, UserInfo } from './domain/customer-context'; import { AccountContext, UserContext } from './domain/customer-context'; @@ -58,8 +58,9 @@ export async function init(configuration: InitConfiguration): Promise { startTelemetry(eventManager, config); sessionManager = await SessionManager.start(eventManager, hooks, config); - new MainAssembly(eventManager, hooks); - new RendererPipeline(eventManager, hooks, config); + const rumEventMapper = new RumEventMapper(config.beforeSend); + new MainAssembly(eventManager, hooks, rumEventMapper); + new RendererPipeline(eventManager, hooks, config, rumEventMapper); new ProfilingCollection(eventManager, sessionManager, config, hooks); @@ -293,13 +294,14 @@ export function getInternalContext(): InternalContext | undefined { export { addDurationVital, startDurationVital, stopDurationVital } from './api'; export type { AccountInfo, UserInfo } from './domain/customer-context'; -export type { InitConfiguration } from './config'; +export type { InitConfiguration, RumBeforeSend } from './config'; export type { AddDurationVitalOptions, DurationVitalOptions, FailureReason, FeatureOperationOptions, RumErrorEvent, + RumEvent, RumResourceEvent, RumViewEvent, RumVitalEvent, From c29fe1e451064058250664319ac999a9934d63fe Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Tue, 21 Jul 2026 12:00:47 +0200 Subject: [PATCH 2/3] refine beforeSend handling --- README.md | 4 +++- src/assembly/MainAssembly.ts | 2 +- src/assembly/RendererPipeline.spec.ts | 6 +++--- src/assembly/RendererPipeline.ts | 2 +- src/assembly/RumEventMapper.spec.ts | 8 +++++--- src/assembly/RumEventMapper.ts | 2 +- src/config.ts | 7 ++++--- 7 files changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6eb535dc..21c23efb 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ await init({ }); ``` -Returning `false` discards the event. View and native crash events cannot be discarded. Editable fields follow the [Browser SDK `beforeSend` allowlist](https://docs.datadoghq.com/real_user_monitoring/guide/enrich-and-control-rum-data/); event identity, session, application, and other protected fields remain unchanged. Callback errors are logged and the event is still sent. +Only returning `false` discards the event. View and native crash events cannot be discarded. Editable fields follow the [Browser SDK `beforeSend` allowlist](https://docs.datadoghq.com/real_user_monitoring/guide/enrich-and-control-rum-data/); event identity, session, application, and other protected fields remain unchanged. Callback errors are logged and the event is still sent. The callback is synchronous and should remain fast. It does not automatically detect PII. Unlike Browser SDK `beforeSend`, it receives no raw DOM, XHR, or original error context because those renderer objects cannot cross the process boundary. @@ -161,6 +161,8 @@ Renderer events have already passed through any `beforeSend` configured in the r Renderer view counters are computed by the Browser SDK before events reach the main process, so filtering a renderer event here does not retroactively adjust those counters. +Filtering a renderer click action does not suppress its session activity, so it can still keep or renew the session. + ### Custom Duration Vitals Record an already-known duration, or measure one between a start and stop call: diff --git a/src/assembly/MainAssembly.ts b/src/assembly/MainAssembly.ts index c38d18c6..9e7e5d9d 100644 --- a/src/assembly/MainAssembly.ts +++ b/src/assembly/MainAssembly.ts @@ -28,7 +28,7 @@ export class MainAssembly { constructor( private eventManager: EventManager, private hooks: FormatHooks, - private rumEventMapper = new RumEventMapper() + private rumEventMapper: RumEventMapper ) { this.eventManager.registerHandler({ canHandle: (event): event is StandardRawEvent => diff --git a/src/assembly/RendererPipeline.spec.ts b/src/assembly/RendererPipeline.spec.ts index 7fd6c648..67caed0e 100644 --- a/src/assembly/RendererPipeline.spec.ts +++ b/src/assembly/RendererPipeline.spec.ts @@ -121,7 +121,7 @@ describe('RendererPipeline', () => { profilingSampleRate: 0, }); mockSetBridgeConfig.mockClear(); - new RendererPipeline(eventManager, hooks, config); + new RendererPipeline(eventManager, hooks, config, new RumEventMapper()); expect(mockSetBridgeConfig).toHaveBeenCalledWith({ defaultPrivacyLevel: 'allow', allowedWebViewHosts: ['example.com'], @@ -133,14 +133,14 @@ describe('RendererPipeline', () => { it('advertises the profiles capability when profilingSampleRate > 0', () => { const config = createTestConfiguration({ profilingSampleRate: 100 }); mockSetBridgeConfig.mockClear(); - new RendererPipeline(new EventManager(), createFormatHooks(), config); + new RendererPipeline(new EventManager(), createFormatHooks(), config, new RumEventMapper()); expect((mockSetBridgeConfig.mock.calls[0]?.[0] as BridgeOptions).capabilities).toEqual(['profiles']); }); it('advertises no capabilities when profilingSampleRate is 0', () => { const config = createTestConfiguration({ profilingSampleRate: 0 }); mockSetBridgeConfig.mockClear(); - new RendererPipeline(new EventManager(), createFormatHooks(), config); + new RendererPipeline(new EventManager(), createFormatHooks(), config, new RumEventMapper()); expect((mockSetBridgeConfig.mock.calls[0]?.[0] as BridgeOptions).capabilities).toEqual([]); }); }); diff --git a/src/assembly/RendererPipeline.ts b/src/assembly/RendererPipeline.ts index b22b47a0..3c6a15ec 100644 --- a/src/assembly/RendererPipeline.ts +++ b/src/assembly/RendererPipeline.ts @@ -36,7 +36,7 @@ export class RendererPipeline { private readonly eventManager: EventManager, private readonly hooks: FormatHooks, config: Configuration, - private readonly rumEventMapper = new RumEventMapper() + private readonly rumEventMapper: RumEventMapper ) { this.bridgeOptions = { defaultPrivacyLevel: config.defaultPrivacyLevel, diff --git a/src/assembly/RumEventMapper.spec.ts b/src/assembly/RumEventMapper.spec.ts index 8cbd6cc6..1c6b8ac2 100644 --- a/src/assembly/RumEventMapper.spec.ts +++ b/src/assembly/RumEventMapper.spec.ts @@ -211,12 +211,14 @@ describe('RumEventMapper', () => { }); it('removes an empty context after beforeSend', () => { - const mapper = new RumEventMapper((event) => { - expect(event.context).toEqual({}); + const originalEvent = createServerRumError(); + const mapper = new RumEventMapper((modifiableEvent) => { + expect(originalEvent.context).toBeUndefined(); + expect(modifiableEvent.context).toEqual({}); return true; }); - expect(mapper.map(createServerRumError())?.context).toBeUndefined(); + expect(mapper.map(originalEvent)?.context).toBeUndefined(); }); it('removes context cleared by beforeSend', () => { diff --git a/src/assembly/RumEventMapper.ts b/src/assembly/RumEventMapper.ts index f9436d17..554dfc99 100644 --- a/src/assembly/RumEventMapper.ts +++ b/src/assembly/RumEventMapper.ts @@ -67,10 +67,10 @@ export class RumEventMapper { return event; } - event.context ??= {}; // Unknown event types only expose common modifiable fields. const modifiableFieldPaths = MODIFIABLE_FIELD_PATHS_BY_EVENT[event.type] ?? COMMON_MODIFIABLE_FIELD_PATHS; const result = limitModification(event, modifiableFieldPaths, (modifiableEvent) => { + modifiableEvent.context ??= {}; try { return beforeSend(modifiableEvent); } catch (error) { diff --git a/src/config.ts b/src/config.ts index 3cce62aa..55da264d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,7 +32,8 @@ export type UploadFrequency = 'RARE' | 'NORMAL' | 'FREQUENT'; /** * Synchronous function called before a fully assembled RUM event is sent to Datadog. * Keep this callback fast. Only supported field changes are applied; other mutations are ignored. - * Return false to discard the event. View and crash events cannot be discarded. + * Only an explicit false discards the event; any other return value keeps it. View and crash events cannot be + * discarded. * * @example * ```ts @@ -62,8 +63,8 @@ export interface InitConfiguration { defaultPrivacyLevel?: DefaultPrivacyLevel; allowedWebViewHosts?: string[]; /** - * Synchronously modify supported fields on fully assembled RUM events, or return false to discard an event. - * Other mutations are ignored. View and crash events cannot be discarded. + * Synchronously modify supported fields on fully assembled RUM events. Only an explicit false discards an event; + * other mutations are ignored. View and crash events cannot be discarded. * * @example * ```ts From 98b110191cdcf698c3ca291b7a62a81cebd3d55a Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Thu, 23 Jul 2026 10:35:22 +0200 Subject: [PATCH 3/3] simplify beforeSend event mapping --- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- src/assembly/RumEventMapper.spec.ts | 10 +--------- src/assembly/RumEventMapper.ts | 8 ++------ 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 21c23efb..c148e2c8 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Only returning `false` discards the event. View and native crash events cannot b The callback is synchronous and should remain fast. It does not automatically detect PII. Unlike Browser SDK `beforeSend`, it receives no raw DOM, XHR, or original error context because those renderer objects cannot cross the process boundary. -Renderer events have already passed through any `beforeSend` configured in the renderer's Browser SDK. The Electron callback runs afterward, once main-process context is added. It can run multiple times for the same view as its metrics change; internal `view_update` payloads are not exposed. +Renderer events have already passed through any `beforeSend` configured in the renderer's Browser SDK. The Electron callback runs afterward, once main-process context is added. It can run multiple times for the same view as its metrics change. Renderer view counters are computed by the Browser SDK before events reach the main process, so filtering a renderer event here does not retroactively adjust those counters. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c0ab99bf..c85c9b97 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -120,7 +120,7 @@ Two handlers transform events into `ServerEvent`s: - **`MainAssembly`**: handles main-process `RawEvent`s (excluding profile events), enriches them via `triggerRum` / `triggerTelemetry` hooks, and emits `ServerEvent`s with `source: MAIN`. - **`RendererPipeline`**: owns the renderer IPC channel, receives pre-assembled RUM events from the Browser SDK, enriches them via `triggerRum` with `source: EventSource.RENDERER`, and emits `ServerRumEvent`s with `source: RENDERER` directly, bypassing the `RawEvent` pipeline entirely. -Both paths apply the shared `RumEventMapper` after enrichment and before emitting the final `ServerRumEvent`. This lets `beforeSend` inspect the complete event and ensures filtered events never reach transport or main-view counters. Telemetry, profiles, and spans are not mapped; internal renderer `view_update` events are not exposed to the callback. +Both paths apply the shared `RumEventMapper` after enrichment and before emitting the final `ServerRumEvent`. This lets `beforeSend` inspect the complete event and ensures filtered events never reach transport or main-view counters. Telemetry, profiles, and spans are not mapped. #### Format Hooks diff --git a/src/assembly/RumEventMapper.spec.ts b/src/assembly/RumEventMapper.spec.ts index 1c6b8ac2..45bb5577 100644 --- a/src/assembly/RumEventMapper.spec.ts +++ b/src/assembly/RumEventMapper.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { RumEvent, RumLongTaskEvent, RumViewUpdateEvent } from '../domain/rum'; +import type { RumEvent, RumLongTaskEvent } from '../domain/rum'; import { createServerRumAction, createServerRumError, @@ -271,12 +271,4 @@ describe('RumEventMapper', () => { expect(beforeSend.mock.contexts[0]).toBeUndefined(); }); - - it('does not expose internal view updates to beforeSend', () => { - const beforeSend = vi.fn(() => false); - const event = { ...createServerRumView(), type: 'view_update' } as RumViewUpdateEvent; - - expect(new RumEventMapper(beforeSend).map(event as RumEvent)).toBe(event); - expect(beforeSend).not.toHaveBeenCalled(); - }); }); diff --git a/src/assembly/RumEventMapper.ts b/src/assembly/RumEventMapper.ts index 554dfc99..9972ddd4 100644 --- a/src/assembly/RumEventMapper.ts +++ b/src/assembly/RumEventMapper.ts @@ -15,7 +15,7 @@ const COMMON_MODIFIABLE_FIELD_PATHS: ModifiableFieldPaths = { version: 'string', }; -const MODIFIABLE_FIELD_PATHS_BY_EVENT: Record = { +const MODIFIABLE_FIELD_PATHS_BY_EVENT: Partial> = { view: { ...COMMON_MODIFIABLE_FIELD_PATHS, 'view.performance.lcp.resource_url': 'string', @@ -48,8 +48,6 @@ const MODIFIABLE_FIELD_PATHS_BY_EVENT: Record { modifiableEvent.context ??= {};