Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already document renderer view-counter staleness. Consider also noting that filtering renderer click actions still emits internal session activity (END_USER_ACTIVITY) so sessions can renew even when the click RUM event is discarded—this is tested in RendererPipeline.spec.ts and may surprise integrators.


Filtering a renderer click action does not suppress its session activity, so it can still keep or renew the session.

### Custom Duration Vitals

Expand Down Expand Up @@ -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 |
7 changes: 6 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ flowchart LR
subgraph Assembly
HOOKS{Format Hooks}
COMBINE[combine]
MAPPER[beforeSend mapper]
end

subgraph "Hook Providers"
Expand All @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion e2e/app/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ declare global {
stopSession: () => Promise<void>;
generateUncaughtException: () => Promise<void>;
generateUnhandledRejection: () => Promise<void>;
generateManualError: (startTime?: number) => Promise<void>;
generateManualError: (startTime?: number, context?: Record<string, string>) => Promise<void>;
crash: () => Promise<void>;
ping: () => Promise<string>;
openBridgeFileWindow: () => Promise<void>;
Expand Down
24 changes: 21 additions & 3 deletions e2e/app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => {
addError(new Error('test manual error'), { context: context ?? { foo: 'bar' }, startTime });
});

ipcMain.handle('addDurationVital', (_event, name: string, options: AddDurationVitalOptions) => {
Expand Down Expand Up @@ -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');
}
3 changes: 2 additions & 1 deletion e2e/app/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) =>
ipcRenderer.invoke('generateManualError', startTime, context),
addDurationVital: (name: string, options: Record<string, unknown>) =>
ipcRenderer.invoke('addDurationVital', name, options),
startDurationVital: (name: string, options?: Record<string, unknown>) =>
Expand Down
9 changes: 7 additions & 2 deletions e2e/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface TestFixtures {
rumBrowserSdk: Record<string, unknown> | null;
initialIntakeQuotaDecision: 'quota_ok' | 'quota_ko';
sdkConfigOverrides: Partial<InitConfiguration> | null;
beforeSendMode: 'scrub-and-filter' | null;
}

/**
Expand Down Expand Up @@ -71,9 +72,11 @@ export const test = base.extend<TestFixtures>({
{ 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);
Expand All @@ -94,6 +97,8 @@ export const test = base.extend<TestFixtures>({
rumBrowserSdk: [null, { option: true }],

sdkConfigOverrides: [null, { option: true }],

beforeSendMode: [null, { option: true }],
});

async function launchApp(
Expand Down
9 changes: 5 additions & 4 deletions e2e/lib/mainPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { BridgeWindowPage } from './bridgeWindowPage';
interface ElectronAppWindow {
electronAPI: {
generateTelemetryErrors: (count: number) => Promise<void>;
generateManualError: (startTime?: number) => Promise<void>;
generateManualError: (startTime?: number, context?: Record<string, string>) => Promise<void>;
addDurationVital: (name: string, options: AddDurationVitalOptions) => Promise<void>;
startDurationVital: (name: string, options?: DurationVitalOptions) => Promise<void>;
stopDurationVital: (name: string, options?: DurationVitalOptions) => Promise<void>;
Expand Down Expand Up @@ -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<string, string>) {
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 }
);
}

Expand Down
28 changes: 28 additions & 0 deletions e2e/scenarios/before-send.scenario.ts
Original file line number Diff line number Diff line change
@@ -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]',
});
});
6 changes: 6 additions & 0 deletions playground/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ <h2>Session ID:</h2>
<button id="op-parallel-uploads">Run 5 parallel uploads</button>
</div>

<div class="section-label">beforeSend</div>
<div class="button-group">
<button id="before-send-scrub">Scrub Error</button>
<button id="before-send-filter">Filter Error</button>
</div>

<div class="session-section">
<h2>IPC Activity Log:</h2>
<div id="ipc-log"></div>
Expand Down
17 changes: 17 additions & 0 deletions playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as fs from 'node:fs';
import * as https from 'node:https';
import {
init,
addError,
stopSession,
_flushTransport,
getInternalContext,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions playground/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down
6 changes: 6 additions & 0 deletions playground/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface ElectronAPI {
generateTelemetryError: () => Promise<void>;
generateUncaughtException: () => Promise<void>;
generateUnhandledRejection: () => Promise<void>;
generateBeforeSendError: (behavior: 'scrub' | 'filter') => Promise<void>;
crash: () => Promise<void>;
mainFetchApi: () => Promise<unknown>;
addDurationVital: (name: string, options: AddDurationVitalOptions) => Promise<void>;
Expand Down Expand Up @@ -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'));
Expand Down
16 changes: 16 additions & 0 deletions playground/test/before-send.scenario.ts
Original file line number Diff line number Diff line change
@@ -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]' },
});
});
33 changes: 31 additions & 2 deletions src/assembly/MainAssembly.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<RawRumEvent>) {
Expand All @@ -53,14 +55,15 @@ describe('MainAssembly', () => {
beforeEach(() => {
eventManager = new EventManager();
hooks = createFormatHooks();
rumEventMapper = new RumEventMapper();
serverEvents = [];

eventManager.registerHandler<ServerEvent>({
canHandle: (event): event is ServerEvent => event.kind === EventKind.SERVER,
handle: (event) => serverEvents.push(event),
});

new MainAssembly(eventManager, hooks);
new MainAssembly(eventManager, hooks, rumEventMapper);
});

it('favors raw event attributes over hook attributes', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading