Skip to content
Open
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ playwright-report/

# Dependencies & Yarn
**/node_modules/
**/.yarn/
**/.yarn/*
!**/.yarn/patches/
!**/.yarn/patches/**

# IA settings
/.claude/settings.local.json
Expand Down
95 changes: 95 additions & 0 deletions bin/patch-dd-trace-preload.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict';

const fs = require('fs');
const path = require('path');

const PRELOAD_REL = 'packages/datadog-instrumentations/src/electron/preload.js';

// The replacement preload — identical to upstream except getCapabilities()
// returns '["records"]' to enable session replay recording.
const NEW_PRELOAD = `'use strict';

// eslint-disable-next-line n/no-missing-require
const { contextBridge, ipcRenderer } = require('electron');

const BRIDGE_CHANNEL = 'datadog:bridge-send';
const CONFIG_CHANNEL = 'datadog:bridge-config';

// Privacy levels matching @datadog/browser-core DefaultPrivacyLevel
const MASK = 'mask';

const config = ipcRenderer.sendSync(CONFIG_CHANNEL);

const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK;
const configuredHosts = config?.allowedWebViewHosts ?? [];
// eslint-disable-next-line no-undef
const allowedHosts = [...new Set([location.hostname, ...configuredHosts])];

const bridge = {
getCapabilities() {
return '["records"]';
},
getPrivacyLevel() {
return defaultPrivacyLevel;
},
getAllowedWebViewHosts() {
return JSON.stringify(allowedHosts);
},
send(msg) {
ipcRenderer.send(BRIDGE_CHANNEL, msg);
},
};

// Support both contextIsolation enabled (default) and disabled

window.DatadogEventBridge = bridge;

try {
contextBridge.exposeInMainWorld('DatadogEventBridge', bridge);
} catch {
// exposeInMainWorld throws when contextIsolation is disabled
}
`;

function findPreloadIn(dir) {
const p = path.join(dir, 'node_modules', 'dd-trace', PRELOAD_REL);
return fs.existsSync(p) ? p : null;
}

/** Walk up from dir looking for a package.json with a workspaces field. */
function findMonorepoRoot(dir) {
let current = dir;
while (true) {
const parent = path.dirname(current);
if (parent === current) return null;
const pkgPath = path.join(parent, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.workspaces) return parent;
} catch {}
}
current = parent;
}
}

const cwd = process.cwd();

let preloadPath = findPreloadIn(cwd);

if (!preloadPath) {
const root = findMonorepoRoot(cwd);
if (root) preloadPath = findPreloadIn(root);
}

if (!preloadPath) {
console.log('[datadog] dd-trace preload.js not found — skipping patch');
process.exit(0);
}

try {
fs.writeFileSync(preloadPath, NEW_PRELOAD, 'utf8');
console.log('[datadog] Patched', path.relative(cwd, preloadPath));
} catch (err) {
console.warn('[datadog] Could not patch dd-trace preload:', err.message);
}
1 change: 1 addition & 0 deletions e2e/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"packageManager": "yarn@4.12.0",
"main": "dist/main.js",
"scripts": {
"postinstall": "node ../../bin/patch-dd-trace-preload.cjs",
"build": "yarn build:ts && mkdir -p ./dist && yarn build:bridge-window && yarn build:main-window",
"build:ts": "tsc && tsc --project tsconfig.renderer.json",
"build:main-window": "esbuild src/main-window.ts --bundle --format=iife --outfile=dist/main-window.js --sourcemap && cp src/main-window.html dist/",
Expand Down
1 change: 1 addition & 0 deletions e2e/app/src/bridge-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ datadogRum.init({
site: 'datadoghq.com',
service: 'e2e-renderer',
sessionSampleRate: 100,
sessionReplaySampleRate: 100,
Comment on lines 8 to +9

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 suggestion: ‏those two are not used in bridge mode

Suggested change
sessionSampleRate: 100,
sessionReplaySampleRate: 100,

trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/electron-builder-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"build": "vite build -c vite.main.config.ts && vite build -c vite.preload.config.ts && vite build -c vite.renderer.config.ts",
"package": "yarn build && electron-builder --dir --publish never"
},
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/electron-vite-esm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"package": "electron-vite build && electron-builder --dir --publish never"
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/electron-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"package": "electron-vite build && electron-builder --dir --publish never"
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/forge-esbuild-cjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"build": "node esbuild.config.mjs",
"start": "yarn build && electron .",
"package": "yarn build && electron-forge package"
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/forge-esbuild-esm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"build": "node esbuild.config.mjs",
"start": "yarn build && electron .",
"package": "yarn build && electron-forge package"
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/forge-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"start": "electron-forge start",
"package": "electron-forge package"
},
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/forge-webpack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
}
},
"scripts": {
"postinstall": "node ../../../../bin/patch-dd-trace-preload.cjs",
"start": "electron-forge start",
"package": "electron-forge package"
},
Expand Down
91 changes: 78 additions & 13 deletions e2e/lib/intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export interface ReceivedEvent {
headers: Record<string, string>;
}

export interface ReplaySegment {
timestamp: number;
/** Parsed JSON from the multipart `event` field — segment metadata + size fields. */
metadata: Record<string, unknown>;
headers: Record<string, string>;
}

export interface Trace {
env: string;
spans: Span[];
Expand All @@ -33,6 +40,7 @@ const byType = (type: string) => (event: ReceivedEvent) => (event.body as { type
export class Intake {
private server: http.Server | null = null;
private rumEvents: ReceivedEvent[] = [];
private replaySegments: ReplaySegment[] = [];
private traces: Trace[] = [];
private port = 0;

Expand All @@ -47,6 +55,31 @@ export class Intake {
}
}

private storeReplaySegment(rawBody: Buffer, headers: Record<string, string>) {
const contentType = headers['content-type'] ?? '';
const boundaryMatch = /boundary=([^\s;]+)/.exec(contentType);
if (!boundaryMatch) return;

const boundary = `--${boundaryMatch[1]}`;
const body = rawBody.toString('utf8');
const parts = body.split(boundary).slice(1); // skip preamble

for (const part of parts) {
if (part.startsWith('--')) continue; // final boundary
const [rawHeaders, ...bodyLines] = part.replace(/^\r\n/, '').split('\r\n\r\n');
const disposition = rawHeaders ?? '';
if (!disposition.includes('name="event"')) continue;

try {
const eventJson = bodyLines.join('\r\n\r\n').replace(/\r\n$/, '');
const metadata = JSON.parse(eventJson) as Record<string, unknown>;
this.replaySegments.push({ timestamp: Date.now(), metadata, headers });
} catch {
// malformed event part — ignore
}
}
}

private storeTraces(parsedBody: unknown) {
const items = Array.isArray(parsedBody) ? (parsedBody as Trace[]) : [parsedBody as Trace];
for (const item of items) {
Expand All @@ -63,26 +96,36 @@ export class Intake {
return;
}

let body = '';
const chunks: Buffer[] = [];

req.on('data', (chunk: Buffer) => {
body += chunk.toString();
chunks.push(chunk);
});

req.on('end', () => {
try {
const parsedBody: unknown = JSON.parse(body);
const headers: Record<string, string> = {};

for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') {
headers[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
headers[key.toLowerCase()] = value.join(', ');
}
const rawBody = Buffer.concat(chunks);
const headers: Record<string, string> = {};

for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') {
headers[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
headers[key.toLowerCase()] = value.join(', ');
}
}

const ddforward = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ddforward') ?? '';
const isMultipart = (headers['content-type'] ?? '').includes('multipart/form-data');

const ddforward = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ddforward') ?? '';
if (ddforward.startsWith('/api/v2/replay') || isMultipart) {
this.storeReplaySegment(rawBody, headers);
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'accepted' }));
return;
}

try {
const parsedBody: unknown = JSON.parse(rawBody.toString());

if (ddforward.startsWith('/api/v2/spans')) {
this.storeTraces(parsedBody);
Expand Down Expand Up @@ -204,8 +247,30 @@ export class Intake {
}
}

async waitForReplaySegment(options?: {
timeout?: number;
predicate?: (segment: ReplaySegment) => boolean;
}): Promise<ReplaySegment> {
const timeout = options?.timeout ?? 10000;
const predicate = options?.predicate ?? (() => true);
const startTime = Date.now();

while (Date.now() - startTime < timeout) {
const match = this.replaySegments.find(predicate);
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, 100));
}

throw new Error(`Timed out waiting for a replay segment after ${timeout}ms.`);
}

getReplaySegments(): ReplaySegment[] {
return [...this.replaySegments];
}

clear(): void {
this.rumEvents = [];
this.replaySegments = [];
this.traces = [];
}

Expand Down
72 changes: 72 additions & 0 deletions e2e/scenarios/replay.scenario.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { test, expect } from '../lib/helpers';

/**
* Session replay E2E scenarios.
*
* These tests verify that:
* 1. The dd-trace preload exposes "records" capability so the browser RUM SDK
* starts emitting BrowserRecord events through the bridge.
* 2. ReplayCollection buffers those records and emits a compressed segment.
* 3. ReplayBatchConsumer uploads the segment as multipart/form-data and the
* mock intake receives it with correct metadata.
*/

test.describe('session replay', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 suggestion: ‏we should add a scenario to exercise the mask configuration

test('replay segment arrives at the intake after opening a bridge window', async ({
electronApp,
mainPage,
intake,
}) => {
// Open a bridge window — the browser RUM SDK initialises and, because the
// dd-trace preload advertises "records" capability, starts recording rrweb events.
const bridgeWindow = await mainPage.openBridgeFileWindow(electronApp);

// Give the renderer time to produce at least one full-snapshot record.
await bridgeWindow.page.waitForTimeout(2000);

// Flush forces ReplayCollection to flush the current segment, compress it,
// and push it through the batch pipeline to the intake.
await mainPage.flushTransport();

const segment = await intake.waitForReplaySegment({ timeout: 20_000 });

// The segment metadata should be populated with main-process context
expect(segment.metadata['session']).toBeDefined();
expect(segment.metadata['application']).toBeDefined();
expect(segment.metadata['view']).toBeDefined();
expect(segment.metadata['records_count']).toBeGreaterThan(0);
expect(segment.metadata['source']).toBe('browser');
});

test('view event includes has_replay: true after a replay segment is sent', async ({
electronApp,
mainPage,
intake,
}) => {
// Open bridge window and wait for recording to produce data
const bridgeWindow = await mainPage.openBridgeFileWindow(electronApp);
await bridgeWindow.page.waitForTimeout(2000);

// First flush — sends the replay segment to the intake
await mainPage.flushTransport();
await intake.waitForReplaySegment({ timeout: 20_000 });

// Trigger renderer activity so browser-rum emits a fresh view update.
// generateError works in file:// context (unlike fetch-based generateResource).
// Assembly will enrich the resulting view update with has_replay: true now
// that replay stats exist for this view.
await bridgeWindow.generateError('replay-trigger');
await mainPage.flushTransport();

const viewEvents = await intake.waitForEventCount('view', 1, {
timeout: 20_000,
predicate: (e) => {
const body = e.body as Record<string, unknown>;
const session = body['session'] as Record<string, unknown> | undefined;
return session?.['has_replay'] === true;
},
});

expect(viewEvents.length).toBeGreaterThanOrEqual(1);
});
});
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export default tseslint.config(
// Integration apps are standalone projects with their own tsconfigs and toolchains.
// They are not part of the root project service and are not linted here.
'e2e/integration/apps/**',
// Plain JS/CJS files not covered by any tsconfig project.
'bin/**',
],
},
js.configs.recommended,
Expand Down
Loading