From c0010352fdfd05db13033f9079b2e3125d5f1036 Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Mon, 1 Jun 2026 15:34:03 +0100 Subject: [PATCH 1/6] Refactor transport to make BatchProducer/Consumer extensible for new transport strategies - Extract abstract base classes from BatchProducer and BatchConsumer, moving the concrete JSON-NDJSON / JSON-array-POST logic into GenericBatchProducer and GenericBatchConsumer under a new `generic/` sub-package. - Introduce BatchFactory to own producer/consumer creation, so BatchManager stays decoupled from concrete implementations. Future tracks can add a peer package and a dispatch case in BatchFactory without touching the scheduling or the file-rotation logic. --- src/transport/batch/BatchFactory.ts | 49 ++++++++++++++ .../GenericBatchConsumer.spec.ts} | 26 +++++++- .../batch/generic/GenericBatchConsumer.ts | 57 ++++++++++++++++ .../batch/generic/GenericBatchProducer.ts | 38 +++++++++++ .../standard/StandardBatchProducer.spec.ts | 66 +++++++++++++++++++ src/transport/batch/types.ts | 34 ++++++++++ 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/transport/batch/BatchFactory.ts rename src/transport/batch/{BatchConsumer.spec.ts => generic/GenericBatchConsumer.spec.ts} (77%) create mode 100644 src/transport/batch/generic/GenericBatchConsumer.ts create mode 100644 src/transport/batch/generic/GenericBatchProducer.ts create mode 100644 src/transport/batch/types.ts diff --git a/src/transport/batch/BatchFactory.ts b/src/transport/batch/BatchFactory.ts new file mode 100644 index 00000000..c6311bbd --- /dev/null +++ b/src/transport/batch/BatchFactory.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; +import type { Configuration } from '../../config'; +import { computeIntakeUrlForTrack } from '../utils'; +import { GenericBatchConsumer } from './generic/GenericBatchConsumer'; +import { GenericBatchProducer } from './generic/GenericBatchProducer'; +import type { BatchConsumerConfig, BatchConfig, BatchProducerConfig } from './types'; +import type { BatchConsumer } from './BatchConsumer'; +import type { BatchProducer } from './BatchProducer'; + +/** + * Factory that creates a matched {@link BatchProducer} / {@link BatchConsumer} pair + * for a given track type and configuration. + * + * Add a new `create*Batch` private method here when introducing a new transport + * strategy (e.g. session replay, profiling) and dispatch to it from {@link create}. + */ +export class BatchFactory { + /** + * Creates and initializes the appropriate producer/consumer pair for the given + * {@link BatchConfig.trackType}. + */ + static async create( + config: Configuration, + batchConfig: BatchConfig + ): Promise<{ producer: BatchProducer; consumer: BatchConsumer }> { + const { clientToken } = config; + const { path: configPath, trackType, batchSize } = batchConfig; + + const trackPath = path.join(configPath, trackType); + const intakeUrl = computeIntakeUrlForTrack(config.site, trackType, config.proxy); + + // TODO: handle other batch types (e.g. session replay) + return BatchFactory.createGenericBatch({ trackPath, batchSize }, { trackPath, intakeUrl, clientToken }); + } + + /** + * Builds a JSON-NDJSON producer paired with a JSON-array-POST consumer. + * Used for standard event tracks (RUM, logs, etc.). + */ + private static async createGenericBatch( + producerConfig: BatchProducerConfig, + consumerConfig: BatchConsumerConfig + ): Promise<{ producer: BatchProducer; consumer: BatchConsumer }> { + const producer = await GenericBatchProducer.create(producerConfig); + const consumer = new GenericBatchConsumer(consumerConfig); + + return { producer, consumer }; + } +} diff --git a/src/transport/batch/BatchConsumer.spec.ts b/src/transport/batch/generic/GenericBatchConsumer.spec.ts similarity index 77% rename from src/transport/batch/BatchConsumer.spec.ts rename to src/transport/batch/generic/GenericBatchConsumer.spec.ts index 79a87df6..3dc4dc6e 100644 --- a/src/transport/batch/BatchConsumer.spec.ts +++ b/src/transport/batch/generic/GenericBatchConsumer.spec.ts @@ -1,11 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +<<<<<<< HEAD:src/transport/batch/BatchConsumer.spec.ts import { BatchConsumer } from './BatchConsumer'; import type { BatchConsumerConfig } from './BatchConsumer'; import { getUserAgent } from '../userAgent'; import { mockFs } from '../../mocks.specUtil'; +======= +import { GenericBatchConsumer } from './GenericBatchConsumer'; +import type { BatchConsumerConfig as ConsumerConfig } from '../types'; +import type { BatchConsumer } from '../BatchConsumer'; +import path from 'node:path'; +import { getUserAgent } from '../../userAgent'; +import { mockFs } from '../../../mocks.specUtil'; +>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchConsumer.spec.ts vi.mock('node:fs/promises'); -vi.mock('../userAgent'); +vi.mock('../../userAgent'); const fsMocks = mockFs(); const TEST_USER_AGENT = 'TestApp/1.0.0'; @@ -15,11 +24,20 @@ const TEST_REQUEST = new Request('https://intake.datadoghq.com/api/v2/rum', { body: '[]', }); +<<<<<<< HEAD:src/transport/batch/BatchConsumer.spec.ts const config: BatchConsumerConfig = { trackPath: '/mock/track', intakeUrl: 'https://intake.datadoghq.com/api/v2/rum', clientToken: 'test-token', }; +======= +describe('GenericBatchConsumer', () => { + const config: ConsumerConfig = { + trackPath: 'rum', + intakeUrl: 'https://intake.datadoghq.com/api/v2/rum', + clientToken: 'test-client-token', + }; +>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchConsumer.spec.ts /** Minimal concrete subclass for testing the base-class upload logic. */ class TestConsumer extends BatchConsumer { @@ -39,7 +57,13 @@ describe('BatchConsumer — upload/send/delete behaviour', () => { beforeEach(() => { fsMocks.reset(); vi.mocked(getUserAgent).mockReset().mockReturnValue(TEST_USER_AGENT); +<<<<<<< HEAD:src/transport/batch/BatchConsumer.spec.ts global.fetch = vi.fn().mockResolvedValue({ ok: true }); +======= + consumer = new GenericBatchConsumer(config); + + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); +>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchConsumer.spec.ts fsMocks.access.mockResolvedValue(undefined); fsMocks.unlink.mockResolvedValue(undefined); }); diff --git a/src/transport/batch/generic/GenericBatchConsumer.ts b/src/transport/batch/generic/GenericBatchConsumer.ts new file mode 100644 index 00000000..45d995e5 --- /dev/null +++ b/src/transport/batch/generic/GenericBatchConsumer.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs/promises'; +import { BatchConsumer } from '../BatchConsumer'; + +/** + * Concrete {@link BatchConsumer} for standard JSON event tracks (RUM, logs, etc.). + * + * Reads newline-delimited JSON from each `.log` batch file, assembles the + * parsed events into a JSON array, and POSTs it to the Datadog intake endpoint. + * Successfully uploaded files are deleted from disk. + */ +export class GenericBatchConsumer extends BatchConsumer { + protected async uploadBatch(filePath: string): Promise { + const lines = await this.readBatchFile(filePath); + + if (lines.length === 0) { + try { + await fs.unlink(filePath); + } catch { + // Ignore deletion errors for empty files + } + return true; + } + + const events = lines + .map((line) => { + try { + return JSON.parse(line) as unknown; + } catch { + return null; + } + }) + .filter((item) => item !== null); + + const body = JSON.stringify(events); + + try { + const response = await fetch(this.intakeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': this.clientToken, + 'User-Agent': this.userAgent!, + }, + body, + }); + + if (response.ok) { + await fs.unlink(filePath); + return true; + } + + return false; + } catch { + return false; + } + } +} diff --git a/src/transport/batch/generic/GenericBatchProducer.ts b/src/transport/batch/generic/GenericBatchProducer.ts new file mode 100644 index 00000000..1e6c18c6 --- /dev/null +++ b/src/transport/batch/generic/GenericBatchProducer.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs/promises'; +import { BatchProducer } from '../BatchProducer'; +import type { BatchProducerConfig } from '../types'; + +/** + * Concrete {@link BatchProducer} that serializes events as newline-delimited JSON + * and appends them to `.tmp` batch files on disk. + */ +export class GenericBatchProducer extends BatchProducer { + /** + * Creates and fully initializes a GenericBatchProducer. + * Ensures the track directory exists and rotates any orphaned `.tmp` files + * left from previous sessions. + */ + static async create(config: BatchProducerConfig): Promise { + const producer = new GenericBatchProducer(config); + await producer.ensureTrackDirectoryExists(); + await producer.rotateOrphanedBatches(); + + return producer; + } + + /** Serializes data as a JSON line and appends it to the current batch file, rotating first if the size limit would be exceeded. */ + async writeData(data: unknown) { + await this.ensureTrackDirectoryExists(); + + const serialized = `${JSON.stringify(data)}\n`; + const dataSize = Buffer.byteLength(serialized, 'utf8'); + + if (this.currentBatchSize + dataSize > this.batchSize && this.currentBatchSize > 0) { + await this.rotateBatch(); + } + + const batchPath = this.getCurrentBatchPath(); + await fs.appendFile(batchPath, serialized, 'utf8'); + this.currentBatchSize += dataSize; + } +} diff --git a/src/transport/batch/standard/StandardBatchProducer.spec.ts b/src/transport/batch/standard/StandardBatchProducer.spec.ts index dee65bea..28d5d209 100644 --- a/src/transport/batch/standard/StandardBatchProducer.spec.ts +++ b/src/transport/batch/standard/StandardBatchProducer.spec.ts @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts import type { StandardBatchProducerConfig as ProducerConfig } from './StandardBatchProducer'; import { mockFs } from '../../../mocks.specUtil'; import { StandardBatchProducer } from './StandardBatchProducer'; import { display } from '../../../tools/display'; +======== +import type { BatchProducerConfig as ProducerConfig } from '../types'; +import { mockFs } from '../../../mocks.specUtil'; +import { GenericBatchProducer } from './GenericBatchProducer'; +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts vi.mock('node:fs/promises'); vi.mock('../../../tools/display', () => ({ @@ -45,7 +51,11 @@ describe('StandardBatchProducer', () => { it('creates track directory when missing', async () => { fsMocks.access.mockRejectedValueOnce(new Error('ENOENT')); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts await StandardBatchProducer.create(config); +======== + await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts expect(fsMocks.mkdir).toHaveBeenCalledWith(config.trackPath, { recursive: true }); }); @@ -53,7 +63,11 @@ describe('StandardBatchProducer', () => { it('does not create directory when it exists', async () => { fsMocks.access.mockResolvedValueOnce(undefined); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts await StandardBatchProducer.create(config); +======== + await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts expect(fsMocks.mkdir).not.toHaveBeenCalled(); }); @@ -61,7 +75,11 @@ describe('StandardBatchProducer', () => { it('rotates orphaned .tmp files from previous sessions to .log', async () => { fsMocks.readdir.mockResolvedValueOnce(['batch-111.tmp', 'batch-222.tmp']); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts await StandardBatchProducer.create(config); +======== + await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts expect(fsMocks.rename).toHaveBeenCalledWith( path.join(config.trackPath, 'batch-111.tmp'), @@ -76,7 +94,11 @@ describe('StandardBatchProducer', () => { it('does not rename non-.tmp files when rotating orphaned batches', async () => { fsMocks.readdir.mockResolvedValueOnce(['batch-111.log', 'other.txt', 'batch-222.tmp']); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts await StandardBatchProducer.create(config); +======== + await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts expect(fsMocks.rename).toHaveBeenCalledTimes(1); expect(fsMocks.rename).toHaveBeenCalledWith( @@ -88,21 +110,33 @@ describe('StandardBatchProducer', () => { it('handles readdir failure gracefully when rotating orphaned batches', async () => { fsMocks.readdir.mockRejectedValueOnce(new Error('ENOENT')); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts await expect(StandardBatchProducer.create(config)).resolves.toBeDefined(); +======== + await expect(GenericBatchProducer.create(config)).resolves.toBeDefined(); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts }); it('handles individual rename failure gracefully when rotating orphaned batches', async () => { fsMocks.readdir.mockResolvedValueOnce(['batch-111.tmp', 'batch-222.tmp']); fsMocks.rename.mockRejectedValueOnce(new Error('rename failed')); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts await expect(StandardBatchProducer.create(config)).resolves.toBeDefined(); +======== + await expect(GenericBatchProducer.create(config)).resolves.toBeDefined(); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts expect(fsMocks.rename).toHaveBeenCalledTimes(2); }); }); describe('post() + write queue', () => { it('serializes each post as JSON + newline and appends to the same .tmp file until rotation', async () => { +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { a: 1 } }); producer.post({ data: { b: 2 } }); @@ -116,7 +150,11 @@ describe('StandardBatchProducer', () => { }); it('writes posts in call order', async () => { +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { order: 1 } }); producer.post({ data: { order: 2 } }); @@ -135,7 +173,11 @@ describe('StandardBatchProducer', () => { vi.mocked(display.error).mockClear(); fsMocks.appendFile.mockRejectedValueOnce(new Error('write failed')).mockResolvedValueOnce(undefined); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { bad: true } }); producer.post({ data: { good: true } }); @@ -148,7 +190,11 @@ describe('StandardBatchProducer', () => { describe('rotation behavior', () => { it('flush() renames current batch from .tmp to .log', async () => { +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { event: 'test' } }); await producer.flush(); @@ -160,7 +206,11 @@ describe('StandardBatchProducer', () => { }); it('flush() does nothing if no data was ever written', async () => { +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts await producer.flush(); @@ -171,7 +221,11 @@ describe('StandardBatchProducer', () => { it('rotates due to size limit BEFORE appending when current batch already has data', async () => { const small = makeConfig({ batchSize: 20 }); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(small); +======== + const producer = await GenericBatchProducer.create(small); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts const { dateNow } = await import('@datadog/js-core/time'); vi.mocked(dateNow) @@ -201,7 +255,11 @@ describe('StandardBatchProducer', () => { fsMocks.rename.mockRejectedValueOnce(new Error('rename failed')); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { first: true } }); await producer.flush(); @@ -224,7 +282,11 @@ describe('StandardBatchProducer', () => { // So we make create succeed and then fail for the subsequent access. fsMocks.access.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('ENOENT')); +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { event: 'test' } }); await producer.flush(); @@ -233,7 +295,11 @@ describe('StandardBatchProducer', () => { }); it('does not mkdir when access succeeds during a write', async () => { +<<<<<<<< HEAD:src/transport/batch/standard/StandardBatchProducer.spec.ts const producer = await StandardBatchProducer.create(config); +======== + const producer = await GenericBatchProducer.create(config); +>>>>>>>> fb1f12c (Refactor transport to make BatchProducer/Consumer extensible for new transport strategies):src/transport/batch/generic/GenericBatchProducer.spec.ts producer.post({ data: { event: 'test' } }); await producer.flush(); diff --git a/src/transport/batch/types.ts b/src/transport/batch/types.ts new file mode 100644 index 00000000..97a76b58 --- /dev/null +++ b/src/transport/batch/types.ts @@ -0,0 +1,34 @@ +import { EventTrack } from '../../event'; + +/** + * Top-level configuration for a single batch track managed by {@link BatchManager}. + * Combines producer, consumer, and scheduling parameters. + */ +export interface BatchConfig { + /** Base directory under which per-track subdirectories are created. */ + path: string; + /** The event track this batch pipeline serves (e.g. RUM, session replay). */ + trackType: EventTrack; + /** Maximum byte size of a single batch file before it is rotated. */ + batchSize: number; + /** Milliseconds between upload cycles. */ + uploadFrequency: number; +} + +/** Configuration for a {@link BatchProducer} instance. */ +export interface BatchProducerConfig { + /** Absolute path to the directory where batch files are written. */ + trackPath: string; + /** Maximum byte size of a single batch file before it is rotated. */ + batchSize: number; +} + +/** Configuration for a {@link BatchConsumer} instance. */ +export interface BatchConsumerConfig { + /** Absolute path to the directory where batch files are read from. */ + trackPath: string; + /** Full intake URL to POST batch data to. */ + intakeUrl: string; + /** Datadog client token sent as `DD-API-KEY`. */ + clientToken: string; +} From 83ebe09a829db08b4e742f3a1ad5e6e88395f8a7 Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Mon, 1 Jun 2026 17:45:31 +0100 Subject: [PATCH 2/6] Integrate session replay into the transport pipeline Implement end-to-end session replay for Electron: BrowserRecord events from renderer processes are buffered into Segments with per-session ZLIB compression, then persisted to disk via a dedicated batch pipeline and uploaded as multipart/form-data to the replay intake. --- src/assembly/MainAssembly.ts | 5 +- src/assembly/RendererPipeline.ts | 25 ++- src/config.ts | 22 +- src/domain/replay/ReplayCollection.ts | 189 ++++++++++++++++++ src/domain/replay/Segment.ts | 131 ++++++++++++ src/domain/replay/StreamingDeflate.ts | 133 ++++++++++++ src/domain/replay/index.ts | 3 + src/event/event.constants.ts | 2 + src/event/event.types.ts | 24 ++- src/index.ts | 26 ++- src/mocks.specUtil.ts | 1 + src/transport/Transport.spec.ts | 3 +- src/transport/Transport.ts | 4 + src/transport/batch/BatchFactory.ts | 27 ++- .../batch/replay/ReplayBatchConsumer.ts | 60 ++++++ .../batch/replay/ReplayBatchProducer.ts | 47 +++++ 16 files changed, 683 insertions(+), 19 deletions(-) create mode 100644 src/domain/replay/ReplayCollection.ts create mode 100644 src/domain/replay/Segment.ts create mode 100644 src/domain/replay/StreamingDeflate.ts create mode 100644 src/domain/replay/index.ts create mode 100644 src/transport/batch/replay/ReplayBatchConsumer.ts create mode 100644 src/transport/batch/replay/ReplayBatchProducer.ts diff --git a/src/assembly/MainAssembly.ts b/src/assembly/MainAssembly.ts index e9a866a1..100fd0a4 100644 --- a/src/assembly/MainAssembly.ts +++ b/src/assembly/MainAssembly.ts @@ -9,6 +9,7 @@ import { EventTrack, type RawEvent, type RawProfileEvent, + type RawReplayEvent, type ServerEvent, } from '../event'; import type { FormatHooks } from './hooks'; @@ -16,7 +17,7 @@ import { RumEvent } from '../domain/rum'; import { TelemetryEvent } from '../domain/telemetry'; // Raw events assembled through the standard main-process hook pipeline. -type StandardRawEvent = Exclude; +type StandardRawEvent = Exclude; /** * Transforms main-process RawEvents into ServerEvents by enriching them with @@ -29,7 +30,7 @@ export class MainAssembly { ) { this.eventManager.registerHandler({ canHandle: (event): event is StandardRawEvent => - event.kind === EventKind.RAW && event.format !== EventFormat.PROFILE, + event.kind === EventKind.RAW && event.format !== EventFormat.PROFILE && event.format !== EventFormat.REPLAY, handle: (event, notify) => { const result = this.assembleMainProcessEvent(event); if (result !== DISCARDED) { diff --git a/src/assembly/RendererPipeline.ts b/src/assembly/RendererPipeline.ts index 6145d7fb..78918ca5 100644 --- a/src/assembly/RendererPipeline.ts +++ b/src/assembly/RendererPipeline.ts @@ -3,7 +3,13 @@ import { type TimeStamp } from '@datadog/js-core/time'; import { combine, isIndexableObject, type RecursivePartial } from '@datadog/js-core/util'; import { DISCARDED } from '@datadog/js-core/assembly'; import { EventKind, EventSource, EventTrack, LifecycleKind, EventFormat } from '../event'; -import type { EventManager, ServerRumEvent, BrowserProfileEvent, BrowserProfilerTrace } from '../event'; +import type { + EventManager, + ServerRumEvent, + BrowserProfileEvent, + BrowserProfilerTrace, + RawReplayEvent, +} from '../event'; import { isEmptyObject } from '@datadog/browser-core'; import { monitor, addError as addTelemetryError } from '../domain/telemetry'; import { BRIDGE_CHANNEL, setBridgeConfig, type BridgeOptions } from '../common'; @@ -11,11 +17,12 @@ import type { FormatHooks } from './hooks'; import type { RumEvent } from '../domain/rum'; import { Configuration } from '../config'; -type BridgeEventType = 'rum' | 'log' | 'internal_telemetry' | 'profile'; +type BridgeEventType = 'rum' | 'log' | 'internal_telemetry' | 'profile' | 'record'; interface BridgeEvent { eventType: BridgeEventType; event: unknown; + view?: { id: string }; } /** @@ -42,7 +49,10 @@ export class RendererPipeline { // Capabilities are resolved once here and advertised globally, not per session. Bridge mode has no // channel to notify the renderer on session renew/expire or capability changes, so the browser SDK // cannot adjust its per-session behavior (e.g. stop profiling a sampled-out session). Out of scope for now. - capabilities: config.profilingSampleRate > 0 ? ['profiles'] : [], + capabilities: [ + ...(config.profilingSampleRate > 0 ? ['profiles'] : []), + ...(config.sessionReplaySampleRate > 0 ? ['records'] : []), + ], }; ipcMain.on( @@ -94,6 +104,15 @@ export class RendererPipeline { }); break; } + case 'record': + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.RENDERER, + format: EventFormat.REPLAY, + data: bridgeEvent.event, + view: bridgeEvent.view, + } as RawReplayEvent); + break; default: addTelemetryError(new Error(`Unhandled bridge event type: ${String(bridgeEvent.eventType)}`)); } diff --git a/src/config.ts b/src/config.ts index 83f3c275..2efa3d60 100644 --- a/src/config.ts +++ b/src/config.ts @@ -37,6 +37,7 @@ export interface InitConfiguration { env?: string; version?: string; sessionSampleRate?: number; + sessionReplaySampleRate?: number; profilingSampleRate?: number; telemetrySampleRate?: number; batchSize?: BatchSize; @@ -54,6 +55,7 @@ export interface Configuration { version?: string; proxy?: string; sessionSampleRate: number; + sessionReplaySampleRate: number; profilingSampleRate: number; telemetrySampleRate: number; batchSize?: BatchSize; @@ -101,6 +103,17 @@ function validateSessionSampleRate(value: unknown): number | undefined { return value as number; } +function validateSessionReplaySampleRate(value: unknown): number | undefined { + if (value === undefined || value === null) { + return 0; + } + if (!Number.isFinite(value) || (value as number) < 0 || (value as number) > 100) { + display.error("Configuration error: 'sessionReplaySampleRate' must be a number between 0 and 100"); + return undefined; + } + return value as number; +} + function validateProfilingSampleRate(value: unknown): number | undefined { if (value === undefined || value === null) { return 0; @@ -163,10 +176,16 @@ export function buildConfiguration(initConfig: InitConfiguration): Configuration const proxy = validateOptionalString(initConfig.proxy); const sessionSampleRate = validateSessionSampleRate(initConfig.sessionSampleRate); + const sessionReplaySampleRate = validateSessionReplaySampleRate(initConfig.sessionReplaySampleRate); const profilingSampleRate = validateProfilingSampleRate(initConfig.profilingSampleRate); const telemetrySampleRate = validateTelemetrySampleRate(initConfig.telemetrySampleRate); - if (sessionSampleRate === undefined || profilingSampleRate === undefined || telemetrySampleRate === undefined) { + if ( + sessionSampleRate === undefined || + sessionReplaySampleRate === undefined || + profilingSampleRate === undefined || + telemetrySampleRate === undefined + ) { return undefined; } @@ -179,6 +198,7 @@ export function buildConfiguration(initConfig: InitConfiguration): Configuration version: validateOptionalString(initConfig.version), proxy, sessionSampleRate, + sessionReplaySampleRate, profilingSampleRate, telemetrySampleRate, defaultPrivacyLevel: validateDefaultPrivacyLevel(initConfig.defaultPrivacyLevel), diff --git a/src/domain/replay/ReplayCollection.ts b/src/domain/replay/ReplayCollection.ts new file mode 100644 index 00000000..57c82962 --- /dev/null +++ b/src/domain/replay/ReplayCollection.ts @@ -0,0 +1,189 @@ +import { ONE_SECOND } from '@datadog/browser-core'; +import { EventFormat, EventKind, EventTrack, LifecycleKind } from '../../event'; +import type { EventManager, RawReplayEvent, LifecycleEvent } from '../../event'; +import type { Configuration } from '../../config'; +import type { SessionManager } from '../session'; +import { monitor } from '../telemetry'; +import { Segment, type BrowserRecord, type CreationReason, type SegmentContext } from './Segment'; +import { StreamingDeflate } from './StreamingDeflate'; +import { correctedChildSampleRate, isSessionSampled } from '../../tools/Sampler'; + +const SEGMENT_DURATION_LIMIT = 5 * ONE_SECOND; +const SEGMENT_BYTES_LIMIT = 60_000; + +/** + * Orchestrates session replay segment collection in the main process. + * + * Listens for individual BrowserRecord events from renderer processes (via + * the bridge), buffers them into {@link Segment} objects with proper context, + * and emits compressed {@link ServerReplayEvent}s for the transport layer to + * persist and upload. + * + * Segments are flushed when: + * - Duration limit (5s) is reached + * - Estimated byte size limit (60KB) is exceeded + * - The renderer view changes (different view.id) + * - The session expires or renews + */ +export interface ViewReplayStats { + segments_count: number; + segments_total_raw_size: number; +} + +export class ReplayCollection { + private segment: Segment | null = null; + private currentViewId: string | undefined; + private nextCreationReason: CreationReason = 'init'; + private segmentIndexPerView = new Map(); + private viewReplayStats = new Map(); + private flushTimeoutId: ReturnType | null = null; + // One persistent deflate stream per session — required so the backend can + // stitch all segments into a single valid ZLIB stream for the replay player. + private deflate = new StreamingDeflate(); + // Tracks the last async compress+notify so stop() can await it. + private pendingFlush: Promise = Promise.resolve(); + + constructor( + private readonly eventManager: EventManager, + private readonly config: Configuration, + private readonly sessionManager: SessionManager + ) { + this.eventManager.registerHandler({ + canHandle: (event): event is RawReplayEvent => + event.kind === EventKind.RAW && 'format' in event && event.format === EventFormat.REPLAY, + handle: monitor((event: RawReplayEvent) => { + this.onRecord(event.data as BrowserRecord, event.view?.id); + }), + }); + + this.eventManager.registerHandler({ + canHandle: (event): event is LifecycleEvent => event.kind === EventKind.LIFECYCLE, + handle: monitor((event: LifecycleEvent) => { + if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) { + this.flush('session_renew'); + } else if (event.lifecycle === LifecycleKind.SESSION_RENEW) { + this.nextCreationReason = 'session_renew'; + } + }), + }); + } + + private isReplaySampled(sessionId: string): boolean { + return isSessionSampled( + sessionId, + correctedChildSampleRate(this.config.sessionSampleRate, this.config.sessionReplaySampleRate) + ); + } + + private onRecord(record: BrowserRecord, viewId: string | undefined): void { + // Detect view change + if (viewId && this.currentViewId && viewId !== this.currentViewId) { + this.flush('view_change'); + } + + if (viewId) { + this.currentViewId = viewId; + } + + if (!this.segment) { + const context = this.getSegmentContext(); + if (!context) { + return; + } + + const indexInView = this.getNextSegmentIndex(context.view.id); + this.segment = new Segment(context, this.nextCreationReason, indexInView); + this.nextCreationReason = 'init'; + this.scheduleFlush(); + } + + this.segment.addRecord(record); + + if (this.segment.estimatedSize > SEGMENT_BYTES_LIMIT) { + this.flush('segment_bytes_limit'); + } + } + + private getSegmentContext(): SegmentContext | undefined { + const session = this.sessionManager.getSession(); + if (session.status !== 'active' || !this.currentViewId || !this.isReplaySampled(session.id)) { + return undefined; + } + + return { + application: { id: this.config.applicationId }, + session: { id: session.id }, + view: { id: this.currentViewId }, + }; + } + + private getNextSegmentIndex(viewId: string): number { + const current = this.segmentIndexPerView.get(viewId) ?? 0; + this.segmentIndexPerView.set(viewId, current + 1); + return current; + } + + getViewReplayStats(viewId: string): ViewReplayStats | undefined { + return this.viewReplayStats.get(viewId); + } + + private flush(reason: CreationReason): void { + this.clearFlushTimeout(); + + if (this.segment && !this.segment.isEmpty) { + const flushResult = this.segment.flush(); + const viewId = flushResult.metadata.view.id; + const current = this.viewReplayStats.get(viewId) ?? { segments_count: 0, segments_total_raw_size: 0 }; + this.viewReplayStats.set(viewId, { + segments_count: current.segments_count + 1, + segments_total_raw_size: current.segments_total_raw_size + flushResult.rawBytesCount, + }); + + const data = Buffer.from(flushResult.serializedSegment, 'utf8'); + this.pendingFlush = this.deflate.compressSegment(data).then((compressed) => { + this.eventManager.notify({ + kind: EventKind.SERVER, + track: EventTrack.REPLAY, + data: { + metadata: flushResult.metadata, + rawBytesCount: flushResult.rawBytesCount, + compressed, + }, + }); + }); + } + + this.segment = null; + this.nextCreationReason = reason; + + // New session gets a fresh deflate context so its segments form an + // independent ZLIB stream. + if (reason === 'session_renew') { + this.deflate = new StreamingDeflate(); + } + } + + /** + * Flush any pending segment and wait for compression to complete. + * Call on graceful app shutdown (e.g. `app.on('before-quit')`) so the + * final segment is queued for the transport layer before process exit. + */ + stop(): Promise { + this.flush('segment_duration_limit'); + return this.pendingFlush; + } + + private scheduleFlush(): void { + this.clearFlushTimeout(); + this.flushTimeoutId = globalThis.setTimeout(() => { + this.flush('segment_duration_limit'); + }, SEGMENT_DURATION_LIMIT); + } + + private clearFlushTimeout(): void { + if (this.flushTimeoutId !== null) { + globalThis.clearTimeout(this.flushTimeoutId); + this.flushTimeoutId = null; + } + } +} diff --git a/src/domain/replay/Segment.ts b/src/domain/replay/Segment.ts new file mode 100644 index 00000000..72ceafdc --- /dev/null +++ b/src/domain/replay/Segment.ts @@ -0,0 +1,131 @@ +/** + * Buffers BrowserRecord objects into a segment, tracking metadata required + * by the session-replay intake (timestamps, record count, full snapshot flag). + * + * On flush, produces the complete segment JSON (records array + metadata) + * and the metadata object separately (for the multipart 'event' part). + */ + +export interface SegmentContext { + application: { id: string }; + session: { id: string }; + view: { id: string }; +} + +export type CreationReason = + | 'init' + | 'segment_duration_limit' + | 'segment_bytes_limit' + | 'view_change' + | 'session_renew'; + +/** Record type constants from the browser SDK's rrweb-based recording. */ +const FULL_SNAPSHOT_TYPE = 2; + +export interface SegmentMetadata { + application: { id: string }; + session: { id: string }; + view: { id: string }; + start: number; + end: number; + records_count: number; + has_full_snapshot: boolean; + index_in_view: number; + source: 'browser'; + creation_reason: CreationReason; +} + +export interface SegmentFlushResult { + /** Complete segment JSON (records + metadata), ready for compression. */ + serializedSegment: string; + /** Metadata for the multipart 'event' part. */ + metadata: SegmentMetadata; + /** Byte size of the serialized segment before compression. */ + rawBytesCount: number; +} + +/** + * The payload written to disk and read by {@link ReplayBatchConsumer}. + * Produced by {@link ReplayCollection} after compression. + */ +export interface ReplaySegmentPayload { + metadata: SegmentMetadata; + rawBytesCount: number; + compressed: Buffer; +} + +export interface BrowserRecord { + type: number; + timestamp: number; + [key: string]: unknown; +} + +export class Segment { + private records: BrowserRecord[] = []; + private metadata: SegmentMetadata; + + constructor(context: SegmentContext, creationReason: CreationReason, indexInView: number) { + this.metadata = { + ...context, + start: Infinity, + end: -Infinity, + records_count: 0, + has_full_snapshot: false, + index_in_view: indexInView, + source: 'browser', + creation_reason: creationReason, + }; + } + + get recordsCount(): number { + return this.records.length; + } + + get isEmpty(): boolean { + return this.records.length === 0; + } + + /** Estimated byte size of the serialized records (pre-compression). */ + get estimatedSize(): number { + // Rough estimate: stringify each record. This avoids re-serializing on every add. + // We sum individual record sizes + overhead for the wrapping segment structure. + let size = 0; + for (const record of this.records) { + size += JSON.stringify(record).length; + } + return size; + } + + addRecord(record: BrowserRecord): void { + this.records.push(record); + this.metadata.start = Math.min(this.metadata.start, record.timestamp); + this.metadata.end = Math.max(this.metadata.end, record.timestamp); + this.metadata.records_count += 1; + if (record.type === FULL_SNAPSHOT_TYPE) { + this.metadata.has_full_snapshot = true; + } + } + + flush(): SegmentFlushResult { + // Match the browser SDK's exact wire format (from segment.js in @datadog/browser-rum): + // encoder.write('{"records":[' + JSON.stringify(r1)) ← opening + first record + // encoder.write(',' + JSON.stringify(rN)) ← subsequent records + // encoder.write(`],${JSON.stringify(metadata).slice(1)}\n`) ← close + metadata + newline + // + // The resulting format is: {"records":[r1,...,rN],...metadata}\n + // + // The trailing \n is critical: the backend stitches segments by concatenating + // DEFLATE bodies. The decompressed stitched stream is N lines of NDJSON that + // the player splits on \n to extract individual segment payloads. Without \n + // the player sees one large blob and can only parse segment 0. + const metadataJson = JSON.stringify(this.metadata); + // metadataJson = '{"start":...}' — drop the opening '{' to splice into the records object + const serializedSegment = `{"records":${JSON.stringify(this.records)},${metadataJson.slice(1)}\n`; + + return { + serializedSegment, + metadata: { ...this.metadata }, + rawBytesCount: Buffer.byteLength(serializedSegment, 'utf8'), + }; + } +} diff --git a/src/domain/replay/StreamingDeflate.ts b/src/domain/replay/StreamingDeflate.ts new file mode 100644 index 00000000..6ac1ea71 --- /dev/null +++ b/src/domain/replay/StreamingDeflate.ts @@ -0,0 +1,133 @@ +import zlib from 'node:zlib'; + +// ZLIB header for default compression (CMF=0x78, FLG=0x9C). +// Prepended to continuation segments (index > 0) which don't have a header +// emitted by zlib.createDeflate() since the stream is already started. +const ZLIB_HEADER = Buffer.from([0x78, 0x9c]); + +// DEFLATE final empty block: BFINAL=1, BTYPE=01 (fixed Huffman), EOB symbol (256). +// This matches the [3, 0] bytes produced by Pako's `vt()` function. +const DEFLATE_FINAL_BLOCK = Buffer.from([0x03, 0x00]); + +const ADLER_MOD = 65521; + +/** + * Maintains a single continuous ZLIB-compressed stream across multiple segment + * flushes within a session, producing output byte-for-byte compatible with what + * the browser SDK's Pako-based encoder generates. + * + * Each segment's compressed output matches the Pako worker's `write` + `vt()` + * output format: + * + * - **Header**: ZLIB header bytes (0x78 0x9C). Only emitted by zlib.createDeflate() + * for segment 0; manually prepended for segments 1..N. + * - **Body**: Z_FULL_FLUSH compressed data — the LZ77 dictionary is reset after + * each flush so every segment is independently decompressable. The backend + * validates each segment with a standalone inflate before stitching. + * - **Trailer**: [0x03, 0x00] (DEFLATE final empty block, BFINAL=1) followed by + * a 4-byte big-endian Adler-32 checksum over ALL raw data compressed so far + * (cumulative, not per-segment). This is exactly what Pako's `vt()` returns. + * + * The Datadog backend's stitching algorithm strips the 2-byte ZLIB header and + * 6-byte trailer from every segment, concatenates the raw DEFLATE bodies, then + * wraps the result with segment 0's header and the last segment's trailer — + * producing a single valid ZLIB stream for the replay player. + */ +export class StreamingDeflate { + private stream = zlib.createDeflate(); + private isFirstSegment = true; + // Running Adler-32 state (initial: A=1, B=0 per RFC 1950) + private adlerA = 1; + private adlerB = 0; + // Serialize concurrent calls so data events don't interleave. + private queue: Promise = Promise.resolve(); + + compressSegment(data: Buffer): Promise { + const result = this.queue.then(() => this.doCompress(data)); + this.queue = result.catch(() => undefined) as Promise; + return result; + } + + private doCompress(data: Buffer): Promise { + // Update running Adler-32 over the raw (uncompressed) data before compressing. + this.updateAdler32(data); + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + const onData = (chunk: Buffer) => chunks.push(chunk); + // eslint-disable-next-line prefer-const + let onError: (err: Error) => void; + + // Cleans up all attached event listeners to prevent memory leaks. + const cleanup = () => { + this.stream.off('data', onData); + if (onError) this.stream.off('error', onError); + }; + + onError = (err) => { + cleanup(); + reject(err); + }; + + this.stream.on('data', onData); + this.stream.once('error', onError); + + this.stream.write(data); + this.stream.flush(zlib.constants.Z_FULL_FLUSH, () => { + cleanup(); + + const parts: Buffer[] = []; + + if (!this.isFirstSegment) { + // Segment 0 already has the ZLIB header from zlib.createDeflate(). + // Segments 1..N are continuations with no header — prepend one so + // the backend's header-stripping doesn't corrupt actual deflate data. + parts.push(ZLIB_HEADER); + } + this.isFirstSegment = false; + + parts.push(Buffer.concat(chunks)); + + // Append the Pako-compatible trailer: DEFLATE final empty block + + // 4-byte big-endian Adler-32 of all raw data compressed so far. + parts.push(DEFLATE_FINAL_BLOCK); + parts.push(this.adler32ToBuffer()); + + resolve(Buffer.concat(parts)); + }); + }); + } + + private updateAdler32(data: Buffer): void { + let a = this.adlerA; + let b = this.adlerB; + const len = data.length; + let i = 0; + + // Process in block sizes up to 5000 bytes to safely accumulate sums + // without modulo operations on every byte, preventing integer overflow. + while (i < len) { + const blockEnd = Math.min(i + 5000, len); + for (; i < blockEnd; i++) { + a += data[i]; + b += a; + } + a %= ADLER_MOD; + b %= ADLER_MOD; + } + + this.adlerA = a; + this.adlerB = b; + } + + private adler32ToBuffer(): Buffer { + // Combine 'b' and 'a' into a single 32-bit signed integer. + const value = (this.adlerB << 16) | this.adlerA | 0; + + // Fast-allocate 4 unmanaged bytes and write the value in Big-Endian order. + const buf = Buffer.allocUnsafe(4); + buf.writeUInt32BE(value >>> 0, 0); + return buf; + } +} diff --git a/src/domain/replay/index.ts b/src/domain/replay/index.ts new file mode 100644 index 00000000..bc475e08 --- /dev/null +++ b/src/domain/replay/index.ts @@ -0,0 +1,3 @@ +export { ReplayCollection } from './ReplayCollection'; +export type { ViewReplayStats } from './ReplayCollection'; +export type { ReplaySegmentPayload, SegmentMetadata } from './Segment'; diff --git a/src/event/event.constants.ts b/src/event/event.constants.ts index 8d835e03..99c98e20 100644 --- a/src/event/event.constants.ts +++ b/src/event/event.constants.ts @@ -9,6 +9,7 @@ export const EventTrack = { RUM: 'rum', SPANS: 'spans', PROFILE: 'profile', + REPLAY: 'replay', } as const; export type EventTrack = (typeof EventTrack)[keyof typeof EventTrack]; @@ -16,6 +17,7 @@ export const EventFormat = { RUM: 'rum', TELEMETRY: 'telemetry', PROFILE: 'profile', + REPLAY: 'replay', } as const; export const EventKind = { diff --git a/src/event/event.types.ts b/src/event/event.types.ts index 4c3856f1..fe29e6d9 100644 --- a/src/event/event.types.ts +++ b/src/event/event.types.ts @@ -4,10 +4,11 @@ import { RawRumData, RumEvent } from '../domain/rum'; import type { TimeStamp } from '@datadog/js-core/time'; import { RawTraceData } from '../domain/tracing/rawTracingData.types'; import type { BrowserProfileEvent, BrowserProfilerTrace } from '../domain/profiling'; +import type { ReplaySegmentPayload } from '../domain/replay'; export type { BrowserProfileEvent, BrowserProfilerTrace }; -export type RawEvent = RawRumEvent | RawTelemetryEvent | RawProfileEvent; +export type RawEvent = RawRumEvent | RawTelemetryEvent | RawProfileEvent | RawReplayEvent; export interface RawRumEvent { kind: typeof EventKind.RAW; @@ -23,19 +24,30 @@ export interface RawTelemetryEvent { startTime?: TimeStamp; } +export interface RawReplayEvent { + kind: typeof EventKind.RAW; + source: typeof EventSource.RENDERER; + track: typeof EventTrack.REPLAY; + format: typeof EventFormat.REPLAY; + data: unknown; + view?: { id: string }; + startTime?: TimeStamp; +} + export type ServerEvent = | ServerRumEvent | ServerTelemetryEvent | ServerLogsEvent | ServerSpansEvent - | ServerProfileEvent; + | ServerProfileEvent + | ServerReplayEvent; /** * Server events transported as newline-delimited JSON, i.e. every {@link ServerEvent} whose * `data` is the full payload to serialize. Excludes {@link ServerProfileEvent}, which carries * an additional `trace` field and is transported as a multipart profile. */ -export type StandardServerEvent = Exclude; +export type StandardServerEvent = Exclude; export interface ServerRumEvent { kind: typeof EventKind.SERVER; @@ -80,6 +92,12 @@ export interface ServerProfileEvent { trace: BrowserProfilerTrace; } +export interface ServerReplayEvent { + kind: typeof EventKind.SERVER; + track: typeof EventTrack.REPLAY; + data: ReplaySegmentPayload; +} + export interface EndUserActivityEvent { kind: typeof EventKind.LIFECYCLE; lifecycle: typeof LifecycleKind.END_USER_ACTIVITY; diff --git a/src/index.ts b/src/index.ts index 501cbd7e..d6cc93df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { app } from 'electron'; import { MainAssembly, RendererPipeline, createFormatHooks, registerCommonContext } from './assembly'; import { setDurationVitalApi } from './api'; import type { AccountInfo, UserInfo } from './domain/customer-context'; @@ -6,6 +7,7 @@ import type { InitConfiguration } from './config'; import { buildConfiguration } from './config'; import type { ErrorOptions, FailureReason, FeatureOperationOptions } from './domain/rum'; import { RumCollection } from './domain/rum'; +import { ReplayCollection } from './domain/replay'; import { SessionManager } from './domain/session'; import { callMonitored, startTelemetry } from './domain/telemetry'; import { SpanProcessor } from './domain/tracing/SpanProcessor'; @@ -21,6 +23,7 @@ let rumApi: ReturnType | undefined; let tracing: Tracing | undefined; let userContext: UserContext | undefined; let accountContext: AccountContext | undefined; +let segmentCollection: ReplayCollection | undefined; /** * Internal SDK context @@ -30,13 +33,6 @@ export interface InternalContext { session_id: string; } -/** - * Internal SDK context - */ -export interface InternalContext { - session_id: string; -} - /** * Initialize the Electron SDK */ @@ -62,6 +58,7 @@ export async function init(configuration: InitConfiguration): Promise { new RendererPipeline(eventManager, hooks, config); new ProfilingCollection(eventManager, sessionManager, config, hooks); + segmentCollection = new ReplayCollection(eventManager, config, sessionManager); if (tracing.enabled) { new SpanProcessor(eventManager, hooks, config); @@ -72,6 +69,20 @@ export async function init(configuration: InitConfiguration): Promise { rumApi = rum.getApi(); setDurationVitalApi(rumApi); + // Flush the final in-flight replay segment (and other transports) before + // the process exits. `preventDefault` defers the quit so the async flush + // can complete; the 5-second fallback ensures we never hang. Using `once` + // so the handler removes itself — when _flushTransport calls app.quit() + // the second quit propagates without re-entering here. + app.once('before-quit', (event: Electron.Event) => { + event.preventDefault(); + const fallback = setTimeout(() => app.quit(), 5000); + void _flushTransport().finally(() => { + clearTimeout(fallback); + app.quit(); + }); + }); + return true; } @@ -264,6 +275,7 @@ export function failFeatureOperation( */ export async function _flushTransport(): Promise { await tracing?.flush(); + await segmentCollection?.stop(); await transport?.flush(); } diff --git a/src/mocks.specUtil.ts b/src/mocks.specUtil.ts index abf1192d..5b01ee1c 100644 --- a/src/mocks.specUtil.ts +++ b/src/mocks.specUtil.ts @@ -37,6 +37,7 @@ export function createTestConfiguration(overrides: Partial = {}): clientToken: 'test-token', applicationId: 'test-app-id', sessionSampleRate: 100, + sessionReplaySampleRate: 100, profilingSampleRate: 100, telemetrySampleRate: 100, defaultPrivacyLevel: 'mask', diff --git a/src/transport/Transport.spec.ts b/src/transport/Transport.spec.ts index 37ba55a7..2da68702 100644 --- a/src/transport/Transport.spec.ts +++ b/src/transport/Transport.spec.ts @@ -136,7 +136,8 @@ describe('Transport', () => { const transport = await Transport.create(config, eventManager); await transport.flush(); - expect(mockBatchFlush).toHaveBeenCalledTimes(3); + // RUM + SPANS + PROFILE + REPLAY + expect(mockBatchFlush).toHaveBeenCalledTimes(4); }); }); diff --git a/src/transport/Transport.ts b/src/transport/Transport.ts index 19275ed5..a948b91f 100644 --- a/src/transport/Transport.ts +++ b/src/transport/Transport.ts @@ -39,6 +39,10 @@ export class Transport { if (this.config.profilingSampleRate > 0) { tracks.push(EventTrack.PROFILE); } + + if (this.config.sessionReplaySampleRate > 0) { + tracks.push(EventTrack.REPLAY); + } return tracks; } diff --git a/src/transport/batch/BatchFactory.ts b/src/transport/batch/BatchFactory.ts index c6311bbd..1d96c253 100644 --- a/src/transport/batch/BatchFactory.ts +++ b/src/transport/batch/BatchFactory.ts @@ -1,8 +1,11 @@ import path from 'node:path'; import type { Configuration } from '../../config'; +import { EventTrack } from '../../event'; import { computeIntakeUrlForTrack } from '../utils'; import { GenericBatchConsumer } from './generic/GenericBatchConsumer'; import { GenericBatchProducer } from './generic/GenericBatchProducer'; +import { ReplayBatchConsumer } from './replay/ReplayBatchConsumer'; +import { ReplayBatchProducer } from './replay/ReplayBatchProducer'; import type { BatchConsumerConfig, BatchConfig, BatchProducerConfig } from './types'; import type { BatchConsumer } from './BatchConsumer'; import type { BatchProducer } from './BatchProducer'; @@ -29,8 +32,14 @@ export class BatchFactory { const trackPath = path.join(configPath, trackType); const intakeUrl = computeIntakeUrlForTrack(config.site, trackType, config.proxy); - // TODO: handle other batch types (e.g. session replay) - return BatchFactory.createGenericBatch({ trackPath, batchSize }, { trackPath, intakeUrl, clientToken }); + const producerConfig: BatchProducerConfig = { trackPath, batchSize }; + const consumerConfig: BatchConsumerConfig = { trackPath, intakeUrl, clientToken }; + + if (trackType === EventTrack.REPLAY) { + return BatchFactory.createReplayBatch(producerConfig, consumerConfig); + } + + return BatchFactory.createGenericBatch(producerConfig, consumerConfig); } /** @@ -46,4 +55,18 @@ export class BatchFactory { return { producer, consumer }; } + + /** + * Builds a replay producer (one atomic file per compressed segment) paired with + * a multipart/form-data consumer for the session replay intake. + */ + private static async createReplayBatch( + producerConfig: BatchProducerConfig, + consumerConfig: BatchConsumerConfig + ): Promise<{ producer: BatchProducer; consumer: BatchConsumer }> { + const producer = await ReplayBatchProducer.create(producerConfig); + const consumer = new ReplayBatchConsumer(consumerConfig); + + return { producer, consumer }; + } } diff --git a/src/transport/batch/replay/ReplayBatchConsumer.ts b/src/transport/batch/replay/ReplayBatchConsumer.ts new file mode 100644 index 00000000..06c4aa9e --- /dev/null +++ b/src/transport/batch/replay/ReplayBatchConsumer.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs/promises'; +import { generateUUID } from '@datadog/browser-core'; +import { BatchConsumer } from '../BatchConsumer'; + +declare const __SDK_VERSION__: string; + +/** + * Concrete {@link BatchConsumer} for session replay segments. + * + * Reads the two-line format written by {@link ReplayBatchProducer} and sends + * a multipart/form-data POST to the Datadog session replay intake: + * - `segment`: deflate-compressed binary blob + * - `event`: JSON metadata including raw/compressed size fields + */ +export class ReplayBatchConsumer extends BatchConsumer { + protected async uploadBatch(filePath: string): Promise { + const lines = await this.readBatchFile(filePath); + + if (lines.length < 2) { + await fs.unlink(filePath).catch(() => undefined); + return true; + } + + const metadataWithSizes = JSON.parse(lines[0]) as Record; + const compressed = Buffer.from(lines[1], 'base64'); + + const sessionId = (metadataWithSizes.session as { id: string }).id; + const start = metadataWithSizes.start as number; + + const formData = new FormData(); + formData.append('segment', new Blob([compressed], { type: 'application/octet-stream' }), `${sessionId}-${start}`); + formData.append('event', new Blob([JSON.stringify(metadataWithSizes)], { type: 'application/json' })); + + const params = new URLSearchParams({ + ddsource: 'browser', + ddtags: `sdk_version:${__SDK_VERSION__}`, + 'dd-api-key': this.clientToken, + 'dd-evp-origin': 'browser', + 'dd-evp-origin-version': __SDK_VERSION__, + 'dd-request-id': generateUUID(), + }); + + try { + const response = await fetch(`${this.intakeUrl}?${params.toString()}`, { + method: 'POST', + headers: { 'User-Agent': this.userAgent! }, + body: formData, + }); + + if (response.ok) { + await fs.unlink(filePath); + return true; + } + + return false; + } catch { + return false; + } + } +} diff --git a/src/transport/batch/replay/ReplayBatchProducer.ts b/src/transport/batch/replay/ReplayBatchProducer.ts new file mode 100644 index 00000000..697c5891 --- /dev/null +++ b/src/transport/batch/replay/ReplayBatchProducer.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { BatchProducer } from '../BatchProducer'; +import type { BatchProducerConfig } from '../types'; +import type { ReplaySegmentPayload } from '../../../domain/replay'; + +/** + * Concrete {@link BatchProducer} for session replay segments. + * + * Unlike the generic producer, each call to {@link writeData} writes one + * complete, atomic file per segment (no append/rotation by size). Each file + * contains two lines: + * - Line 1: JSON metadata + size fields (consumed by the multipart 'event' part) + * - Line 2: base64-encoded compressed segment binary (the multipart 'blob' part) + * + * The `.tmp` → `.log` rename ensures atomicity: the consumer never reads a + * partially-written segment. + */ +export class ReplayBatchProducer extends BatchProducer { + /** Creates and fully initializes a ReplayBatchProducer. */ + static async create(config: BatchProducerConfig): Promise { + const producer = new ReplayBatchProducer(config); + await producer.ensureTrackDirectoryExists(); + await producer.rotateOrphanedBatches(); + return producer; + } + + protected async writeData(data: unknown): Promise { + const { metadata, rawBytesCount, compressed } = data as ReplaySegmentPayload; + + await this.ensureTrackDirectoryExists(); + + const fileName = this.generateBatchFileName(); + const tmpPath = path.join(this.trackPath, fileName); + const logPath = tmpPath.replace(/\.tmp$/, '.log'); + + const metadataWithSizes = { + ...metadata, + raw_segment_size: rawBytesCount, + compressed_segment_size: compressed.byteLength, + }; + + const content = `${JSON.stringify(metadataWithSizes)}\n${compressed.toString('base64')}\n`; + await fs.writeFile(tmpPath, content, 'utf8'); + await fs.rename(tmpPath, logPath); + } +} From 4e60db9ae6869d94fe1d1ad318c41787a73eb9eb Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Tue, 2 Jun 2026 15:20:02 +0100 Subject: [PATCH 3/6] Update .gitignore rules to allow for yarn patches --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2bd3e039..9cf2da17 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,9 @@ playwright-report/ # Dependencies & Yarn **/node_modules/ -**/.yarn/ +**/.yarn/* +!**/.yarn/patches/ +!**/.yarn/patches/** # IA settings /.claude/settings.local.json From 0d3fc5cfd53cc0aaf73175e462413c8f2df4dd82 Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Tue, 2 Jun 2026 15:24:22 +0100 Subject: [PATCH 4/6] Patch dd-trace preload to enable session replay and fix config timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "records" to getCapabilities() so the browser RUM SDK detects session replay support and starts emitting BrowserRecord events through the bridge. -Replace the synchronous ipcRenderer.sendSync(CONFIG_CHANNEL) with an async retry loop using ipcRenderer.invoke. The bridge now initialises immediately with safe defaults (privacy: mask, hosts: [location.hostname]) and polls for up to 5s (50 × 100ms) for the Electron SDK's CONFIG_CHANNEL handler to become available. This decouples the preload lifecycle from SDK init order — previously the config was silently dropped if init() had not been called before the BrowserWindow was created. --- .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ e2e/app/yarn.lock | 41 ++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 +++++++++++++++++++ playground/yarn.lock | 41 ++++++++ yarn.lock | 41 ++++++++ 13 files changed, 1053 insertions(+) create mode 100644 .yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/electron-builder-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/electron-vite-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/electron-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/forge-esbuild-cjs/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/forge-esbuild-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/forge-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 e2e/integration/apps/forge-webpack/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 playground/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch diff --git a/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/app/yarn.lock b/e2e/app/yarn.lock index 132a8f77..f65b11d2 100644 --- a/e2e/app/yarn.lock +++ b/e2e/app/yarn.lock @@ -785,6 +785,47 @@ __metadata: languageName: node linkType: hard +"dd-trace@patch:dd-trace@npm%3A5.103.0#~/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch": + version: 5.103.0 + resolution: "dd-trace@patch:dd-trace@npm%3A5.103.0#~/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch::version=5.103.0&hash=41c989" + dependencies: + "@datadog/libdatadog": "npm:0.9.3" + "@datadog/native-appsec": "npm:11.0.1" + "@datadog/native-iast-taint-tracking": "npm:4.1.0" + "@datadog/native-metrics": "npm:3.1.2" + "@datadog/openfeature-node-server": "npm:1.1.2" + "@datadog/pprof": "npm:5.14.1" + "@datadog/wasm-js-rewriter": "npm:5.0.1" + "@opentelemetry/api": "npm:>=1.0.0 <1.10.0" + "@opentelemetry/api-logs": "npm:<1.0.0" + dc-polyfill: "npm:^0.1.11" + import-in-the-middle: "npm:^3.0.1" + oxc-parser: "npm:^0.129.0" + dependenciesMeta: + "@datadog/libdatadog": + optional: true + "@datadog/native-appsec": + optional: true + "@datadog/native-iast-taint-tracking": + optional: true + "@datadog/native-metrics": + optional: true + "@datadog/openfeature-node-server": + optional: true + "@datadog/pprof": + optional: true + "@datadog/wasm-js-rewriter": + optional: true + "@opentelemetry/api": + optional: true + "@opentelemetry/api-logs": + optional: true + oxc-parser: + optional: true + checksum: 10c0/876c62ee31fbc046f6d0fd41bfbc05172748e56c66df25f08826e3899fcb91d976e8b7f2bbbc01314e034d43f987f16006282ce255b88d7f2dd4cdf582bcd08d + languageName: node + linkType: hard + "debug@npm:^4.1.0, debug@npm:^4.1.1": version: 4.4.3 resolution: "debug@npm:4.4.3" diff --git a/e2e/integration/apps/electron-builder-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/electron-builder-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/electron-builder-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/integration/apps/electron-vite-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/electron-vite-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/electron-vite-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/integration/apps/electron-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/electron-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/electron-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/integration/apps/forge-esbuild-cjs/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/forge-esbuild-cjs/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/forge-esbuild-cjs/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/integration/apps/forge-esbuild-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/forge-esbuild-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/forge-esbuild-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/integration/apps/forge-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/forge-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/forge-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/e2e/integration/apps/forge-webpack/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/integration/apps/forge-webpack/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/e2e/integration/apps/forge-webpack/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/playground/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/playground/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch new file mode 100644 index 00000000..66a01268 --- /dev/null +++ b/playground/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch @@ -0,0 +1,93 @@ +diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js +index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 +--- a/packages/datadog-instrumentations/src/electron/preload.js ++++ b/packages/datadog-instrumentations/src/electron/preload.js +@@ -1,42 +1,66 @@ +-'use strict' ++"use strict"; + + // eslint-disable-next-line n/no-missing-require +-const { contextBridge, ipcRenderer } = require('electron') ++const { contextBridge, ipcRenderer } = require("electron"); + +-const BRIDGE_CHANNEL = 'datadog:bridge-send' +-const CONFIG_CHANNEL = 'datadog:bridge-config' ++const BRIDGE_CHANNEL = "datadog:bridge-send"; ++const CONFIG_CHANNEL = "datadog:bridge-config"; + + // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel +-const MASK = 'mask' ++const MASK = "mask"; + +-const config = ipcRenderer.sendSync(CONFIG_CHANNEL) ++const bridgeState = { ++ defaultPrivacyLevel: MASK, ++ allowedHosts: [location.hostname], ++}; + +-const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK +-const configuredHosts = config?.allowedWebViewHosts ?? [] +-// eslint-disable-next-line no-undef +-const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] ++function applyConfig(config) { ++ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; ++ ++ bridgeState.allowedHosts = [ ++ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), ++ ]; ++} ++ ++async function loadConfigWithRetry() { ++ const maxAttempts = 50; ++ const retryDelayMs = 100; ++ ++ for (let attempt = 0; attempt < maxAttempts; attempt++) { ++ try { ++ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); ++ applyConfig(config); ++ return; ++ } catch { ++ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); ++ } ++ } ++ ++ // Keep safe defaults if config never becomes available. ++} + + const bridge = { +- getCapabilities () { +- return '[]' ++ getCapabilities() { ++ return '["records"]'; + }, +- getPrivacyLevel () { +- return defaultPrivacyLevel ++ getPrivacyLevel() { ++ return bridgeState.defaultPrivacyLevel; + }, +- getAllowedWebViewHosts () { +- return JSON.stringify(allowedHosts) ++ getAllowedWebViewHosts() { ++ return JSON.stringify(bridgeState.allowedHosts); + }, +- send (msg) { +- ipcRenderer.send(BRIDGE_CHANNEL, msg) ++ send(msg) { ++ ipcRenderer.send(BRIDGE_CHANNEL, msg); + }, +-} ++}; + + // Support both contextIsolation enabled (default) and disabled +- +-window.DatadogEventBridge = bridge ++window.DatadogEventBridge = bridge; + + try { +- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) ++ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); + } catch { + // exposeInMainWorld throws when contextIsolation is disabled + } ++ ++loadConfigWithRetry(); diff --git a/playground/yarn.lock b/playground/yarn.lock index 1be9975c..bd2df3f5 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -1016,6 +1016,47 @@ __metadata: languageName: node linkType: hard +"dd-trace@patch:dd-trace@npm%3A5.103.0#~/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch": + version: 5.103.0 + resolution: "dd-trace@patch:dd-trace@npm%3A5.103.0#~/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch::version=5.103.0&hash=41c989" + dependencies: + "@datadog/libdatadog": "npm:0.9.3" + "@datadog/native-appsec": "npm:11.0.1" + "@datadog/native-iast-taint-tracking": "npm:4.1.0" + "@datadog/native-metrics": "npm:3.1.2" + "@datadog/openfeature-node-server": "npm:1.1.2" + "@datadog/pprof": "npm:5.14.1" + "@datadog/wasm-js-rewriter": "npm:5.0.1" + "@opentelemetry/api": "npm:>=1.0.0 <1.10.0" + "@opentelemetry/api-logs": "npm:<1.0.0" + dc-polyfill: "npm:^0.1.11" + import-in-the-middle: "npm:^3.0.1" + oxc-parser: "npm:^0.129.0" + dependenciesMeta: + "@datadog/libdatadog": + optional: true + "@datadog/native-appsec": + optional: true + "@datadog/native-iast-taint-tracking": + optional: true + "@datadog/native-metrics": + optional: true + "@datadog/openfeature-node-server": + optional: true + "@datadog/pprof": + optional: true + "@datadog/wasm-js-rewriter": + optional: true + "@opentelemetry/api": + optional: true + "@opentelemetry/api-logs": + optional: true + oxc-parser: + optional: true + checksum: 10c0/876c62ee31fbc046f6d0fd41bfbc05172748e56c66df25f08826e3899fcb91d976e8b7f2bbbc01314e034d43f987f16006282ce255b88d7f2dd4cdf582bcd08d + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.4": version: 4.4.3 resolution: "debug@npm:4.4.3" diff --git a/yarn.lock b/yarn.lock index 8a59b48f..bd389f30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2003,6 +2003,47 @@ __metadata: languageName: node linkType: hard +"dd-trace@patch:dd-trace@npm%3A5.103.0#~/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch": + version: 5.103.0 + resolution: "dd-trace@patch:dd-trace@npm%3A5.103.0#~/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch::version=5.103.0&hash=41c989" + dependencies: + "@datadog/libdatadog": "npm:0.9.3" + "@datadog/native-appsec": "npm:11.0.1" + "@datadog/native-iast-taint-tracking": "npm:4.1.0" + "@datadog/native-metrics": "npm:3.1.2" + "@datadog/openfeature-node-server": "npm:1.1.2" + "@datadog/pprof": "npm:5.14.1" + "@datadog/wasm-js-rewriter": "npm:5.0.1" + "@opentelemetry/api": "npm:>=1.0.0 <1.10.0" + "@opentelemetry/api-logs": "npm:<1.0.0" + dc-polyfill: "npm:^0.1.11" + import-in-the-middle: "npm:^3.0.1" + oxc-parser: "npm:^0.129.0" + dependenciesMeta: + "@datadog/libdatadog": + optional: true + "@datadog/native-appsec": + optional: true + "@datadog/native-iast-taint-tracking": + optional: true + "@datadog/native-metrics": + optional: true + "@datadog/openfeature-node-server": + optional: true + "@datadog/pprof": + optional: true + "@datadog/wasm-js-rewriter": + optional: true + "@opentelemetry/api": + optional: true + "@opentelemetry/api-logs": + optional: true + oxc-parser: + optional: true + checksum: 10c0/876c62ee31fbc046f6d0fd41bfbc05172748e56c66df25f08826e3899fcb91d976e8b7f2bbbc01314e034d43f987f16006282ce255b88d7f2dd4cdf582bcd08d + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" From c17cb885b592a995b2b3e9592f4427f9951903eb Mon Sep 17 00:00:00 2001 From: Carlos Nogueira Date: Wed, 3 Jun 2026 11:21:02 +0100 Subject: [PATCH 5/6] Add unit and E2E test coverage for session replay --- e2e/app/src/bridge-window.ts | 1 + e2e/lib/intake.ts | 89 ++++++- e2e/scenarios/replay.scenario.ts | 72 ++++++ src/domain/replay/ReplayCollection.spec.ts | 240 ++++++++++++++++++ src/domain/replay/Segment.spec.ts | 117 +++++++++ src/domain/replay/StreamingDeflate.spec.ts | 107 ++++++++ .../batch/replay/ReplayBatchConsumer.spec.ts | 173 +++++++++++++ .../batch/replay/ReplayBatchProducer.spec.ts | 120 +++++++++ 8 files changed, 907 insertions(+), 12 deletions(-) create mode 100644 e2e/scenarios/replay.scenario.ts create mode 100644 src/domain/replay/ReplayCollection.spec.ts create mode 100644 src/domain/replay/Segment.spec.ts create mode 100644 src/domain/replay/StreamingDeflate.spec.ts create mode 100644 src/transport/batch/replay/ReplayBatchConsumer.spec.ts create mode 100644 src/transport/batch/replay/ReplayBatchProducer.spec.ts diff --git a/e2e/app/src/bridge-window.ts b/e2e/app/src/bridge-window.ts index 505607b0..5637c55c 100644 --- a/e2e/app/src/bridge-window.ts +++ b/e2e/app/src/bridge-window.ts @@ -7,6 +7,7 @@ datadogRum.init({ service: 'e2e-renderer', sessionSampleRate: 100, profilingSampleRate: 100, + sessionReplaySampleRate: 100, trackResources: true, trackLongTasks: true, trackUserInteractions: true, diff --git a/e2e/lib/intake.ts b/e2e/lib/intake.ts index 27530b35..fe306d60 100644 --- a/e2e/lib/intake.ts +++ b/e2e/lib/intake.ts @@ -14,6 +14,13 @@ export interface ReceivedEvent { ddforward: string; } +export interface ReplaySegment { + timestamp: number; + /** Parsed JSON from the multipart `event` field — segment metadata + size fields. */ + metadata: Record; + headers: Record; +} + export interface Trace { env: string; spans: Span[]; @@ -40,6 +47,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 profilingRequests: ProfilingRequest[] = []; private port = 0; @@ -57,6 +65,31 @@ export class Intake { } } + private storeReplaySegment(rawBody: Buffer, headers: Record) { + 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; + 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) { @@ -92,10 +125,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')) { @@ -117,18 +150,28 @@ export class Intake { } req.on('end', () => { - try { - const parsedBody: unknown = JSON.parse(body); - const headers: Record = {}; - - 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 = {}; + + 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 { @@ -278,8 +321,30 @@ export class Intake { } } + async waitForReplaySegment(options?: { + timeout?: number; + predicate?: (segment: ReplaySegment) => boolean; + }): Promise { + 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'; diff --git a/e2e/scenarios/replay.scenario.ts b/e2e/scenarios/replay.scenario.ts new file mode 100644 index 00000000..0b7ce0c3 --- /dev/null +++ b/e2e/scenarios/replay.scenario.ts @@ -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', () => { + 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; + const session = body['session'] as Record | undefined; + return session?.['has_replay'] === true; + }, + }); + + expect(viewEvents.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/domain/replay/ReplayCollection.spec.ts b/src/domain/replay/ReplayCollection.spec.ts new file mode 100644 index 00000000..e459d722 --- /dev/null +++ b/src/domain/replay/ReplayCollection.spec.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventFormat, EventKind, EventTrack, EventManager, EventSource, LifecycleKind } from '../../event'; +import type { RawReplayEvent, ServerReplayEvent } from '../../event'; +import { ReplayCollection } from './ReplayCollection'; +import type { Configuration } from '../../config'; +import type { SessionManager } from '../session'; +import type { ReplaySegmentPayload } from './Segment'; + +vi.mock('../telemetry', () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + monitor: (fn: Function) => fn, +})); + +vi.mock('./StreamingDeflate', () => ({ + StreamingDeflate: class { + compressSegment = vi.fn().mockResolvedValue(Buffer.from('compressed')); + }, +})); + +function makeConfig(overrides?: Partial): Configuration { + return { + site: 'datadoghq.com', + service: 'test', + clientToken: 'pub-test', + applicationId: 'app-1', + telemetrySampleRate: 0, + defaultPrivacyLevel: 'mask', + allowedWebViewHosts: [], + ...overrides, + } as Configuration; +} + +function makeSessionManager(id = 'sess-1', status: 'active' | 'expired' = 'active'): SessionManager { + return { + getSession: () => ({ id, status }), + } as unknown as SessionManager; +} + +function sendRecord( + eventManager: EventManager, + record: { type: number; timestamp: number; [key: string]: unknown }, + viewId = 'view-1' +) { + eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.RENDERER, + format: EventFormat.REPLAY, + data: record, + view: { id: viewId }, + } as RawReplayEvent); +} + +function captureReplayEvents(eventManager: EventManager): ReplaySegmentPayload[] { + const captured: ReplaySegmentPayload[] = []; + eventManager.registerHandler({ + canHandle: (event): event is ServerReplayEvent => + event.kind === EventKind.SERVER && event.track === EventTrack.REPLAY, + handle: (event) => captured.push(event.data), + }); + return captured; +} + +describe('ReplayCollection', () => { + let eventManager: EventManager; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + eventManager = new EventManager(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('idle behaviour', () => { + it('emits nothing when no records are received', () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + vi.advanceTimersByTime(10_000); + expect(captured).toHaveLength(0); + }); + + it('does not collect records when session is expired', () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager('sess-1', 'expired')); + + sendRecord(eventManager, { type: 3, timestamp: 100 }); + vi.advanceTimersByTime(10_000); + expect(captured).toHaveLength(0); + }); + }); + + describe('duration-based flush (5 s timer)', () => { + it('flushes segment after 5 s and emits a ServerReplayEvent', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 2, timestamp: 1000 }); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(captured).toHaveLength(1); + expect(captured[0].metadata.records_count).toBe(1); + expect(captured[0].metadata.application.id).toBe('app-1'); + expect(captured[0].metadata.session.id).toBe('sess-1'); + expect(captured[0].metadata.view.id).toBe('view-1'); + }); + + it('accumulates multiple records into a single segment', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 4, timestamp: 100 }); + sendRecord(eventManager, { type: 2, timestamp: 200 }); + sendRecord(eventManager, { type: 3, timestamp: 300 }); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(captured).toHaveLength(1); + expect(captured[0].metadata.records_count).toBe(3); + expect(captured[0].metadata.start).toBe(100); + expect(captured[0].metadata.end).toBe(300); + expect(captured[0].metadata.has_full_snapshot).toBe(true); + }); + }); + + describe('view-change flush', () => { + it('flushes when the view ID changes', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 3, timestamp: 100 }, 'view-1'); + sendRecord(eventManager, { type: 3, timestamp: 200 }, 'view-2'); + await Promise.resolve(); + + expect(captured).toHaveLength(1); + expect(captured[0].metadata.view.id).toBe('view-1'); + }); + + it('sets creation_reason to view_change on the next segment', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 3, timestamp: 100 }, 'view-1'); + sendRecord(eventManager, { type: 3, timestamp: 200 }, 'view-2'); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(captured).toHaveLength(2); + expect(captured[1].metadata.creation_reason).toBe('view_change'); + }); + }); + + describe('session lifecycle flush', () => { + it('flushes on SESSION_EXPIRED', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 3, timestamp: 100 }); + eventManager.notify({ kind: EventKind.LIFECYCLE, lifecycle: LifecycleKind.SESSION_EXPIRED }); + await Promise.resolve(); + + expect(captured).toHaveLength(1); + }); + }); + + describe('segment indexing', () => { + it('increments index_in_view for successive segments in the same view', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 3, timestamp: 100 }, 'view-1'); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + sendRecord(eventManager, { type: 3, timestamp: 200 }, 'view-1'); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(captured[0].metadata.index_in_view).toBe(0); + expect(captured[1].metadata.index_in_view).toBe(1); + }); + + it('resets index_in_view for a new view', async () => { + const captured = captureReplayEvents(eventManager); + new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 3, timestamp: 100 }, 'view-1'); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + sendRecord(eventManager, { type: 3, timestamp: 200 }, 'view-2'); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(captured[0].metadata.index_in_view).toBe(0); + expect(captured[1].metadata.index_in_view).toBe(0); + }); + }); + + describe('getViewReplayStats()', () => { + it('returns stats accumulated for a view after flushing', async () => { + const collection = new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + captureReplayEvents(eventManager); + + sendRecord(eventManager, { type: 3, timestamp: 100 }, 'view-abc'); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + const stats = collection.getViewReplayStats('view-abc'); + expect(stats).toBeDefined(); + expect(stats!.segments_count).toBe(1); + expect(stats!.segments_total_raw_size).toBeGreaterThan(0); + }); + + it('returns undefined for a view with no segments', () => { + const collection = new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + expect(collection.getViewReplayStats('unknown-view')).toBeUndefined(); + }); + }); + + describe('stop()', () => { + it('flushes pending segment before resolving', async () => { + const captured = captureReplayEvents(eventManager); + const collection = new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + + sendRecord(eventManager, { type: 3, timestamp: 100 }); + await collection.stop(); + + expect(captured).toHaveLength(1); + }); + + it('resolves immediately if no records are pending', async () => { + const collection = new ReplayCollection(eventManager, makeConfig(), makeSessionManager()); + await expect(collection.stop()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/domain/replay/Segment.spec.ts b/src/domain/replay/Segment.spec.ts new file mode 100644 index 00000000..96698e80 --- /dev/null +++ b/src/domain/replay/Segment.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { Segment, type BrowserRecord } from './Segment'; + +const CONTEXT = { application: { id: 'app-1' }, session: { id: 'sess-1' }, view: { id: 'view-1' } }; + +function makeRecord(overrides: Partial = {}): BrowserRecord { + return { type: 3, timestamp: 1000, data: { source: 0 }, ...overrides }; +} + +describe('Segment', () => { + describe('initial state', () => { + it('starts empty', () => { + const segment = new Segment(CONTEXT, 'init', 0); + expect(segment.isEmpty).toBe(true); + expect(segment.recordsCount).toBe(0); + }); + }); + + describe('addRecord()', () => { + it('tracks record count', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord()); + segment.addRecord(makeRecord()); + expect(segment.recordsCount).toBe(2); + expect(segment.isEmpty).toBe(false); + }); + + it('tracks start and end timestamps across out-of-order records', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord({ timestamp: 500 })); + segment.addRecord(makeRecord({ timestamp: 300 })); + segment.addRecord(makeRecord({ timestamp: 700 })); + const { metadata } = segment.flush(); + expect(metadata.start).toBe(300); + expect(metadata.end).toBe(700); + }); + + it('does not set has_full_snapshot for non-snapshot records', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord({ type: 3 })); + expect(segment.flush().metadata.has_full_snapshot).toBe(false); + }); + + it('sets has_full_snapshot when a type-2 record is added', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord({ type: 2 })); + expect(segment.flush().metadata.has_full_snapshot).toBe(true); + }); + + it('accumulates estimated size proportional to record count', () => { + const segment = new Segment(CONTEXT, 'init', 0); + expect(segment.estimatedSize).toBe(0); + segment.addRecord(makeRecord()); + const sizeAfterOne = segment.estimatedSize; + segment.addRecord(makeRecord()); + expect(segment.estimatedSize).toBeGreaterThan(sizeAfterOne); + }); + }); + + describe('flush()', () => { + it('embeds context and segment config in metadata', () => { + const segment = new Segment(CONTEXT, 'view_change', 3); + segment.addRecord(makeRecord()); + const { metadata } = segment.flush(); + expect(metadata.application.id).toBe('app-1'); + expect(metadata.session.id).toBe('sess-1'); + expect(metadata.view.id).toBe('view-1'); + expect(metadata.creation_reason).toBe('view_change'); + expect(metadata.index_in_view).toBe(3); + expect(metadata.source).toBe('browser'); + }); + + it('produces valid JSON with records array merged with metadata', () => { + const segment = new Segment(CONTEXT, 'init', 0); + const record = makeRecord({ timestamp: 1000 }); + segment.addRecord(record); + + const { serializedSegment } = segment.flush(); + const parsed = JSON.parse(serializedSegment) as Record; + + expect(Array.isArray(parsed['records'])).toBe(true); + expect((parsed['records'] as unknown[]).length).toBe(1); + expect((parsed['records'] as unknown[])[0]).toEqual(record); + // Metadata fields are merged at the top level (not nested) + expect(parsed['application']).toEqual({ id: 'app-1' }); + expect(parsed['records_count']).toBe(1); + }); + + it('serialized segment ends with a newline', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord()); + const { serializedSegment } = segment.flush(); + expect(serializedSegment.endsWith('\n')).toBe(true); + }); + + it('rawBytesCount matches the byte length of the serialized segment', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord()); + const { serializedSegment, rawBytesCount } = segment.flush(); + expect(rawBytesCount).toBe(Buffer.byteLength(serializedSegment, 'utf8')); + }); + + it('returns a snapshot of metadata (not a live reference)', () => { + const segment = new Segment(CONTEXT, 'init', 0); + segment.addRecord(makeRecord({ timestamp: 1000 })); + const first = segment.flush(); + + // After flush, adding more records to a new segment should not affect the snapshot + const segment2 = new Segment(CONTEXT, 'init', 0); + segment2.addRecord(makeRecord({ timestamp: 9999 })); + const second = segment2.flush(); + + expect(first.metadata.start).toBe(1000); + expect(second.metadata.start).toBe(9999); + }); + }); +}); diff --git a/src/domain/replay/StreamingDeflate.spec.ts b/src/domain/replay/StreamingDeflate.spec.ts new file mode 100644 index 00000000..3daae4f0 --- /dev/null +++ b/src/domain/replay/StreamingDeflate.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import zlib from 'node:zlib'; +import { StreamingDeflate } from './StreamingDeflate'; + +const ZLIB_HEADER = [0x78, 0x9c]; +const DEFLATE_FINAL_BLOCK = [0x03, 0x00]; + +describe('StreamingDeflate', () => { + describe('output structure', () => { + it('returns a non-empty Buffer', async () => { + const deflate = new StreamingDeflate(); + const result = await deflate.compressSegment(Buffer.from('hello world')); + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('first segment starts with the ZLIB header [0x78, 0x9C]', async () => { + const deflate = new StreamingDeflate(); + const result = await deflate.compressSegment(Buffer.from('first segment data')); + expect(result[0]).toBe(ZLIB_HEADER[0]); + expect(result[1]).toBe(ZLIB_HEADER[1]); + }); + + it('subsequent segments also start with the ZLIB header (manually prepended)', async () => { + const deflate = new StreamingDeflate(); + await deflate.compressSegment(Buffer.from('first segment')); + const second = await deflate.compressSegment(Buffer.from('second segment')); + expect(second[0]).toBe(ZLIB_HEADER[0]); + expect(second[1]).toBe(ZLIB_HEADER[1]); + }); + + it('each segment ends with DEFLATE final block [0x03, 0x00] followed by 4-byte Adler-32', async () => { + const deflate = new StreamingDeflate(); + const result = await deflate.compressSegment(Buffer.from('test payload')); + // Last 6 bytes: [0x03, 0x00] (final block) + Adler-32 (4 bytes) + expect(result[result.length - 6]).toBe(DEFLATE_FINAL_BLOCK[0]); + expect(result[result.length - 5]).toBe(DEFLATE_FINAL_BLOCK[1]); + // 4-byte Adler-32 follows + expect(result.length).toBeGreaterThanOrEqual(8); + }); + + it('single segment is a valid ZLIB stream that inflates back to the original data', async () => { + const deflate = new StreamingDeflate(); + const original = Buffer.from('the quick brown fox jumps over the lazy dog'); + const compressed = await deflate.compressSegment(original); + const inflated = zlib.inflateSync(compressed); + expect(inflated.equals(original)).toBe(true); + }); + }); + + describe('Adler-32 checksum', () => { + it('is cumulative across segments — covers all data sent so far', async () => { + // Two separate instances with data in different orders should produce different checksums + const d1 = new StreamingDeflate(); + const d2 = new StreamingDeflate(); + + await d1.compressSegment(Buffer.from('aaa')); + const r1 = await d1.compressSegment(Buffer.from('bbb')); + + await d2.compressSegment(Buffer.from('bbb')); + const r2 = await d2.compressSegment(Buffer.from('aaa')); + + // Adler-32 = last 4 bytes of each segment + const adler1 = r1.slice(-4); + const adler2 = r2.slice(-4); + // Different data order → different cumulative checksum + expect(adler1.equals(adler2)).toBe(false); + }); + + it('two instances compressing the same data in the same order produce equal Adler-32', async () => { + const d1 = new StreamingDeflate(); + const d2 = new StreamingDeflate(); + const data = Buffer.from('identical data'); + + const r1 = await d1.compressSegment(data); + const r2 = await d2.compressSegment(data); + + expect(r1.slice(-4).equals(r2.slice(-4))).toBe(true); + }); + }); + + describe('queue serialization', () => { + it('resolves concurrent calls in call order', async () => { + const deflate = new StreamingDeflate(); + const order: number[] = []; + + const p1 = deflate.compressSegment(Buffer.from('a')).then(() => order.push(1)); + const p2 = deflate.compressSegment(Buffer.from('b')).then(() => order.push(2)); + const p3 = deflate.compressSegment(Buffer.from('c')).then(() => order.push(3)); + + await Promise.all([p1, p2, p3]); + expect(order).toEqual([1, 2, 3]); + }); + + it('each call produces a valid result even under concurrency', async () => { + const deflate = new StreamingDeflate(); + const data = Buffer.from('concurrent data'); + + const [r1, r2] = await Promise.all([deflate.compressSegment(data), deflate.compressSegment(data)]); + + expect(r1).toBeInstanceOf(Buffer); + expect(r2).toBeInstanceOf(Buffer); + expect(r1.length).toBeGreaterThan(0); + expect(r2.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/transport/batch/replay/ReplayBatchConsumer.spec.ts b/src/transport/batch/replay/ReplayBatchConsumer.spec.ts new file mode 100644 index 00000000..085370a7 --- /dev/null +++ b/src/transport/batch/replay/ReplayBatchConsumer.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ReplayBatchConsumer } from './ReplayBatchConsumer'; +import { getUserAgent } from '../../userAgent'; +import { mockFs } from '../../../mocks.specUtil'; + +vi.mock('node:fs/promises'); +vi.mock('../../userAgent'); +vi.mock('@datadog/browser-core', () => ({ + generateUUID: vi.fn(() => 'test-request-id'), +})); +vi.stubGlobal('__SDK_VERSION__', '0.0.0-test'); + +const fsMocks = mockFs(); +const TEST_USER_AGENT = 'TestApp/1.0.0 Electron/0'; + +const config = { + trackPath: '/mock/replay', + intakeUrl: 'https://browser-intake-datadoghq.com/api/v2/replay', + clientToken: 'test-client-token', +}; + +function makeFileLine(metadata: Record, compressed: Buffer): string { + return `${JSON.stringify(metadata)}\n${compressed.toString('base64')}\n`; +} + +describe('ReplayBatchConsumer', () => { + let consumer: ReplayBatchConsumer; + + beforeEach(() => { + fsMocks.reset(); + vi.mocked(getUserAgent).mockReset().mockReturnValue(TEST_USER_AGENT); + consumer = new ReplayBatchConsumer(config); + global.fetch = vi.fn().mockResolvedValue({ ok: true } as Response); + fsMocks.access.mockResolvedValue(undefined); + fsMocks.unlink.mockResolvedValue(undefined); + }); + + describe('upload lifecycle', () => { + it('reads .log files from the track directory and uploads each one', async () => { + const metadata = { session: { id: 'sess-1' }, start: 1000, raw_segment_size: 100, compressed_segment_size: 50 }; + const compressed = Buffer.from([0x78, 0x9c, 0x03, 0x00]); + fsMocks.readdir.mockResolvedValue(['segment-1.log']); + fsMocks.readFile.mockResolvedValue(makeFileLine(metadata, compressed)); + + await consumer.upload(); + + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('deletes the file after a successful upload', async () => { + fsMocks.readdir.mockResolvedValue(['segment-1.log']); + fsMocks.readFile.mockResolvedValue( + makeFileLine( + { session: { id: 'sess' }, start: 0, raw_segment_size: 1, compressed_segment_size: 1 }, + Buffer.from([0x01]) + ) + ); + + await consumer.upload(); + + expect(fsMocks.unlink).toHaveBeenCalledOnce(); + }); + + it('keeps the file when the intake returns a non-ok response', async () => { + fsMocks.readdir.mockResolvedValue(['segment-1.log']); + fsMocks.readFile.mockResolvedValue( + makeFileLine( + { session: { id: 'sess' }, start: 0, raw_segment_size: 1, compressed_segment_size: 1 }, + Buffer.from([0x01]) + ) + ); + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 500 } as Response); + + await consumer.upload(); + + expect(fsMocks.unlink).not.toHaveBeenCalled(); + }); + + it('keeps the file when fetch throws a network error', async () => { + fsMocks.readdir.mockResolvedValue(['segment-1.log']); + fsMocks.readFile.mockResolvedValue( + makeFileLine( + { session: { id: 'sess' }, start: 0, raw_segment_size: 1, compressed_segment_size: 1 }, + Buffer.from([0x01]) + ) + ); + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('Failed to fetch')); + + await expect(consumer.upload()).resolves.not.toThrow(); + expect(fsMocks.unlink).not.toHaveBeenCalled(); + }); + + it('deletes an empty file (< 2 lines) without calling fetch', async () => { + fsMocks.readdir.mockResolvedValue(['empty.log']); + fsMocks.readFile.mockResolvedValue(''); + + await consumer.upload(); + + expect(fetch).not.toHaveBeenCalled(); + expect(fsMocks.unlink).toHaveBeenCalledOnce(); + }); + + it('does not crash if trackPath does not exist', async () => { + fsMocks.access.mockRejectedValue(new Error('ENOENT')); + await expect(consumer.upload()).resolves.not.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + describe('request format', () => { + it('sends a multipart/form-data POST request', async () => { + const metadata = { session: { id: 'sess-1' }, start: 1000, raw_segment_size: 100, compressed_segment_size: 50 }; + const compressed = Buffer.from([0x78, 0x9c]); + fsMocks.readdir.mockResolvedValue(['segment.log']); + fsMocks.readFile.mockResolvedValue(makeFileLine(metadata, compressed)); + + await consumer.upload(); + + const [, init] = vi.mocked(fetch).mock.calls[0]; + expect((init?.body as FormData) instanceof FormData).toBe(true); + }); + + it('includes the correct query parameters in the URL', async () => { + fsMocks.readdir.mockResolvedValue(['segment.log']); + fsMocks.readFile.mockResolvedValue( + makeFileLine( + { session: { id: 'sess' }, start: 0, raw_segment_size: 1, compressed_segment_size: 1 }, + Buffer.from([0x01]) + ) + ); + + await consumer.upload(); + + const [url] = vi.mocked(fetch).mock.calls[0]; + const parsedUrl = new URL(url as string); + expect(parsedUrl.searchParams.get('ddsource')).toBe('browser'); + expect(parsedUrl.searchParams.get('dd-api-key')).toBe(config.clientToken); + expect(parsedUrl.searchParams.get('dd-evp-origin')).toBe('browser'); + expect(parsedUrl.searchParams.get('dd-request-id')).toBe('test-request-id'); + expect(parsedUrl.searchParams.get('ddtags')).toContain('sdk_version:0.0.0-test'); + }); + + it('sends the User-Agent header', async () => { + fsMocks.readdir.mockResolvedValue(['segment.log']); + fsMocks.readFile.mockResolvedValue( + makeFileLine( + { session: { id: 'sess' }, start: 0, raw_segment_size: 1, compressed_segment_size: 1 }, + Buffer.from([0x01]) + ) + ); + + await consumer.upload(); + + const [, init] = vi.mocked(fetch).mock.calls[0]; + expect((init?.headers as Record)['User-Agent']).toBe(TEST_USER_AGENT); + }); + + it('calls getUserAgent only once across multiple uploads', async () => { + fsMocks.readdir.mockResolvedValue(['a.log', 'b.log']); + fsMocks.readFile.mockResolvedValue( + makeFileLine( + { session: { id: 'sess' }, start: 0, raw_segment_size: 1, compressed_segment_size: 1 }, + Buffer.from([0x01]) + ) + ); + + await consumer.upload(); + await consumer.upload(); + + expect(getUserAgent).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/transport/batch/replay/ReplayBatchProducer.spec.ts b/src/transport/batch/replay/ReplayBatchProducer.spec.ts new file mode 100644 index 00000000..1c936311 --- /dev/null +++ b/src/transport/batch/replay/ReplayBatchProducer.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'node:path'; +import type { BatchProducerConfig } from '../types'; +import { mockFs } from '../../../mocks.specUtil'; +import { ReplayBatchProducer } from './ReplayBatchProducer'; +import type { ReplaySegmentPayload, SegmentMetadata } from '../../../domain/replay'; + +vi.mock('node:fs/promises'); +vi.mock('@datadog/browser-core', () => ({ + dateNow: vi.fn(() => 1234567890), +})); + +const fsMocks = mockFs(); + +const config: BatchProducerConfig = { + trackPath: '/mock/replay', + batchSize: 1024 * 1024, +}; + +function makePayload(overrides: Partial = {}): ReplaySegmentPayload { + const metadata: SegmentMetadata = { + application: { id: 'app-1' }, + session: { id: 'sess-1' }, + view: { id: 'view-1' }, + start: 1000, + end: 2000, + records_count: 3, + has_full_snapshot: true, + index_in_view: 0, + source: 'browser', + creation_reason: 'init', + }; + return { + metadata, + rawBytesCount: 256, + compressed: Buffer.from([0x78, 0x9c, 0x01, 0x02, 0x03]), + ...overrides, + }; +} + +describe('ReplayBatchProducer', () => { + beforeEach(() => { + fsMocks.reset(); + fsMocks.access.mockResolvedValue(undefined); + fsMocks.mkdir.mockResolvedValue(undefined); + fsMocks.readdir.mockResolvedValue([]); + fsMocks.writeFile.mockResolvedValue(undefined); + fsMocks.rename.mockResolvedValue(undefined); + }); + + describe('create()', () => { + it('creates the track directory when it does not exist', async () => { + fsMocks.access.mockRejectedValueOnce(new Error('ENOENT')); + await ReplayBatchProducer.create(config); + expect(fsMocks.mkdir).toHaveBeenCalledWith(config.trackPath, { recursive: true }); + }); + + it('does not create directory when it already exists', async () => { + await ReplayBatchProducer.create(config); + expect(fsMocks.mkdir).not.toHaveBeenCalled(); + }); + + it('rotates orphaned .tmp files from previous sessions', async () => { + fsMocks.readdir.mockResolvedValueOnce(['batch-111.tmp', 'batch-222.tmp']); + await ReplayBatchProducer.create(config); + expect(fsMocks.rename).toHaveBeenCalledTimes(2); + }); + }); + + describe('writeData() — file format', () => { + it('writes metadata JSON as line 1 and base64-encoded compressed data as line 2', async () => { + const producer = await ReplayBatchProducer.create(config); + const payload = makePayload(); + + producer.post(payload); + await producer.flush(); + + expect(fsMocks.writeFile).toHaveBeenCalledOnce(); + const [, content] = fsMocks.writeFile.mock.calls[0] as [string, string, string]; + const lines = content.split('\n').filter(Boolean); + + expect(lines).toHaveLength(2); + + // Line 1: JSON metadata with size fields injected + const meta = JSON.parse(lines[0]) as Record; + expect((meta['session'] as { id: string }).id).toBe('sess-1'); + expect((meta['view'] as { id: string }).id).toBe('view-1'); + expect(meta['raw_segment_size']).toBe(payload.rawBytesCount); + expect(meta['compressed_segment_size']).toBe(payload.compressed.byteLength); + expect(meta['records_count']).toBe(3); + + // Line 2: base64-encoded compressed data + expect(Buffer.from(lines[1], 'base64').equals(payload.compressed)).toBe(true); + }); + + it('writes to a .tmp file then atomically renames it to .log', async () => { + const producer = await ReplayBatchProducer.create(config); + producer.post(makePayload()); + await producer.flush(); + + const [tmpPath] = fsMocks.writeFile.mock.calls[0] as [string, string, string]; + const [fromPath, toPath] = fsMocks.rename.mock.calls[fsMocks.rename.mock.calls.length - 1] as [string, string]; + + expect(tmpPath).toMatch(/\.tmp$/); + expect(fromPath).toBe(tmpPath); + expect(toPath).toMatch(/\.log$/); + expect(path.dirname(toPath)).toBe(config.trackPath); + }); + + it('each post creates a separate file', async () => { + const producer = await ReplayBatchProducer.create(config); + producer.post(makePayload()); + producer.post(makePayload()); + await producer.flush(); + + expect(fsMocks.writeFile).toHaveBeenCalledTimes(2); + expect(fsMocks.rename).toHaveBeenCalledTimes(2); + }); + }); +}); From f82eda73fc36a11a89383d5c9cada2ebcc0c0bdb Mon Sep 17 00:00:00 2001 From: "carlos.nogueira" Date: Thu, 23 Jul 2026 14:56:40 +0100 Subject: [PATCH 6/6] Review fixes --- .gitignore | 4 +- .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- README.md | 67 ++++-- .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- e2e/app/src/bridge-window.ts | 5 +- e2e/app/src/main-window.html | 2 +- e2e/app/yarn.lock | 41 ---- .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../src/renderer/index.ts | 1 + .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../src/renderer/src/main.ts | 1 + .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../electron-vite/src/renderer/src/main.ts | 1 + .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../forge-esbuild-cjs/src/renderer/index.ts | 1 + .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../forge-esbuild-esm/src/renderer/index.ts | 1 + .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../apps/forge-vite/src/renderer/index.ts | 1 + .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- .../apps/forge-webpack/src/renderer/index.ts | 1 + e2e/integration/lib/integrationFixture.ts | 1 + e2e/lib/bridgeWindowPage.ts | 12 + e2e/lib/helpers.ts | 2 + e2e/lib/intake.ts | 78 ++++++- e2e/scenarios/replay.scenario.ts | 73 +++++- .../dd-trace-npm-5.103.0-bcd76a72f0.patch | 93 -------- playground/src/main.ts | 1 + playground/src/renderer.ts | 1 + playground/yarn.lock | 41 ---- src/assembly/RendererPipeline.spec.ts | 83 ++++++- src/assembly/RendererPipeline.ts | 40 +++- src/assembly/commonContext.spec.ts | 6 +- src/assembly/commonContext.ts | 1 + src/assembly/hooks.ts | 2 + src/common/bridgeConfig.spec.ts | 1 + src/common/bridgeConfig.ts | 17 +- src/config.spec.ts | 21 ++ src/domain/replay/ReplayCollection.spec.ts | 108 +++++++-- src/domain/replay/ReplayCollection.ts | 166 ++++++++++---- src/domain/replay/Segment.spec.ts | 40 ++-- src/domain/replay/Segment.ts | 51 +++-- src/domain/replay/index.ts | 4 +- src/domain/replay/replayContext.spec.ts | 214 ++++++++++++++++++ src/domain/replay/replayContext.ts | 62 +++++ src/domain/telemetry/timer.ts | 6 + src/event/event.types.ts | 7 +- src/index.ts | 77 +++++-- src/instrument/registerBridgeConfig.spec.ts | 1 + .../replay => tools}/StreamingDeflate.spec.ts | 0 .../replay => tools}/StreamingDeflate.ts | 54 +++-- src/transport/Transport.spec.ts | 10 +- ...Consumer.spec.ts => BatchConsumer.spec.ts} | 26 +-- src/transport/batch/BatchFactory.ts | 72 ------ src/transport/batch/BatchManager.spec.ts | 23 ++ src/transport/batch/BatchManager.ts | 67 +++++- .../batch/generic/GenericBatchConsumer.ts | 57 ----- .../batch/generic/GenericBatchProducer.ts | 38 ---- .../batch/replay/ReplayBatchConsumer.spec.ts | 135 ++++++----- .../batch/replay/ReplayBatchConsumer.ts | 79 ++++--- .../batch/replay/ReplayBatchProducer.spec.ts | 29 ++- .../batch/replay/ReplayBatchProducer.ts | 18 +- .../standard/StandardBatchProducer.spec.ts | 66 ------ src/transport/batch/types.ts | 34 --- src/transport/utils.spec.ts | 31 ++- src/transport/utils.ts | 33 +++ yarn.lock | 41 ---- 67 files changed, 1318 insertions(+), 1667 deletions(-) delete mode 100644 .yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/electron-builder-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/electron-vite-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/electron-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/forge-esbuild-cjs/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/forge-esbuild-esm/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/forge-vite/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 e2e/integration/apps/forge-webpack/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch delete mode 100644 playground/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch create mode 100644 src/domain/replay/replayContext.spec.ts create mode 100644 src/domain/replay/replayContext.ts rename src/{domain/replay => tools}/StreamingDeflate.spec.ts (100%) rename src/{domain/replay => tools}/StreamingDeflate.ts (79%) rename src/transport/batch/{generic/GenericBatchConsumer.spec.ts => BatchConsumer.spec.ts} (77%) delete mode 100644 src/transport/batch/BatchFactory.ts delete mode 100644 src/transport/batch/generic/GenericBatchConsumer.ts delete mode 100644 src/transport/batch/generic/GenericBatchProducer.ts delete mode 100644 src/transport/batch/types.ts diff --git a/.gitignore b/.gitignore index 9cf2da17..2bd3e039 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,7 @@ playwright-report/ # Dependencies & Yarn **/node_modules/ -**/.yarn/* -!**/.yarn/patches/ -!**/.yarn/patches/** +**/.yarn/ # IA settings /.claude/settings.local.json diff --git a/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch deleted file mode 100644 index 66a01268..00000000 --- a/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch +++ /dev/null @@ -1,93 +0,0 @@ -diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js -index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 ---- a/packages/datadog-instrumentations/src/electron/preload.js -+++ b/packages/datadog-instrumentations/src/electron/preload.js -@@ -1,42 +1,66 @@ --'use strict' -+"use strict"; - - // eslint-disable-next-line n/no-missing-require --const { contextBridge, ipcRenderer } = require('electron') -+const { contextBridge, ipcRenderer } = require("electron"); - --const BRIDGE_CHANNEL = 'datadog:bridge-send' --const CONFIG_CHANNEL = 'datadog:bridge-config' -+const BRIDGE_CHANNEL = "datadog:bridge-send"; -+const CONFIG_CHANNEL = "datadog:bridge-config"; - - // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel --const MASK = 'mask' -+const MASK = "mask"; - --const config = ipcRenderer.sendSync(CONFIG_CHANNEL) -+const bridgeState = { -+ defaultPrivacyLevel: MASK, -+ allowedHosts: [location.hostname], -+}; - --const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK --const configuredHosts = config?.allowedWebViewHosts ?? [] --// eslint-disable-next-line no-undef --const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] -+function applyConfig(config) { -+ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; -+ -+ bridgeState.allowedHosts = [ -+ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), -+ ]; -+} -+ -+async function loadConfigWithRetry() { -+ const maxAttempts = 50; -+ const retryDelayMs = 100; -+ -+ for (let attempt = 0; attempt < maxAttempts; attempt++) { -+ try { -+ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); -+ applyConfig(config); -+ return; -+ } catch { -+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); -+ } -+ } -+ -+ // Keep safe defaults if config never becomes available. -+} - - const bridge = { -- getCapabilities () { -- return '[]' -+ getCapabilities() { -+ return '["records"]'; - }, -- getPrivacyLevel () { -- return defaultPrivacyLevel -+ getPrivacyLevel() { -+ return bridgeState.defaultPrivacyLevel; - }, -- getAllowedWebViewHosts () { -- return JSON.stringify(allowedHosts) -+ getAllowedWebViewHosts() { -+ return JSON.stringify(bridgeState.allowedHosts); - }, -- send (msg) { -- ipcRenderer.send(BRIDGE_CHANNEL, msg) -+ send(msg) { -+ ipcRenderer.send(BRIDGE_CHANNEL, msg); - }, --} -+}; - - // Support both contextIsolation enabled (default) and disabled -- --window.DatadogEventBridge = bridge -+window.DatadogEventBridge = bridge; - - try { -- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) -+ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); - } catch { - // exposeInMainWorld throws when contextIsolation is disabled - } -+ -+loadConfigWithRetry(); diff --git a/README.md b/README.md index a9480af9..e7064a13 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ await esbuild.build({ - **Renderer Events** — Capture RUM events from renderer processes via the browser SDK - **Custom Duration Vitals** — Measure user-defined durations in the main process - **Renderer Profiling** — Collect JS Self-Profiling data from renderer pages and correlate it with RUM +- **Session Replay** — Record renderer UI sessions in the main process and correlate them with RUM views - **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 @@ -245,6 +246,41 @@ protocol.registerSchemesAsPrivileged([ ]); ``` +### Session Replay + +The Browser SDK records renderer UI ([Session Replay](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/)) and streams the records to the main process, which buffers, compresses, and uploads them — correlated with your RUM views. + +**1. Enable session replay from the main process.** Set `sessionReplaySampleRate` on the Electron SDK `init()`: + +```ts +await init({ + clientToken: '', + applicationId: '', + site: 'datadoghq.com', + service: 'my-electron-app', + sessionReplaySampleRate: 100, // percentage of sampled sessions that record replay (0–100) + defaultPrivacyLevel: 'mask', // 'mask' | 'allow' | 'mask-user-input' +}); +``` + +The Electron SDK owns the replay sampling decision and performs the upload. `sessionReplaySampleRate` is applied as a child of `sessionSampleRate`, so with `sessionSampleRate: 0` no session records replay. The `defaultPrivacyLevel` set here is propagated to the renderer recorder through the bridge, so masking is enforced end-to-end. + +**2. Enable session replay in the renderer's Browser SDK.** In the pages loaded by the renderer, initialize `@datadog/browser-rum` with session replay turned on: + +```ts +import { datadogRum } from '@datadog/browser-rum'; + +datadogRum.init({ + clientToken: '', + applicationId: '', + site: 'datadoghq.com', + service: 'my-electron-app', + sessionReplaySampleRate: 100, +}); +``` + +The Electron SDK advertises a `records` capability over the bridge, so the Browser SDK streams replay records to the main process instead of uploading them itself — no `startSessionReplayRecording()` call is required. See [Renderer process setup](#renderer-process-setup) for general Browser SDK configuration. + ## API ### `init(config: InitConfiguration): Promise` @@ -375,18 +411,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`. | +| `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 | diff --git a/e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch b/e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch deleted file mode 100644 index 66a01268..00000000 --- a/e2e/app/.yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch +++ /dev/null @@ -1,93 +0,0 @@ -diff --git a/packages/datadog-instrumentations/src/electron/preload.js b/packages/datadog-instrumentations/src/electron/preload.js -index df62472e9f5b6b671afad3720edc3c24668e074b..864f70e2aa6097cc6b05d3a7e6e325c18d96d713 100644 ---- a/packages/datadog-instrumentations/src/electron/preload.js -+++ b/packages/datadog-instrumentations/src/electron/preload.js -@@ -1,42 +1,66 @@ --'use strict' -+"use strict"; - - // eslint-disable-next-line n/no-missing-require --const { contextBridge, ipcRenderer } = require('electron') -+const { contextBridge, ipcRenderer } = require("electron"); - --const BRIDGE_CHANNEL = 'datadog:bridge-send' --const CONFIG_CHANNEL = 'datadog:bridge-config' -+const BRIDGE_CHANNEL = "datadog:bridge-send"; -+const CONFIG_CHANNEL = "datadog:bridge-config"; - - // Privacy levels matching @datadog/browser-core DefaultPrivacyLevel --const MASK = 'mask' -+const MASK = "mask"; - --const config = ipcRenderer.sendSync(CONFIG_CHANNEL) -+const bridgeState = { -+ defaultPrivacyLevel: MASK, -+ allowedHosts: [location.hostname], -+}; - --const defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK --const configuredHosts = config?.allowedWebViewHosts ?? [] --// eslint-disable-next-line no-undef --const allowedHosts = [...new Set([location.hostname, ...configuredHosts])] -+function applyConfig(config) { -+ bridgeState.defaultPrivacyLevel = config?.defaultPrivacyLevel ?? MASK; -+ -+ bridgeState.allowedHosts = [ -+ ...new Set([location.hostname, ...(config?.allowedWebViewHosts ?? [])]), -+ ]; -+} -+ -+async function loadConfigWithRetry() { -+ const maxAttempts = 50; -+ const retryDelayMs = 100; -+ -+ for (let attempt = 0; attempt < maxAttempts; attempt++) { -+ try { -+ const config = await ipcRenderer.invoke(CONFIG_CHANNEL); -+ applyConfig(config); -+ return; -+ } catch { -+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); -+ } -+ } -+ -+ // Keep safe defaults if config never becomes available. -+} - - const bridge = { -- getCapabilities () { -- return '[]' -+ getCapabilities() { -+ return '["records"]'; - }, -- getPrivacyLevel () { -- return defaultPrivacyLevel -+ getPrivacyLevel() { -+ return bridgeState.defaultPrivacyLevel; - }, -- getAllowedWebViewHosts () { -- return JSON.stringify(allowedHosts) -+ getAllowedWebViewHosts() { -+ return JSON.stringify(bridgeState.allowedHosts); - }, -- send (msg) { -- ipcRenderer.send(BRIDGE_CHANNEL, msg) -+ send(msg) { -+ ipcRenderer.send(BRIDGE_CHANNEL, msg); - }, --} -+}; - - // Support both contextIsolation enabled (default) and disabled -- --window.DatadogEventBridge = bridge -+window.DatadogEventBridge = bridge; - - try { -- contextBridge.exposeInMainWorld('DatadogEventBridge', bridge) -+ contextBridge.exposeInMainWorld("DatadogEventBridge", bridge); - } catch { - // exposeInMainWorld throws when contextIsolation is disabled - } -+ -+loadConfigWithRetry(); diff --git a/e2e/app/src/bridge-window.ts b/e2e/app/src/bridge-window.ts index 5637c55c..2adb51cc 100644 --- a/e2e/app/src/bridge-window.ts +++ b/e2e/app/src/bridge-window.ts @@ -5,12 +5,11 @@ datadogRum.init({ clientToken: 'pub-renderer-token', site: 'datadoghq.com', service: 'e2e-renderer', - sessionSampleRate: 100, - profilingSampleRate: 100, - sessionReplaySampleRate: 100, trackResources: true, trackLongTasks: true, trackUserInteractions: true, + profilingSampleRate: 100, + sessionReplaySampleRate: 100, }); document.getElementById('status')!.textContent = 'bridge-ready'; diff --git a/e2e/app/src/main-window.html b/e2e/app/src/main-window.html index e2e05b9e..1f786bb1 100644 --- a/e2e/app/src/main-window.html +++ b/e2e/app/src/main-window.html @@ -5,7 +5,7 @@ Main Window