Repository navigation
✨ Persist tracking consent for crash recovery #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0e26c0c
290b119
d5cbbf1
ff248b5
165187a
ab08ef2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| }); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the Browser RUM SDK is already running in a window before a deferred main-process Useful? React with 👍 / 👎. |
||
| }); | ||
|
|
||
| return manager; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a fresh launch, constructing the manager only queues the initial consent snapshot through 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); | ||
| } | ||
|
|
@@ -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. */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
_dd_tracking_consent_historycontains syntactically valid JSON such as[null], this new call reachesDiskValueHistory.init(), whose restore loop dereferencesentry.endTimeoutside its read/parsetryblock (src/tools/DiskValueHistory.ts:49-55). The resulting exception rejects the top-level SDKinit()instead of falling back to an empty consent history. The current revision therefore retains the earlier malformed-history failure through the newly introducedDiskValueHistorypath even though the intermediateTrackingConsentHistoryimplementation is gone; validate restored entries or catch restoration errors before constructing the manager.Useful? React with 👍 / 👎.