Skip to content

Commit 37adee8

Browse files
RUM-15238: fix RUM events associated to prior view
1 parent 3433cc8 commit 37adee8

6 files changed

Lines changed: 1118 additions & 17 deletions

File tree

packages/core/src/sdk/DatadogProvider/Buffer/BufferSingleton.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,32 @@ import { getGlobalInstance } from '../../../utils/singletonUtils';
88

99
import { BoundedBuffer } from './BoundedBuffer';
1010
import type { DatadogBuffer } from './DatadogBuffer';
11+
import { NavigationBuffer } from './NavigationBuffer';
1112
import { PassThroughBuffer } from './PassThroughBuffer';
1213

14+
// IMPORTANT: Keep this key aligned with the react-navigation package
1315
const BUFFER_SINGLETON_MODULE = 'com.datadog.reactnative.buffer_singleton';
1416

1517
class _BufferSingleton {
1618
private bufferInstance: DatadogBuffer = new BoundedBuffer();
19+
private navigationBuffer: NavigationBuffer | null = null;
1720

1821
getInstance = (): DatadogBuffer => {
1922
return BufferSingleton.bufferInstance;
2023
};
2124

25+
getNavigationBuffer = (): NavigationBuffer | null => {
26+
return this.navigationBuffer;
27+
};
28+
2229
onInitialization = () => {
2330
this.bufferInstance.drain();
24-
this.bufferInstance = new PassThroughBuffer();
31+
this.navigationBuffer = new NavigationBuffer(new PassThroughBuffer());
32+
this.bufferInstance = this.navigationBuffer;
2533
};
2634

2735
reset = () => {
36+
this.navigationBuffer = null;
2837
BufferSingleton.bufferInstance = new BoundedBuffer();
2938
};
3039
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
import { DatadogBuffer } from './DatadogBuffer';
8+
9+
/**
10+
* Safety timeout (ms): auto-drains the buffer if `onStateChange` never fires
11+
* after a navigation dispatch.
12+
*/
13+
export const NAVIGATION_BUFFER_TIMEOUT_MS = 500;
14+
15+
// TODO: DEBUG LOGGING — remove before shipping
16+
const LOG = (msg: string, ...args: any[]) =>
17+
// eslint-disable-next-line no-console
18+
console.log(`[DD NavBuffer] ${new Date().toISOString()} ${msg}`, ...args);
19+
20+
/**
21+
* An internal `DatadogBuffer` decorator that queues RUM events during a
22+
* navigation transition and flushes them once the new view is confirmed.
23+
*
24+
* **IMPORTANT**
25+
* Any changes to the public methods of this class must be reflected in
26+
* the interface definition of the react-navigation package.
27+
*
28+
* **Lifecycle**
29+
* 1. `startNavigation()` — called when a navigation action is dispatched
30+
* (via the `__unsafe_action__` listener). Starts buffering all incoming
31+
* RUM events and records `navigationStartTime` so the view-start can be
32+
* backdated to the moment the user triggered the navigation.
33+
* A safety timeout (`NAVIGATION_BUFFER_TIMEOUT_MS`) automatically calls
34+
* `endNavigation()` if the state-change callback never fires.
35+
* 2. `prepareEndNavigation()` — called just before `DdRum.startView()`.
36+
* Stops accepting new events into the queue (so `startView` itself passes
37+
* through immediately) but keeps the queue intact and preserves
38+
* `navigationStartTime` for the caller to read.
39+
* 3. `flush()` — called after `startView()` resolves. Drains queued events
40+
* to the inner buffer so they are attributed to the new view.
41+
* 4. `endNavigation()` — stop-and-drain shortcut used by the safety timeout,
42+
* teardown (`stopTrackingViews`), and any path where no `startView` fires
43+
* (background state, predicate returning false, undefined route, etc.).
44+
*
45+
* **Integration point**
46+
* `BufferSingleton.onInitialization()` installs a `NavigationBuffer` wrapping
47+
* a `PassThroughBuffer` as the active SDK buffer. The react-navigation package
48+
* accesses it via `getGlobalInstance` using the shared
49+
* `'com.datadog.reactnative.buffer_singleton'` key — no public export needed.
50+
*
51+
* @internal
52+
*/
53+
export class NavigationBuffer extends DatadogBuffer {
54+
private innerBuffer: DatadogBuffer;
55+
private isNavigating = false;
56+
private callbackQueue: Array<() => void> = [];
57+
private timeoutId: ReturnType<typeof setTimeout> | null = null;
58+
private _navigationStartTime: number | null = null;
59+
60+
/**
61+
* The timestamp (ms since epoch) captured when startNavigation() was called.
62+
* Use this as the timestampMs for DdRum.startView() so the view start reflects
63+
* when the user initiated navigation, not when onStateChange fired.
64+
* Null when no navigation is in progress.
65+
*/
66+
get navigationStartTime(): number | null {
67+
return this._navigationStartTime;
68+
}
69+
70+
constructor(innerBuffer: DatadogBuffer) {
71+
super();
72+
this.innerBuffer = innerBuffer;
73+
LOG('constructed', { innerBuffer: innerBuffer.constructor.name });
74+
}
75+
76+
addCallback = (callback: () => Promise<void>): Promise<void> => {
77+
if (!this.isNavigating) {
78+
LOG('addCallback → passthrough');
79+
return this.innerBuffer.addCallback(callback);
80+
}
81+
LOG(
82+
'addCallback → QUEUED, queueLength now',
83+
this.callbackQueue.length + 1
84+
);
85+
this.callbackQueue.push(() => {
86+
this.innerBuffer.addCallback(callback);
87+
});
88+
return Promise.resolve();
89+
};
90+
91+
addCallbackReturningId = (
92+
callback: () => Promise<string>
93+
): Promise<string> => {
94+
if (!this.isNavigating) {
95+
LOG('addCallbackReturningId → passthrough');
96+
return this.innerBuffer.addCallbackReturningId(callback);
97+
}
98+
LOG(
99+
'addCallbackReturningId → QUEUED, queueLength now',
100+
this.callbackQueue.length + 1
101+
);
102+
return new Promise<string>(resolve => {
103+
this.callbackQueue.push(() => {
104+
this.innerBuffer.addCallbackReturningId(callback).then(resolve);
105+
});
106+
});
107+
};
108+
109+
addCallbackWithId = (
110+
callback: (id: string) => Promise<void>,
111+
id: string
112+
): Promise<void> => {
113+
if (!this.isNavigating) {
114+
LOG('addCallbackWithId → passthrough, id:', id);
115+
return this.innerBuffer.addCallbackWithId(callback, id);
116+
}
117+
LOG(
118+
'addCallbackWithId → QUEUED, id:',
119+
id,
120+
'queueLength now',
121+
this.callbackQueue.length + 1
122+
);
123+
return new Promise<void>(resolve => {
124+
this.callbackQueue.push(() => {
125+
this.innerBuffer.addCallbackWithId(callback, id).then(resolve);
126+
});
127+
});
128+
};
129+
130+
drain = (): void => {
131+
LOG(
132+
'drain() called, queueLength:',
133+
this.callbackQueue.length,
134+
'isNavigating:',
135+
this.isNavigating
136+
);
137+
this.flushQueue();
138+
this.innerBuffer.drain();
139+
};
140+
141+
startNavigation = (): void => {
142+
const wasAlreadyNavigating = this.isNavigating;
143+
if (this.timeoutId !== null) {
144+
clearTimeout(this.timeoutId);
145+
}
146+
// Only capture the start time on the first navigation start; preserve it
147+
// across rapid re-navigations so the timestamp reflects the original intent.
148+
if (!wasAlreadyNavigating) {
149+
this._navigationStartTime = Date.now();
150+
}
151+
this.isNavigating = true;
152+
this.timeoutId = setTimeout(() => {
153+
LOG(
154+
`timeout fired after ${NAVIGATION_BUFFER_TIMEOUT_MS}ms — calling endNavigation`
155+
);
156+
this.endNavigation();
157+
}, NAVIGATION_BUFFER_TIMEOUT_MS);
158+
LOG('startNavigation()', {
159+
wasAlreadyNavigating,
160+
navigationStartTime: this._navigationStartTime,
161+
queueLength: this.callbackQueue.length,
162+
timeoutMs: NAVIGATION_BUFFER_TIMEOUT_MS
163+
});
164+
};
165+
166+
/**
167+
* Stop accepting new events into the buffer and cancel any pending timeout,
168+
* WITHOUT draining the queue. Use this before calling DdRum.startView() so
169+
* that startView() itself passes through immediately. Then call flush() after
170+
* startView resolves to send queued events to the now-active view.
171+
*
172+
* Contrast with endNavigation(), which stops AND drains immediately (used by
173+
* timeout auto-drain and teardown paths).
174+
*/
175+
prepareEndNavigation = (): void => {
176+
if (this.timeoutId !== null) {
177+
clearTimeout(this.timeoutId);
178+
this.timeoutId = null;
179+
}
180+
this.isNavigating = false;
181+
LOG(
182+
'prepareEndNavigation() — stopped buffering, queue preserved, navigationStartTime:',
183+
this._navigationStartTime,
184+
'queueLength:',
185+
this.callbackQueue.length
186+
);
187+
// Note: _navigationStartTime is intentionally kept until flush() so the
188+
// caller can still read it after prepareEndNavigation() returns.
189+
};
190+
191+
/**
192+
* Drain the queued events to the inner buffer. Call this after startView()
193+
* resolves to flush buffered events to the new view.
194+
*
195+
* Safe to call when the queue is empty (no-op).
196+
*/
197+
flush = (): void => {
198+
const now = Date.now();
199+
const lag =
200+
this._navigationStartTime !== null
201+
? now - this._navigationStartTime
202+
: null;
203+
LOG(
204+
'flush() called — draining',
205+
this.callbackQueue.length,
206+
'queued events | navigationStartTime:',
207+
this._navigationStartTime,
208+
'| now:',
209+
now,
210+
'| lag since nav start:',
211+
lag !== null ? `${lag}ms` : 'n/a'
212+
);
213+
this._navigationStartTime = null;
214+
this.flushQueue();
215+
};
216+
217+
/**
218+
* Stop buffering and drain the queue immediately. Used by:
219+
* - Timeout auto-drain (navigation never completed)
220+
* - Teardown (stopTrackingViews)
221+
*/
222+
endNavigation = (): void => {
223+
if (this.timeoutId !== null) {
224+
clearTimeout(this.timeoutId);
225+
this.timeoutId = null;
226+
}
227+
this.isNavigating = false;
228+
const now = Date.now();
229+
const lag =
230+
this._navigationStartTime !== null
231+
? now - this._navigationStartTime
232+
: null;
233+
LOG(
234+
'endNavigation() — draining',
235+
this.callbackQueue.length,
236+
'queued events | navigationStartTime:',
237+
this._navigationStartTime,
238+
'| now:',
239+
now,
240+
'| lag since nav start:',
241+
lag !== null ? `${lag}ms` : 'n/a'
242+
);
243+
this._navigationStartTime = null;
244+
this.flushQueue();
245+
LOG('endNavigation() done');
246+
};
247+
248+
private flushQueue = (): void => {
249+
const pending = this.callbackQueue;
250+
this.callbackQueue = [];
251+
LOG('flushQueue() executing', pending.length, 'queued callbacks');
252+
for (const callback of pending) {
253+
callback();
254+
}
255+
};
256+
}

packages/core/src/sdk/DatadogProvider/Buffer/__tests__/BufferSingleton.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { BufferSingleton } from '../BufferSingleton';
8+
import { NavigationBuffer } from '../NavigationBuffer';
89

910
const flushPromises = () =>
1011
new Promise<void>(jest.requireActual('timers').setImmediate);
@@ -48,4 +49,54 @@ describe('BufferSingleton', () => {
4849
expect(callbackWithId).toHaveBeenCalledWith('callbackId');
4950
});
5051
});
52+
53+
describe('NavigationBuffer wiring', () => {
54+
afterEach(() => {
55+
BufferSingleton.reset();
56+
});
57+
58+
it('getNavigationBuffer returns null before initialization', () => {
59+
expect(BufferSingleton.getNavigationBuffer()).toBeNull();
60+
});
61+
62+
it('getNavigationBuffer returns NavigationBuffer after initialization', () => {
63+
BufferSingleton.onInitialization();
64+
const navBuffer = BufferSingleton.getNavigationBuffer();
65+
expect(navBuffer).toBeInstanceOf(NavigationBuffer);
66+
});
67+
68+
it('getInstance returns the NavigationBuffer after initialization', () => {
69+
BufferSingleton.onInitialization();
70+
const instance = BufferSingleton.getInstance();
71+
expect(instance).toBeInstanceOf(NavigationBuffer);
72+
});
73+
74+
it('NavigationBuffer passes through callbacks after initialization (not navigating)', async () => {
75+
BufferSingleton.onInitialization();
76+
const cb = jest.fn().mockResolvedValue(undefined);
77+
BufferSingleton.getInstance().addCallback(cb);
78+
expect(cb).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it('NavigationBuffer holds callbacks during navigation after initialization', async () => {
82+
BufferSingleton.onInitialization();
83+
const navBuffer = BufferSingleton.getNavigationBuffer()!;
84+
const cb = jest.fn().mockResolvedValue(undefined);
85+
86+
navBuffer.startNavigation();
87+
BufferSingleton.getInstance().addCallback(cb);
88+
expect(cb).not.toHaveBeenCalled();
89+
90+
navBuffer.endNavigation();
91+
expect(cb).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it('reset clears navigationBuffer reference', () => {
95+
BufferSingleton.onInitialization();
96+
expect(BufferSingleton.getNavigationBuffer()).not.toBeNull();
97+
98+
BufferSingleton.reset();
99+
expect(BufferSingleton.getNavigationBuffer()).toBeNull();
100+
});
101+
});
51102
});

0 commit comments

Comments
 (0)