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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,12 @@ When `enableExecutionContext` is set, `ExecutionContextCollection` (`src/domain/

## Internal Tracking Consent State

`TrackingConsentManager` starts in `granted` when explicitly constructed. Its timestamp history
is kept in memory from construction onward; it neither persists consent nor infers consent from an earlier process.
Changes notify monitored subscribers synchronously after updating the state.
Consumers own their subscriptions and unsubscribe when stopped.
`TrackingConsentManager` defaults to `granted`. Changes notify monitored subscribers synchronously after updating
the state. Consumers own their subscriptions and unsubscribe when stopped.

History lookups return the state active at the requested time. For example, a past `pending` period still returns
`pending` after the current state changes to `granted` or `not-granted`.
Its timestamped history is persisted like the session and view histories, so that an event is judged by the
consent of its own time, even when it is assembled at a later launch. History lookups return the state active at
the requested time; a past `pending` period still returns `pending` after a decision.

### Consent and batch storage

Expand Down Expand Up @@ -198,6 +197,22 @@ The existing best-effort limit of 100 completed batches applies to the authorize
of 100 applies across all remaining pending and migration directories together, so creating new periods
does not multiply the allowance. Open files and filesystem failures can temporarily exceed these limits.

### Consent at assembly

Batch storage applies the consent current when an event is written. That is not enough for events recovered from
an earlier launch: a crash is written at the next launch, and its saved session, view and customer context is not
proof that it was captured with consent.

A RUM format hook therefore discards events whose start time falls in a refused period, or in no known period.
A `pending` period takes the decision that ended it, like its pending batches: a grant keeps its events, and a
refusal discards them even if consent is granted again later. Events of a still undecided `pending` period are left
to the batch storage. A new process cannot grant the previous one's undecided period, so startup records it as
refused, as it deletes its pending batches. Recovered crashes are then stored according to the current consent,
like other events.

Consent changes reach the disk asynchronously: a crash that follows a refusal before it is written is accepted at
the next launch.

## Error Reporting

Failures are routed by _who can act on them_:
Expand Down
80 changes: 79 additions & 1 deletion src/domain/tracking-consent/TrackingConsentManager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import * as fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { TimeStamp } from '@datadog/js-core/time';
import { EventKind, EventManager, type RawEvent } from '../../event';
import { DISCARDED } from '@datadog/js-core/assembly';
import { createFormatHooks } from '../../assembly';
import { EventKind, EventManager, EventSource, type RawEvent } from '../../event';
import { createTestConfiguration } from '../../mocks.specUtil';
import { startTelemetry, stopTelemetry } from '../telemetry';
import { TrackingConsentManager, type TrackingConsent, type TrackingConsentChange } from './index';

vi.mock('node:fs/promises');
vi.mock('electron', () => ({ app: { getPath: () => '/mock/user/data' } }));

describe('TrackingConsentManager', () => {
let manager: TrackingConsentManager;

Expand Down Expand Up @@ -132,3 +138,75 @@ describe('TrackingConsentManager', () => {
expect(otherObserver).not.toHaveBeenCalled();
});
});

describe('persisted tracking consent', () => {
let file: string;

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1000);
file = '[]';
vi.mocked(fs.readFile).mockImplementation(() => Promise.resolve(file));
vi.mocked(fs.writeFile).mockImplementation((_path, data) => {
file = data as string;
return Promise.resolve();
});
});

afterEach(() => {
vi.resetAllMocks();
vi.useRealTimers();
});

async function launch(initialConsent?: TrackingConsent) {
// Let the previous launch finish writing its history.
await vi.advanceTimersByTimeAsync(0);
const hooks = createFormatHooks();
const manager = await TrackingConsentManager.init(hooks, initialConsent);
const isDiscardedAt = (time: number) =>
hooks.triggerRum({ eventType: 'error', startTime: time as TimeStamp, source: EventSource.MAIN }) === DISCARDED;
return { manager, isDiscardedAt };
}

it('checks events against the consent of their own launch without restoring it as the current state', async () => {
await launch();
vi.setSystemTime(1020);
const { manager, isDiscardedAt } = await launch('pending');

expect(manager.get()).toBe('pending');
expect(isDiscardedAt(1010)).toBe(false);
expect(isDiscardedAt(1020)).toBe(false);
expect(isDiscardedAt(999)).toBe(true);
});

it('refuses a pending period left undecided by the previous launch', async () => {
await launch('pending');
vi.setSystemTime(1020);
const { manager, isDiscardedAt } = await launch();

expect(isDiscardedAt(1010)).toBe(true);
manager.update('not-granted');
manager.update('granted');
expect(isDiscardedAt(1010)).toBe(true);

vi.setSystemTime(1040);
expect((await launch()).isDiscardedAt(1010)).toBe(true);
});

it.each<TrackingConsent>(['granted', 'not-granted'])(
'applies the first %s decision to a pending period, including after a restart',
async (decision) => {
const { manager, isDiscardedAt } = await launch('pending');
expect(isDiscardedAt(1000)).toBe(false);

vi.setSystemTime(1020);
manager.update(decision);
vi.setSystemTime(1030);
manager.update(decision === 'granted' ? 'not-granted' : 'granted');
expect(isDiscardedAt(1010)).toBe(decision === 'not-granted');

vi.setSystemTime(1040);
expect((await launch()).isDiscardedAt(1010)).toBe(decision === 'not-granted');
}
);
});
64 changes: 56 additions & 8 deletions src/domain/tracking-consent/TrackingConsentManager.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,70 @@
import { app } from 'electron';
import * as path from 'node:path';
import { Observable, type Subscription } from '@datadog/browser-core';
import { DISCARDED, SKIPPED } from '@datadog/js-core/assembly';
import { timeStampNow, type TimeStamp } from '@datadog/js-core/time';
import type { FormatHooks } from '../../assembly';
import { DiskValueHistory } from '../../tools/DiskValueHistory';
import { TimeStampValueHistory } from '../../tools/TimeStampValueHistory';
import { SESSION_TIME_OUT_DELAY } from '../session';
import { monitor } from '../telemetry';

export const TRACKING_CONSENT_HISTORY_FILE_NAME = '_dd_tracking_consent_history';

type ConsentHistory = Pick<TimeStampValueHistory<TrackingConsent>, 'add' | 'closeActive' | 'find' | 'getEntries'>;

/**
* Owns the internal consent state and its in-memory history for one SDK instance.
* Observers run synchronously after a transition.
* Collection and storage remain the responsibility of consumers.
* Owns the consent state and its timestamped history, with synchronous change notifications.
* When initialized for the SDK, the history is persisted, and assembly discards RUM events captured during a
* refused period, including events recovered at a later launch, such as crashes.
*/
export class TrackingConsentManager {
private readonly history = new TimeStampValueHistory<TrackingConsent>({ expireDelay: Infinity });
private readonly changes = new Observable<TrackingConsentChange>();

constructor() {
this.history.add('granted', timeStampNow());
constructor(
initialConsent: TrackingConsent = 'granted',
private readonly history: ConsentHistory = new TimeStampValueHistory<TrackingConsent>({ expireDelay: Infinity })
) {
this.record(initialConsent, timeStampNow());
}

static async init(hooks: FormatHooks, initialConsent: TrackingConsent = 'granted'): Promise<TrackingConsentManager> {
const history = await DiskValueHistory.init<TrackingConsent>({
filePath: path.join(app.getPath('userData'), TRACKING_CONSENT_HISTORY_FILE_NAME),
expireDelay: SESSION_TIME_OUT_DELAY,
});
Comment on lines +32 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat malformed consent history as absent

When _dd_tracking_consent_history contains syntactically valid JSON such as [null], this new call reaches DiskValueHistory.init(), whose restore loop dereferences entry.endTime outside its read/parse try block (src/tools/DiskValueHistory.ts:49-55). The resulting exception rejects the top-level SDK init() instead of falling back to an empty consent history. The current revision therefore retains the earlier malformed-history failure through the newly introduced DiskValueHistory path even though the intermediate TrackingConsentHistory implementation is gone; validate restored entries or catch restoration errors before constructing the manager.

Useful? React with 👍 / 👎.

const now = timeStampNow();
// A new process cannot grant the previous one's undecided period, whose pending batches it deletes.
if (history.find(now) === 'pending') {
history.closeAndAdd('not-granted', now);
}
const manager = new TrackingConsentManager(initialConsent, history);

hooks.registerRum(({ startTime }) => {
// Events of an undecided pending period are held by the batch storage until its decision.
const consent = manager.getDecisionAt(startTime);
return consent === 'granted' || consent === 'pending' ? SKIPPED : DISCARDED;
Comment on lines +43 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rotate pre-init renderer views when consent history starts

When the Browser RUM SDK is already running in a window before a deferred main-process init(), the active renderer view keeps a date from before this manager was created. RendererPipeline passes that unchanged date as startTime for every later update of the view, so this callback finds no covering consent entry and discards every update even after initialization; meanwhile post-init actions and errors can still be uploaded with that view ID, leaving them attached to a view document that never arrives until navigation or session renewal creates a new view. The documented pre-init bridge supports such existing windows, so initialization needs to rotate/rebase their active views or otherwise establish a consent boundary they can use.

Useful? React with 👍 / 👎.

});

return manager;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Await the initial consent snapshot before returning

On a fresh launch, constructing the manager only queues the initial consent snapshot through DiskValueHistory.add(); this method returns before its fs.writeFile promise settles, and init() provides no flush before returning. If the native process crashes shortly after the SDK reports successful initialization, the history file can still be missing or incomplete, so the next launch finds no consent interval covering that crash and discards the recovered report. Await the initial snapshot before completing initialization so the crash-recovery feature is effective immediately.

Useful? React with 👍 / 👎.

}

/** Consent applying to data captured at a time: a past pending period takes the state that ended it. */
private getDecisionAt(time: TimeStamp): TrackingConsent | undefined {
const entries = this.history.getEntries();
const index = entries.findIndex((entry) => entry.startTime <= time && time < entry.endTime);
if (index === -1) {
return undefined;
}
const { value } = entries[index];
return value === 'pending' && index > 0 ? entries[index - 1].value : value;
}

get(): TrackingConsent {
return this.history.find(timeStampNow())!;
}

/** Original consent at capture time, or undefined before this manager was created. */
/** Original consent at a time, or undefined when no history covers it. */
getAt(time: TimeStamp): TrackingConsent | undefined {
return this.history.find(time);
}
Expand All @@ -33,9 +77,13 @@ export class TrackingConsentManager {
}

const time = timeStampNow();
this.record(consent, time);
this.changes.notify({ previous, current: consent, time });
}

private record(consent: TrackingConsent, time: TimeStamp): void {
this.history.closeActive(time);
this.history.add(consent, time);
this.changes.notify({ previous, current: consent, time });
}

/** Subscribe to future changes. A failing observer must not interrupt other consumers. */
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export async function init(configuration: InitConfiguration): Promise<boolean> {
return false;
}

const trackingConsentManager = new TrackingConsentManager();
tracing = new Tracing(config);

eventManager = new EventManager();
const hooks = createFormatHooks();
const trackingConsentManager = await TrackingConsentManager.init(hooks);

registerCommonContext(config, hooks);
userContext = await UserContext.init(hooks);
Expand Down
Loading