Skip to content

Commit 94f0dee

Browse files
feat: Adjust SR Timestamps to NR Server Time (#939)
1 parent 548d7b4 commit 94f0dee

File tree

8 files changed

+177
-54
lines changed

8 files changed

+177
-54
lines changed

src/features/session_replay/aggregate/index.js

+12-8
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export class Aggregate extends AggregateBase {
5050

5151
/** set by BCS response */
5252
this.entitled = false
53+
/** set at BCS response, stored in runtime */
54+
this.timeKeeper = undefined
5355

5456
this.recorder = args?.recorder
5557
if (this.recorder) this.recorder.parent = this
@@ -179,7 +181,8 @@ export class Aggregate extends AggregateBase {
179181
// we are not actively recording SR... DO NOT import or run the recording library
180182
// session replay samples can only be decided on the first load of a session
181183
// session replays can continue if already in progress
182-
const { session } = getRuntime(this.agentIdentifier)
184+
const { session, timeKeeper } = getRuntime(this.agentIdentifier)
185+
this.timeKeeper = timeKeeper
183186
if (!session.isNew && !ignoreSession) { // inherit the mode of the existing session
184187
this.mode = session.state.sessionReplayMode
185188
} else {
@@ -241,7 +244,7 @@ export class Aggregate extends AggregateBase {
241244
}
242245

243246
prepareHarvest ({ opts } = {}) {
244-
if (!this.recorder) return
247+
if (!this.recorder || !this.timeKeeper?.ready) return
245248
const recorderEvents = this.recorder.getEvents()
246249
// get the event type and use that to trigger another harvest if needed
247250
if (!recorderEvents.events.length || (this.mode !== MODE.FULL) || this.blocked) return
@@ -254,7 +257,10 @@ export class Aggregate extends AggregateBase {
254257

255258
let len = 0
256259
if (!!this.gzipper && !!this.u8) {
257-
payload.body = this.gzipper(this.u8(`[${payload.body.map(e => e.__serialized).join(',')}]`))
260+
payload.body = this.gzipper(this.u8(`[${payload.body.map(e => {
261+
if (e.__serialized) return e.__serialized
262+
return stringify(e)
263+
}).join(',')}]`))
258264
len = payload.body.length
259265
this.scheduler.opts.gzip = true
260266
} else {
@@ -300,13 +306,12 @@ export class Aggregate extends AggregateBase {
300306
recorderEvents.hasMeta = !!events.find(x => x.type === RRWEB_EVENT_TYPES.Meta)
301307
}
302308

303-
const agentOffset = getRuntime(this.agentIdentifier).offset
304309
const relativeNow = now()
305310

306311
const firstEventTimestamp = events[0]?.timestamp // from rrweb node
307312
const lastEventTimestamp = events[events.length - 1]?.timestamp // from rrweb node
308-
const firstTimestamp = firstEventTimestamp || recorderEvents.cycleTimestamp // from rrweb node || from when the harvest cycle started
309-
const lastTimestamp = lastEventTimestamp || agentOffset + relativeNow
313+
const firstTimestamp = firstEventTimestamp || this.timeKeeper.correctAbsoluteTimestamp(recorderEvents.cycleTimestamp) // from rrweb node || from when the harvest cycle started
314+
const lastTimestamp = lastEventTimestamp || this.timeKeeper.convertRelativeTimestamp(relativeNow)
310315

311316
const agentMetadata = agentRuntime.appMetadata?.agents?.[0] || {}
312317
return {
@@ -315,15 +320,14 @@ export class Aggregate extends AggregateBase {
315320
type: 'SessionReplay',
316321
app_id: info.applicationID,
317322
protocol_version: '0',
323+
timestamp: firstTimestamp,
318324
attributes: encodeObj({
319325
// this section of attributes must be controllable and stay below the query param padding limit -- see QUERY_PARAM_PADDING
320326
// if not, data could be lost to truncation at time of sending, potentially breaking parsing / API behavior in NR1
321327
...(!!this.gzipper && !!this.u8 && { content_encoding: 'gzip' }),
322328
...(agentMetadata.entityGuid && { entityGuid: agentMetadata.entityGuid }),
323329
'replay.firstTimestamp': firstTimestamp,
324-
'replay.firstTimestampOffset': firstTimestamp - agentOffset,
325330
'replay.lastTimestamp': lastTimestamp,
326-
'replay.durationMs': lastTimestamp - firstTimestamp,
327331
'replay.nodes': events.length,
328332
'session.durationMs': agentRuntime.session.getDuration(),
329333
agentVersion: agentRuntime.version,

src/features/session_replay/shared/recorder-events.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
export class RecorderEvents {
2-
constructor () {
2+
constructor ({ canCorrectTimestamps }) {
33
/** The buffer to hold recorder event nodes */
44
this.events = []
55
/** Payload metadata -- Should indicate when a replay blob started recording. Resets each time a harvest occurs.
66
* cycle timestamps are used as fallbacks if event timestamps cannot be used
77
*/
88
this.cycleTimestamp = Date.now()
9+
/** Payload metadata -- Whether timestamps can be corrected, defaults as false, can be set to true if timekeeper is present at init time. Used to determine
10+
* if harvest needs to re-loop through nodes and correct them before sending. Ideal behavior is to correct them as they flow into the recorder
11+
* to prevent re-looping, but is not always possible since the timekeeper is not set until after page load and the recorder can be preloaded.
12+
*/
13+
this.canCorrectTimestamps = !!canCorrectTimestamps
914
/** A value which increments with every new mutation node reported. Resets after a harvest is sent */
1015
this.payloadBytesEstimation = 0
1116
/** Payload metadata -- Should indicate that the payload being sent has a full DOM snapshot. This can happen

src/features/session_replay/shared/recorder.js

+33-9
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@ import { FEATURE_NAMES } from '../../../loaders/features/features'
1111

1212
export class Recorder {
1313
/** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */
14-
#events = new RecorderEvents()
14+
#events
1515
/** Backlog used for a 2-part sliding window to guarantee a 15-30s buffer window */
16-
#backloggedEvents = new RecorderEvents()
16+
#backloggedEvents
1717
/** array of recorder events -- Will be filled only if forced harvest was triggered and harvester does not exist */
18-
#preloaded = [new RecorderEvents()]
18+
#preloaded
1919
/** flag that if true, blocks events from being "stored". Only set to true when a full snapshot has incomplete nodes (only stylesheets ATM) */
2020
#fixing = false
2121

2222
constructor (parent) {
23+
this.#events = new RecorderEvents({ canCorrectTimestamps: !!parent.timeKeeper?.ready })
24+
this.#backloggedEvents = new RecorderEvents({ canCorrectTimestamps: !!parent.timeKeeper?.ready })
25+
this.#preloaded = [new RecorderEvents({ canCorrectTimestamps: !!parent.timeKeeper?.ready })]
2326
/** True when actively recording, false when paused or stopped */
2427
this.recording = false
2528
/** The pointer to the current bucket holding rrweb events */
@@ -37,9 +40,14 @@ export class Recorder {
3740
}
3841

3942
getEvents () {
40-
if (this.#preloaded[0]?.events.length) return { ...this.#preloaded[0], type: 'preloaded' }
43+
if (this.#preloaded[0]?.events.length) {
44+
const preloadedEvents = this.returnCorrectTimestamps(this.#preloaded[0])
45+
return { ...this.#preloaded[0], events: preloadedEvents, type: 'preloaded' }
46+
}
47+
const backloggedEvents = this.returnCorrectTimestamps(this.#backloggedEvents)
48+
const events = this.returnCorrectTimestamps(this.#events)
4149
return {
42-
events: [...this.#backloggedEvents.events, ...this.#events.events].filter(x => x),
50+
events: [...backloggedEvents, ...events].filter(x => x),
4351
type: 'standard',
4452
cycleTimestamp: Math.min(this.#backloggedEvents.cycleTimestamp, this.#events.cycleTimestamp),
4553
payloadBytesEstimation: this.#backloggedEvents.payloadBytesEstimation + this.#events.payloadBytesEstimation,
@@ -50,12 +58,24 @@ export class Recorder {
5058
}
5159
}
5260

61+
/**
62+
* Returns time-corrected events. If the events were correctable from the beginning, this correction will have already been applied.
63+
* @param {SessionReplayEvent[]} events The array of buffered SR nodes
64+
* @returns {CorrectedSessionReplayEvent[]}
65+
*/
66+
returnCorrectTimestamps (events) {
67+
if (!this.parent.timeKeeper?.ready) return events.events
68+
return events.canCorrectTimestamps
69+
? events.events
70+
: events.events.map(({ __serialized, timestamp, ...e }) => ({ timestamp: this.parent.timeKeeper.correctAbsoluteTimestamp(timestamp), ...e }))
71+
}
72+
5373
/** Clears the buffer (this.#events), and resets all payload metadata properties */
5474
clearBuffer () {
5575
if (this.#preloaded[0]?.events.length) this.#preloaded.shift()
5676
else if (this.parent.mode === MODE.ERROR) this.#backloggedEvents = this.#events
57-
else this.#backloggedEvents = new RecorderEvents()
58-
this.#events = new RecorderEvents()
77+
else this.#backloggedEvents = new RecorderEvents({ canCorrectTimestamps: !!this.parent.timeKeeper?.ready })
78+
this.#events = new RecorderEvents({ canCorrectTimestamps: !!this.parent.timeKeeper?.ready })
5979
}
6080

6181
/** Begin recording using configured recording lib */
@@ -122,12 +142,16 @@ export class Recorder {
122142
/** Store a payload in the buffer (this.#events). This should be the callback to the recording lib noticing a mutation */
123143
store (event, isCheckout) {
124144
if (!event) return
125-
event.__serialized = stringify(event)
126145

127146
if (!this.parent.scheduler && this.#preloaded.length) this.currentBufferTarget = this.#preloaded[this.#preloaded.length - 1]
128147
else this.currentBufferTarget = this.#events
129148

130149
if (this.parent.blocked) return
150+
151+
if (this.currentBufferTarget.canCorrectTimestamps) {
152+
event.timestamp = this.parent.timeKeeper.correctAbsoluteTimestamp(event.timestamp)
153+
}
154+
event.__serialized = stringify(event)
131155
const eventBytes = event.__serialized.length
132156
/** The estimated size of the payload after compression */
133157
const payloadSize = this.getPayloadSize(eventBytes)
@@ -160,7 +184,7 @@ export class Recorder {
160184
this.parent.scheduler.runHarvest()
161185
} else {
162186
// we are still in "preload" and it triggered a "stop point". Make a new set, which will get pointed at on next cycle
163-
this.#preloaded.push(new RecorderEvents())
187+
this.#preloaded.push(new RecorderEvents({ canCorrectTimestamps: !!this.parent.timeKeeper?.ready }))
164188
}
165189
}
166190
}

tests/assets/rrweb-instrumented.html

+2-3
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@
2424
window.wasPreloaded = false;
2525
window.addEventListener("load", () => {
2626
try {
27-
const replayEvents = Object.values(newrelic.initializedAgents)[0].features.session_replay.recorder.getEvents();
28-
window.wasPreloaded = !!replayEvents.events.length && replayEvents.type === "preloaded";
27+
window.wasPreloaded = !!Object.values(newrelic.initializedAgents)[0].features.session_replay.recorder
2928
} catch (err) {
30-
// do nothing because it failed to get the recorder events -- which means they dont exist
29+
// do nothing because it failed to get the recorder -- which inherently also means it was not preloaded
3130
}
3231
});
3332
</script>

tests/components/session_replay/aggregate.test.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,13 @@ describe('Session Replay', () => {
9898

9999
setConfiguration(agentIdentifier, { ...init })
100100
sr = new SessionReplayAgg(agentIdentifier, new Aggregator({}))
101+
sr.scheduler.runHarvest = jest.fn()
101102
sr.ee.emit('rumresp', [{ sr: 1 }])
102103
await wait(1)
103104
expect(sr.initialized).toBeTruthy()
104105
expect(sr.recorder.recording).toBeTruthy()
105106
sr.ee.emit(SESSION_EVENTS.RESET)
106-
expect(global.XMLHttpRequest).toHaveBeenCalled()
107+
expect(sr.scheduler.runHarvest).toHaveBeenCalled()
107108
expect(sr.recorder.recording).toBeFalsy()
108109
expect(sr.blocked).toBeTruthy()
109110
})
@@ -359,8 +360,11 @@ function wait (ms = 0) {
359360

360361
function primeSessionAndReplay (sess = new SessionEntity({ agentIdentifier, key: 'SESSION', storage: new LocalMemory() })) {
361362
const timeKeeper = new TimeKeeper(Date.now())
362-
const agent = { agentIdentifier, timeKeeper }
363+
timeKeeper.processRumRequest({
364+
getResponseHeader: jest.fn(() => (new Date()).toUTCString())
365+
}, 450, 600)
366+
const agent = { agentIdentifier }
363367
setNREUMInitializedAgent(agentIdentifier, agent)
364368
session = sess
365-
configure(agent, { info, runtime: { session, isolatedBacklog: false }, init: {} }, 'test', true)
369+
configure(agent, { info, runtime: { session, isolatedBacklog: false, timeKeeper }, init: {} }, 'test', true)
366370
}

0 commit comments

Comments
 (0)