Skip to content
Open
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,18 +375,19 @@ interface FeatureOperationOptions {

### Configuration Options

| Option | Type | Required | Default | Description |
| --------------------- | ---------------------------------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | Yes | — | Datadog client token |
| `applicationId` | `string` | Yes | — | RUM application ID |
| `site` | `string` | Yes | — | Datadog site (e.g. `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`, `ddog-gov.com`) |
| `service` | `string` | Yes | — | Service name |
| `env` | `string` | No | — | Application environment |
| `version` | `string` | No | — | Application version |
| `sessionSampleRate` | `number` | No | `100` | Percentage of sessions to collect (0–100). `0` collects no sessions; `100` collects all sessions. |
| `profilingSampleRate` | `number` | No | `0` | Percentage of sampled sessions that are profiled (0–100). `0` disables renderer profiling. See [Renderer Profiling](#renderer-profiling). |
| `telemetrySampleRate` | `number` | No | `20` | Telemetry sample rate (0–100) |
| `batchSize` | `'SMALL' \| 'MEDIUM' \| 'LARGE'` | No | — | Batch size for event uploads |
| `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 |
| Option | Type | Required | Default | Description |
| ------------------------- | ---------------------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientToken` | `string` | Yes | — | Datadog client token |
| `applicationId` | `string` | Yes | — | RUM application ID |
| `site` | `string` | Yes | — | Datadog site (e.g. `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`, `ddog-gov.com`) |
| `service` | `string` | Yes | — | Service name |
| `env` | `string` | No | — | Application environment |
| `version` | `string` | No | — | Application version |
| `sessionSampleRate` | `number` | No | `100` | Percentage of sessions to collect (0–100). `0` collects no sessions; `100` collects all sessions. |
| `sessionReplaySampleRate` | `number` | No | `0` | Percentage of sampled sessions that record session replay (0–100). `0` disables renderer session replay. Applied as a child of `sessionSampleRate`. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit — add a Session Replay setup section.

sessionReplaySampleRate is now documented in the config table (good), but unlike profilingSampleRate there is no linked "Session Replay" section under Available Features or a setup guide explaining that customers must also initialize @datadog/browser-rum in the renderer with session replay enabled. A short section mirroring Renderer Profiling would close the loop for adopters.

| `profilingSampleRate` | `number` | No | `0` | Percentage of sampled sessions that are profiled (0–100). `0` disables renderer profiling. See [Renderer Profiling](#renderer-profiling). |
| `telemetrySampleRate` | `number` | No | `20` | Telemetry sample rate (0–100) |
| `batchSize` | `'SMALL' \| 'MEDIUM' \| 'LARGE'` | No | — | Batch size for event uploads |
| `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 |
3 changes: 1 addition & 2 deletions e2e/app/src/bridge-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ datadogRum.init({
clientToken: 'pub-renderer-token',
site: 'datadoghq.com',
service: 'e2e-renderer',
sessionSampleRate: 100,
profilingSampleRate: 100,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
sessionReplaySampleRate: 100,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore profiling sampling in the bridge fixture

This bridge-window fixture is also used by e2e/scenarios/profiling.scenario.ts, which opens the bridge window and waits for a profiling multipart upload. Removing profilingSampleRate: 100 from this Browser RUM init leaves browser-side profiling disabled for that window, so the existing profiling E2E will time out waiting for a profile even though the Electron SDK still advertises the profiles capability.

Useful? React with 👍 / 👎.

});

document.getElementById('status')!.textContent = 'bridge-ready';
2 changes: 1 addition & 1 deletion e2e/app/src/main-window.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'; worker-src blob:"
/>
<title>Main Window</title>
<style>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/forge-vite/src/renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/apps/forge-webpack/src/renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ datadogRum.init({
applicationId: 'integration-test-app-id',
service: 'integration-test-renderer',
site: 'datadoghq.com',
sessionReplaySampleRate: 100,
trackUserInteractions: false,
trackResources: false,
trackLongTasks: false,
Expand Down
1 change: 1 addition & 0 deletions e2e/integration/lib/integrationFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function buildSdkConfig(intake: Intake): InitConfiguration {
applicationId: 'integration-test-app-id',
env: 'test',
version: '1.0.0',
sessionReplaySampleRate: 100,
telemetrySampleRate: 100,
defaultPrivacyLevel: 'mask',
allowedWebViewHosts: [],
Expand Down
12 changes: 12 additions & 0 deletions e2e/lib/bridgeWindowPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export class BridgeWindowPage {
);
}

async getBridgeCapabilities(): Promise<string[]> {
return this.page.evaluate(() => {
const bridge = (
globalThis as unknown as {
DatadogEventBridge: { getCapabilities(): string };
}
).DatadogEventBridge;

return JSON.parse(bridge.getCapabilities()) as string[];
});
}

async triggerProfilingFlush(): Promise<void> {
await this.page.close({ runBeforeUnload: true });
// Wait for IPC propagation: beforeunload → bridge send → main process → write queue
Expand Down
2 changes: 2 additions & 0 deletions e2e/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async function launchApp(
env: 'test',
version: '1.0.0',
sessionSampleRate: 100,
sessionReplaySampleRate: 100,
profilingSampleRate: 100,
telemetrySampleRate: 100,
defaultPrivacyLevel: 'mask',
Expand All @@ -135,6 +136,7 @@ async function launchApp(
site: 'datadoghq.com',
service: 'e2e-main-window',
sessionSampleRate: 100,
sessionReplaySampleRate: 100,
trackUserInteractions: true,
...rumBrowserSdk,
});
Expand Down
143 changes: 131 additions & 12 deletions e2e/lib/intake.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as http from 'node:http';
import zlib from 'node:zlib';

/**
* Fake Datadog intake used to assert what the SDK sends.
Expand All @@ -14,6 +15,19 @@ export interface ReceivedEvent {
ddforward: 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>;
/**
* The rrweb records decoded from the compressed `segment` blob, when it was a
* standalone-inflatable ZLIB stream (the common single-segment case). Undefined
* if the `segment` part was absent or could not be inflated on its own.
*/
records?: unknown[];
}

export interface Trace {
env: string;
spans: Span[];
Expand All @@ -37,9 +51,27 @@ export interface ProfilingRequest {

const byType = (type: string) => (event: ReceivedEvent) => (event.body as { type?: string }).type === type;

/**
* Splits a Buffer on a byte delimiter, returning the chunks between delimiters.
* Operates on raw bytes (not utf8) so binary multipart parts survive intact.
*/
function splitBuffer(buf: Buffer, delimiter: Buffer): Buffer[] {
const parts: Buffer[] = [];
let start = 0;
let idx = buf.indexOf(delimiter, start);
while (idx !== -1) {
if (idx > start) parts.push(buf.subarray(start, idx));
start = idx + delimiter.length;
idx = buf.indexOf(delimiter, start);
}
if (start < buf.length) parts.push(buf.subarray(start));
return parts;
}

export class Intake {
private server: http.Server | null = null;
private rumEvents: ReceivedEvent[] = [];
private replaySegments: ReplaySegment[] = [];
private traces: Trace[] = [];
private profilingRequests: ProfilingRequest[] = [];
private port = 0;
Expand All @@ -57,6 +89,61 @@ 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;

// Parse the multipart body on the raw Buffer (not a utf8 string): the `segment`
// part is deflate-compressed binary and would be corrupted by a utf8 round-trip.
const delimiter = Buffer.from(`--${boundaryMatch[1]}`);
let metadata: Record<string, unknown> | undefined;
let compressed: Buffer | undefined;

for (const part of splitBuffer(rawBody, delimiter)) {
// Each part is `\r\n<headers>\r\n\r\n<body>\r\n`. The preamble and the closing
// `--` marker have no header/body separator, so they're skipped here.
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;

const partHeaders = part.subarray(0, headerEnd).toString('utf8');
let body = part.subarray(headerEnd + 4);
// Strip the trailing CRLF that precedes the next delimiter.
if (body.length >= 2 && body[body.length - 2] === 0x0d && body[body.length - 1] === 0x0a) {
body = body.subarray(0, body.length - 2);
}

if (partHeaders.includes('name="event"')) {
try {
metadata = JSON.parse(body.toString('utf8')) as Record<string, unknown>;
} catch {
// malformed event part — ignore
}
} else if (partHeaders.includes('name="segment"')) {
compressed = body;
}
}

if (!metadata) return;

const segment: ReplaySegment = { timestamp: Date.now(), metadata, headers };

if (compressed) {
try {
// A single segment is a self-contained ZLIB stream (header + full-flush body +
// final block + Adler-32), so it inflates standalone. Continuation segments carry
// a cumulative Adler-32 that won't match a lone stream — those stay undecoded.
const inflated = zlib.inflateSync(compressed);
const parsed = JSON.parse(inflated.toString('utf8')) as { records?: unknown[] };
segment.records = parsed.records ?? [];
} catch {
// segment blob not standalone-inflatable — leave records undefined
}
}

this.replaySegments.push(segment);
}

private storeTraces(parsedBody: unknown) {
const items = Array.isArray(parsedBody) ? (parsedBody as Trace[]) : [parsedBody as Trace];
for (const item of items) {
Expand Down Expand Up @@ -92,10 +179,10 @@ export class Intake {
return;
}

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

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

if (ddforward.startsWith('/api/v2/profile')) {
Expand All @@ -117,18 +204,28 @@ export class Intake {
}

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 isMultipart = (headers['content-type'] ?? '').includes('multipart/form-data');

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);
} else {
Expand Down Expand Up @@ -278,8 +375,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 = [];
this.profilingRequests = [];
this.quotaDecision = 'quota_ok';
Expand Down
Loading