diff --git a/README.md b/README.md index a9480af9..c148e2c8 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,36 @@ 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; + }, +}); +``` + +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. + +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. + +Filtering a renderer click action does not suppress its session activity, so it can still keep or renew the session. ### Custom Duration Vitals @@ -390,3 +420,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..c85c9b97 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. + #### 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..9e7e5d9d 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: 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..67caed0e 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', () => { @@ -117,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'], @@ -129,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([]); }); }); @@ -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..3c6a15ec 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: 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..45bb5577 --- /dev/null +++ b/src/assembly/RumEventMapper.spec.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RumEvent, RumLongTaskEvent } 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 originalEvent = createServerRumError(); + const mapper = new RumEventMapper((modifiableEvent) => { + expect(originalEvent.context).toBeUndefined(); + expect(modifiableEvent.context).toEqual({}); + return true; + }); + + expect(mapper.map(originalEvent)?.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(); + }); +}); diff --git a/src/assembly/RumEventMapper.ts b/src/assembly/RumEventMapper.ts new file mode 100644 index 00000000..9972ddd4 --- /dev/null +++ b/src/assembly/RumEventMapper.ts @@ -0,0 +1,156 @@ +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: Partial> = { + 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, +}; + +/** + * 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; + if (!beforeSend) { + return event; + } + + 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) { + 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..55da264d 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,24 @@ 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. + * Only an explicit false discards the event; any other return value keeps it. 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 +62,16 @@ export interface InitConfiguration { uploadFrequency?: UploadFrequency; defaultPrivacyLevel?: DefaultPrivacyLevel; allowedWebViewHosts?: string[]; + /** + * 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 + * beforeSend: (event) => event.context?.internal !== true + * ``` + */ + beforeSend?: RumBeforeSend; } export interface Configuration { @@ -60,6 +89,7 @@ export interface Configuration { uploadFrequency?: UploadFrequency; defaultPrivacyLevel: DefaultPrivacyLevel; allowedWebViewHosts: string[]; + beforeSend?: RumBeforeSend; } function validateRequiredString(value: unknown, fieldName: string): string | undefined { @@ -151,6 +181,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 +224,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,