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
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ When `enableExecutionContext` is set, `ExecutionContextCollection` (`src/domain/

**Fake views.** Every RUM event needs a `view.id` to resolve against, but the main process has no real navigable page to attach one to. Fake views are synthetically created by the SDK to carry execution-context events during the transition phase where execution-context events still require a `view.id`. Every event type associated with a fake view carries `view.is_fake: true`, so the backend and frontend can filter it out. `ViewContext` (`src/domain/rum/view/ViewContext.ts`) is what tags main-process events with this fake view instead of a real one when execution-context tracking is enabled — pointing at the same `id` `MainProcessContext` maintains as an actual `view` RUM document.

### Payload Scrubbing

`PathScrubber` (`src/tools/pathScrubber.ts`) replaces the app path (and its `.unpacked` folder) with `/` in serialized payloads, so stack frames and URLs match uploaded source maps. Data folders (`userData`, `crashDumps`) are not masked.

- Built once in `init()` and injected into the three exit points (`StandardBatchProducer`, `ProfileBatchProducer`, `Segment.flush`), after `beforeSendRum`. Batch rotation and the replay raw size (`raw_segment_size`) are computed on the scrubbed text.
- Contract: valid JSON in and out, never throws; an unscrubbed payload is sent rather than lost.
- Best effort: known limits are listed in the class JSDoc.

## Internal Tracking Consent State

`TrackingConsentManager` starts in `granted` when explicitly constructed. Its timestamp history
Expand Down
5 changes: 5 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ Integration scenarios inherit a 60-second test timeout from `e2e/playwright.conf
The minimal E2E project keeps its 30-second timeout; the session-renewal scenario allows 60 seconds overall
and 30 seconds for renewed-session telemetry. Other intake waits retain their existing limits.

At teardown, the intake fails the test if any captured payload (every endpoint, with replay and profile parts decoded)
contains an SDK path of an app launched during the test (app path, `userData`, `crashDumps` and their realpaths, read at
launch), in raw, `/`-separated or decoded URL form. Data folders are checked although the SDK does not mask them, so that
a failure on them signals a real case and prompts the decision to scrub them.

#### `rumBrowserSdk` option

By default, no browser-sdk runs in the main window renderer. Tests that need real user-activity tracking (e.g. session renewal via click) opt in per-describe or per file:
Expand Down
26 changes: 23 additions & 3 deletions e2e/integration/lib/integrationFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { Intake } from '../../lib/intake';
import { Intake, registerSdkPaths } from '../../lib/intake';
import { TestServer } from '../../lib/testServer';
import { assertExpectedElectronVersion, getCompatibilityRun } from '../../lib/compatibility';
import type { IntegrationApp, IntegrationMode, IntegrationVariant } from '../../playwright.config';
Expand Down Expand Up @@ -42,7 +42,11 @@ export const test = base.extend<IntegrationFixtures>({
const intake = new Intake();
await intake.start();
await use(intake);
await intake.stop();
try {
intake.assertNoSdkPath();
} finally {
await intake.stop();
}
},
{ option: true },
],
Expand Down Expand Up @@ -87,7 +91,23 @@ export async function launchApp(
userDataDir: string,
variant: IntegrationVariant = null
): Promise<ElectronApplication> {
const config = buildSdkConfig(intake);
const electronApp = await launchAppProcess(appDir, mode, buildSdkConfig(intake), userDataDir, variant);
try {
await registerSdkPaths(intake, electronApp);
return electronApp;
} catch (error) {
await electronApp.close();
throw error;
}
}

async function launchAppProcess(
appDir: string,
mode: IntegrationMode,
config: InitConfiguration,
userDataDir: string,
variant: IntegrationVariant
): Promise<ElectronApplication> {
const userDataArgs = [`--user-data-dir=${userDataDir}`];

if (mode === 'packaged') {
Expand Down
9 changes: 7 additions & 2 deletions e2e/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test as base, _electron as electron, type ElectronApplication, type Pag
import { join } from 'node:path';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { Intake } from './intake';
import { Intake, registerSdkPaths } from './intake';
import { TestServer } from './testServer';
import { MainPage } from './mainPage';
import { assertExpectedElectronVersion, getCompatibilityRun } from './compatibility';
Expand Down Expand Up @@ -60,7 +60,11 @@ export const test = base.extend<TestFixtures>({
await intake.start();
intake.setQuotaResponse(initialIntakeQuotaDecision);
await use(intake);
await intake.stop();
try {
intake.assertNoSdkPath();
} finally {
await intake.stop();
}
},
{ option: true },
],
Expand Down Expand Up @@ -164,6 +168,7 @@ async function launchApp(
});
try {
await assertExpectedElectronVersion(electronApp);
await registerSdkPaths(intake, electronApp);
return electronApp;
} catch (error) {
await electronApp.close();
Expand Down
161 changes: 144 additions & 17 deletions e2e/lib/intake.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as http from 'node:http';
import { realpathSync } from 'node:fs';
import zlib from 'node:zlib';
import type { ElectronApplication } from '@playwright/test';
import type {
LogsEvent,
RumActionEvent,
Expand Down Expand Up @@ -86,6 +88,12 @@ export interface ProfilingRequest {
headers: Record<string, string>;
}

interface PayloadText {
/** Intake route the payload came from (e.g. `/api/v2/rum`), to name it in a failure. */
source: string;
text: string;
}

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

/**
Expand Down Expand Up @@ -176,6 +184,8 @@ export class Intake {
private replaySegments: ReplaySegment[] = [];
private traces: Trace[] = [];
private profilingRequests: ProfilingRequest[] = [];
private payloadTexts: PayloadText[] = [];
private sdkPaths = new Set<string>();
private port = 0;
private quotaDecision: 'quota_ok' | 'quota_ko' = 'quota_ok';

Expand All @@ -191,7 +201,7 @@ export class Intake {
}
}

private storeReplaySegment(rawBody: Buffer, headers: Record<string, string>) {
private storeReplaySegment(rawBody: Buffer, headers: Record<string, string>, source: string) {
const contentType = headers['content-type'] ?? '';
const boundaryMatch = /boundary=([^\s;]+)/.exec(contentType);
if (!boundaryMatch) return;
Expand All @@ -209,13 +219,10 @@ export class Intake {
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);
}
const body = stripTrailingCrlf(part.subarray(headerEnd + 4));

if (partHeaders.includes('name="event"')) {
this.payloadTexts.push({ source, text: body.toString('utf8') });
try {
metadata = JSON.parse(body.toString('utf8')) as Record<string, unknown>;
} catch {
Expand All @@ -231,21 +238,43 @@ export class Intake {
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
const inflated = inflateSegment(compressed);
if (inflated !== undefined) {
this.payloadTexts.push({ source, text: inflated });
try {
const parsed = JSON.parse(inflated) as { records?: unknown[] };
segment.records = parsed.records ?? [];
} catch {
// segment blob is not JSON: leave records undefined
}
}
}

this.replaySegments.push(segment);
}

private storeProfilePayloads(rawBody: Buffer, contentType: string, source: string) {
const boundary = /boundary=([^\s;]+)/.exec(contentType)?.[1];
if (!boundary) return;

for (const part of splitBuffer(rawBody, Buffer.from(`--${boundary}`))) {
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;

const partHeaders = part.subarray(0, headerEnd).toString('utf8');
const body = stripTrailingCrlf(part.subarray(headerEnd + 4));
if (partHeaders.includes('name="event"')) {
this.payloadTexts.push({ source, text: body.toString('utf8') });
} else if (partHeaders.includes('name="wall-time.json"')) {
try {
this.payloadTexts.push({ source, text: zlib.inflateSync(body).toString('utf8') });
} catch {
// not a deflated profile: nothing to scan
}
}
}
}

/**
* Logs live in their own store rather than in `rumEvents`: they arrive on a different track and
* carry no `type` discriminator, so the `EventBodyByType` reads cannot select them.
Expand Down Expand Up @@ -300,12 +329,12 @@ export class Intake {
});

if (ddforward.startsWith('/api/v2/profile')) {
req.resume();
req.on('end', () => {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') headers[key.toLowerCase()] = value;
}
this.storeProfilePayloads(Buffer.concat(chunks), req.headers['content-type'] ?? '', ddforward);
this.profilingRequests.push({
timestamp: Date.now(),
contentType: req.headers['content-type'] ?? '',
Expand All @@ -332,12 +361,13 @@ export class Intake {
const isMultipart = (headers['content-type'] ?? '').includes('multipart/form-data');

if (ddforward.startsWith('/api/v2/replay') || isMultipart) {
this.storeReplaySegment(rawBody, headers);
this.storeReplaySegment(rawBody, headers, ddforward);
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'accepted' }));
return;
}

this.payloadTexts.push({ source: ddforward, text: rawBody.toString() });
try {
const parsedBody: unknown = JSON.parse(rawBody.toString());
if (ddforward.startsWith('/api/v2/spans')) {
Expand Down Expand Up @@ -595,7 +625,104 @@ export class Intake {
this.quotaDecision = 'quota_ok';
}

registerSdkPaths(paths: string[]): void {
for (const path of paths) {
this.sdkPaths.add(path);
try {
this.sdkPaths.add(realpathSync(path));
} catch {
// not on disk (e.g. crashDumps before the first crash): the declared path is enough
}
}
}

/** Fails if any string value of any captured payload contains a registered SDK path, in any notation. */
assertNoSdkPath(): void {
const forms = [...this.sdkPaths].flatMap((path) => [path, path.replace(/\\/g, '/')]).map(normalizeCase);
const leaks: string[] = [];
for (const { source, text } of this.payloadTexts) {
for (const value of stringValues(text)) {
const candidates = [value, safeDecodeURI(value)].map(normalizeCase);
const leaked = forms.find((form) => candidates.some((candidate) => candidate.includes(form)));
if (leaked) {
leaks.push(`${source}: ${leaked} in ${JSON.stringify(value.slice(0, 200))}`);
}
}
}
if (leaks.length > 0) {
throw new Error(`Payloads contain absolute SDK paths:\n${leaks.join('\n')}`);
}
}

getPort(): number {
return this.port;
}
}

/** Records the SDK paths of a launched app, so the intake can check no payload leaks them. */
export async function registerSdkPaths(intake: Intake, electronApp: ElectronApplication): Promise<void> {
const paths = await electronApp.evaluate(({ app }) =>
[() => app.getAppPath(), () => app.getPath('userData'), () => app.getPath('crashDumps')].flatMap((read) => {
try {
return [read()];
} catch {
return [];
}
})
);
intake.registerSdkPaths(paths);
}

// The first segment is a self-contained ZLIB stream. A continuation has a prepended header and a cumulative
// Adler-32 that a lone stream rejects, but its body is raw deflate once the header and trailer are stripped.
function inflateSegment(compressed: Buffer): string | undefined {
try {
return zlib.inflateSync(compressed).toString('utf8');
} catch {
// not a standalone ZLIB stream: try as a continuation segment
}
try {
return zlib.inflateRawSync(compressed.subarray(2, -4)).toString('utf8');
} catch {
return undefined;
}
}

function stripTrailingCrlf(body: Buffer): Buffer {
return body.length >= 2 && body[body.length - 2] === 0x0d && body[body.length - 1] === 0x0a
? body.subarray(0, body.length - 2)
: body;
}

// Windows paths are case-insensitive.
function normalizeCase(value: string): string {
return process.platform === 'win32' ? value.toLowerCase() : value;
}

// Every string value of a JSON (or NDJSON) payload; a non-JSON payload is checked as a whole.
function stringValues(text: string): string[] {
const values: string[] = [];
const visit = (value: unknown) => {
if (typeof value === 'string') {
values.push(value);
} else if (value && typeof value === 'object') {
Object.values(value).forEach(visit);
}
};
for (const line of text.split('\n').filter(Boolean)) {
try {
visit(JSON.parse(line));
} catch {
values.push(line);
}
}
return values;
}

function safeDecodeURI(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
2 changes: 2 additions & 0 deletions e2e/scenarios/error.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ test('emits an error event on uncaught exception', async ({ mainPage, intake })
expect(error.error.handling).toBe('unhandled');
expect(error.error.type).toBe('Error');
expect(error.error.stack).toBeDefined();
// The app path is scrubbed to `/`: main-process frames are root-relative, matching uploaded source maps.
expect(error.error.stack).toMatch(/\(\/main\.js:\d+:\d+\)/);
expect(error.error.id).toBeDefined();
expect(error.session.id).toBe(view.session.id);
expect(error.view.id).toBe(view.view.id);
Expand Down
Loading
Loading