Skip to content

Commit 41360f7

Browse files
authored
Session replay integration tests (#1179)
1 parent c6cf994 commit 41360f7

File tree

5 files changed

+283
-6
lines changed

5 files changed

+283
-6
lines changed

CLAUDE.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,24 @@ The `src/tracing/` directory contains an OpenTelemetry-inspired tracing implemen
138138
- **Attributes and events**: Add metadata to spans with `setAttribute()` and `addEvent()`
139139
- **Session management**: Automatically manages user sessions via browser sessionStorage
140140

141-
### Integration with Session Replay
141+
### Session Replay Implementation
142142

143-
The Session Replay feature utilizes this tracing infrastructure to:
143+
The `src/browser/replay` directory contains the implementation of the Session Replay feature:
144144

145-
- Track and record user sessions with unique identifiers
146-
- Associate spans with specific user sessions for complete context
147-
- Capture timing information for accurate playback
148-
- Store interaction events as span attributes and events
145+
- **Recorder**: Core class that integrates with rrweb to record DOM events
146+
- **Configuration**: Configurable options in `defaults.js` for replay behavior
147+
148+
The Session Replay feature utilizes our tracing infrastructure to:
149+
150+
- Record user interactions using rrweb in a memory-efficient way
151+
- Store recordings with checkpoints for better performance
152+
- Generate spans that contain replay events with proper timing
153+
- Associate recordings with user sessions for complete context
154+
- Transport recordings to Rollbar servers via the API
155+
156+
### Testing Approach
157+
158+
- **Mock Implementation**: `test/replay/mockRecordFn.js` provides a deterministic mock of rrweb
159+
- **Fixtures**: Realistic rrweb events in `test/fixtures/replay/` for testing
160+
- **Integration Tests**: Verify interaction between Recorder and Tracing system
161+
- **Edge Cases**: Test handling of empty events, checkpoints, and error conditions

Gruntfile.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,33 @@ module.exports = function (grunt) {
196196
var tasks = [karmaTask];
197197
grunt.task.run.apply(grunt.task, tasks);
198198
});
199+
200+
// Custom task for running all replay-related tests
201+
grunt.registerTask('test-replay', function () {
202+
// Find all replay-related test files:
203+
// 1. Tests in test/replay/ directory
204+
// 2. Tests in test/tracing/ directory (underlying infrastructure)
205+
// 3. Browser replay tests (browser.replay.*.test.js)
206+
var replayTests = Object.keys(browserTests).filter(function(testName) {
207+
var testPath = browserTests[testName];
208+
return (
209+
testPath.includes('test/replay/') ||
210+
testPath.includes('test/tracing/') ||
211+
testPath.match(/test\/browser\.replay\..*\.test\.js/)
212+
);
213+
});
214+
215+
// Log which tests will be run
216+
grunt.log.writeln('Running replay-related tests:');
217+
replayTests.forEach(function(testName) {
218+
grunt.log.writeln('- ' + testName + ' (' + browserTests[testName] + ')');
219+
});
220+
221+
// Run each test
222+
replayTests.forEach(function(testName) {
223+
grunt.task.run('karma:' + testName);
224+
});
225+
});
199226

200227
grunt.registerTask('copyrelease', function createRelease() {
201228
var version = pkg.version;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"test": "./node_modules/.bin/grunt test",
9090
"test-browser": "./node_modules/.bin/grunt test-browser",
9191
"test-server": "./node_modules/.bin/grunt test-server",
92+
"test-replay": "./node_modules/.bin/grunt test-replay",
9293
"test_ci": "./node_modules/.bin/grunt test",
9394
"lint": "./node_modules/.bin/eslint ."
9495
},

test/replay/mockRecordFn.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Mock implementation of rrweb.record for testing
3+
* Emits fixture events on a schedule to test the Recorder
4+
*/
5+
6+
import { allEvents } from '../fixtures/replay/index.js';
7+
8+
/**
9+
* Mock implementation of rrweb's record function
10+
*
11+
* @param {Object} options Configuration options
12+
* @param {Function} options.emit Function that will receive events
13+
* @param {number} options.checkoutEveryNms Milliseconds between checkpoints
14+
* @param {number} options.emitEveryNms Milliseconds between events
15+
* @returns {Function} Stop function
16+
*/
17+
export default function mockRecordFn(options = {}) {
18+
function* cycling(xs) {
19+
let i = 0;
20+
while (true) {
21+
yield xs[i++ % xs.length];
22+
}
23+
}
24+
25+
const systemEvents = ['domContentLoaded', 'load', 'meta', 'fullSnapshot'];
26+
const events = cycling(
27+
Object.entries(allEvents)
28+
.filter(([eventName]) => !systemEvents.includes(eventName))
29+
.map(([_, event]) => event),
30+
);
31+
32+
const emit = options.emit;
33+
34+
let lastCheckoutTime = Date.now();
35+
let intervalId = null;
36+
let stopping = false;
37+
let initialSnapshotDone = false;
38+
39+
const emitNextEvent = () => {
40+
if (stopping) return;
41+
42+
// Check if we need to do a checkout
43+
const now = Date.now();
44+
45+
if (
46+
initialSnapshotDone &&
47+
options.checkoutEveryNms &&
48+
now - lastCheckoutTime >= options.checkoutEveryNms
49+
) {
50+
lastCheckoutTime = now;
51+
52+
// checkout:
53+
// rrweb sends both Meta and FullSnapshot events in the same tick
54+
// with isCheckout = true
55+
emit({ ...allEvents.meta }, true);
56+
emit({ ...allEvents.fullSnapshot }, true);
57+
}
58+
59+
emit(events.next().value, false);
60+
};
61+
62+
// Start emitting events on a regular interval
63+
// 1 event every 100ms is a reasonable pace for testing
64+
intervalId = setInterval(emitNextEvent, options.emitEveryNms);
65+
66+
// Initial events: domContentLoaded, load, meta, fullSnapshot
67+
// Based on rrweb's implementation, the initial snapshot is NOT a checkout
68+
emit(allEvents.domContentLoaded, false);
69+
emit(allEvents.load, false);
70+
emit(allEvents.meta, false);
71+
emit(allEvents.fullSnapshot, false);
72+
initialSnapshotDone = true;
73+
74+
// Return a stop function that cleans up the intervals
75+
return () => {
76+
stopping = true;
77+
clearInterval(intervalId);
78+
intervalId = null;
79+
};
80+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Integration tests for the Recorder and Tracing interaction
3+
*/
4+
5+
/* globals describe */
6+
/* globals it */
7+
/* globals beforeEach */
8+
/* globals afterEach */
9+
10+
import { expect } from 'chai';
11+
import Tracing from '../../src/tracing/tracing.js';
12+
import { Span } from '../../src/tracing/span.js';
13+
import { Context } from '../../src/tracing/context.js';
14+
import Recorder from '../../src/browser/replay/recorder.js';
15+
import recorderDefaults from '../../src/browser/replay/defaults.js';
16+
import { spanExportQueue } from '../../src/tracing/exporter.js';
17+
import mockRecordFn from './mockRecordFn.js';
18+
import { EventType } from '../fixtures/replay/types.js';
19+
20+
const mockWindow = {
21+
sessionStorage: {},
22+
document: {
23+
location: {
24+
href: 'https://example.com/test',
25+
},
26+
},
27+
navigator: {
28+
userAgent: 'Mozilla/5.0 Test',
29+
},
30+
};
31+
32+
const options = {
33+
enabled: true,
34+
resource: {
35+
attributes: {
36+
'service.name': 'unknown_service',
37+
'telemetry.sdk.language': 'webjs',
38+
'telemetry.sdk.name': 'rollbar',
39+
'telemetry.sdk.version': '0.1.0',
40+
},
41+
},
42+
notifier: {
43+
name: 'rollbar.js',
44+
version: '0.1.0',
45+
},
46+
recorder: {
47+
...recorderDefaults,
48+
enabled: true,
49+
autoStart: false,
50+
emitEveryNms: 100, // non-rrweb, used by mockRecordFn
51+
},
52+
};
53+
54+
describe('Session Replay Integration', function () {
55+
let tracing;
56+
let recorder;
57+
58+
beforeEach(function () {
59+
spanExportQueue.length = 0;
60+
61+
tracing = new Tracing(mockWindow, options);
62+
tracing.initSession();
63+
});
64+
65+
afterEach(function () {
66+
recorder.stop();
67+
});
68+
69+
it('dumping recording should export tracing', function (done) {
70+
recorder = new Recorder(tracing, options.recorder, mockRecordFn);
71+
recorder.start();
72+
73+
const tracingContext = tracing.contextManager.active();
74+
expect(tracingContext).to.be.instanceOf(Context);
75+
76+
const dumpRecording = () => {
77+
const recordingSpan = recorder.dump(tracingContext);
78+
expect(recordingSpan).to.be.instanceOf(Span);
79+
expect(recordingSpan.span.name).to.be.equal('rrweb-replay-recording');
80+
81+
const events = recordingSpan.span.events;
82+
expect(events.length).to.be.greaterThan(0);
83+
expect(events.every((e) => e.name === 'rrweb-replay-events')).to.be.true;
84+
expect(events[0].attributes).to.have.property('eventType');
85+
expect(events[0].attributes).to.have.property('json');
86+
87+
expect(spanExportQueue.length).to.be.equal(1);
88+
expect(spanExportQueue[0]).to.be.deep.equal(recordingSpan.span);
89+
expect(spanExportQueue[0].events.length).to.be.equal(events.length);
90+
expect(spanExportQueue[0].events).to.be.deep.equal(events);
91+
expect(spanExportQueue[0].name).to.be.equal('rrweb-replay-recording');
92+
93+
done();
94+
};
95+
96+
setTimeout(dumpRecording, 1000);
97+
});
98+
99+
it('should handle checkouts correctly', function (done) {
100+
recorder = new Recorder(
101+
tracing,
102+
{
103+
...options.recorder,
104+
checkoutEveryNms: 250,
105+
},
106+
mockRecordFn,
107+
);
108+
109+
recorder.start();
110+
111+
const dumpRecording = () => {
112+
const recordingSpan = recorder.dump();
113+
114+
const events = recordingSpan.span.events;
115+
expect(
116+
events.filter((e) => e.attributes.eventType === EventType.Meta),
117+
).to.have.lengthOf(2);
118+
expect(
119+
events.filter((e) => e.attributes.eventType === EventType.FullSnapshot),
120+
).to.have.lengthOf(2);
121+
122+
done();
123+
};
124+
125+
setTimeout(dumpRecording, 1000);
126+
});
127+
128+
it('should handle no checkouts correctly', function (done) {
129+
recorder = new Recorder(
130+
tracing,
131+
{
132+
...options.recorder,
133+
checkoutEveryNms: 500,
134+
},
135+
mockRecordFn,
136+
);
137+
138+
recorder.start();
139+
140+
const dumpRecording = () => {
141+
const recordingSpan = recorder.dump();
142+
143+
const events = recordingSpan.span.events;
144+
expect(
145+
events.filter((e) => e.attributes.eventType === EventType.Meta),
146+
).to.have.lengthOf(1);
147+
expect(
148+
events.filter((e) => e.attributes.eventType === EventType.FullSnapshot),
149+
).to.have.lengthOf(1);
150+
151+
done();
152+
};
153+
154+
setTimeout(dumpRecording, 250);
155+
});
156+
});

0 commit comments

Comments
 (0)