@nagoorkani/session-capture-vue-replay 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +85 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +200 -0
- package/dist/recorder.d.ts +61 -0
- package/dist/recorder.js +96 -0
- package/dist/upload.d.ts +33 -0
- package/dist/upload.js +47 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
PROPRIETARY SOFTWARE LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nagoorkani2393. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software, its source code, and its accompanying documentation (the
|
|
6
|
+
"Software") are the proprietary and confidential property of the copyright
|
|
7
|
+
holder.
|
|
8
|
+
|
|
9
|
+
NO LICENSE IS GRANTED. No person or entity is permitted to use, copy, modify,
|
|
10
|
+
merge, publish, distribute, sublicense, reverse engineer, create derivative
|
|
11
|
+
works from, or sell the Software, in whole or in part, by any means, except
|
|
12
|
+
under a separate written agreement signed by the copyright holder.
|
|
13
|
+
|
|
14
|
+
Availability of the Software through a public package registry, source
|
|
15
|
+
repository, or any other distribution channel is for the convenience of
|
|
16
|
+
authorized licensees only. It does not grant any license, right, or permission
|
|
17
|
+
to the general public, and no such license may be implied from that
|
|
18
|
+
availability. Unauthorized use, reproduction, or distribution is prohibited.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
24
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
25
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @nagoorkani/session-capture-vue-replay
|
|
2
|
+
|
|
3
|
+
Optional DOM session replay for [`@nagoorkani/session-capture-vue`](../vue/README.md).
|
|
4
|
+
|
|
5
|
+
Separate from the SDK on purpose. rrweb is several times the size of the SDK
|
|
6
|
+
it attaches to, and section 23 says a recorder that most projects will not turn
|
|
7
|
+
on does not belong in everybody's bundle. The glue here is under 5 KB; rrweb is
|
|
8
|
+
what you are actually installing.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @nagoorkani/session-capture-vue-replay rrweb
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { installReplay } from '@nagoorkani/session-capture-vue-replay'
|
|
18
|
+
|
|
19
|
+
// After sessionCapture is installed, so the session is already open.
|
|
20
|
+
installReplay({ endpoint: 'https://your-ingestion-host', apiKey: 'pk_live_…' })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Nothing records until remote config says so. `replayEnabled` and
|
|
24
|
+
`replaySampleRate` are both off by default and live on the project's Settings
|
|
25
|
+
page, because continuous recording is the most expensive thing this SDK does
|
|
26
|
+
and a shipped page can only be stopped from the server. An unsampled session
|
|
27
|
+
costs nothing — the recorder never starts.
|
|
28
|
+
|
|
29
|
+
A session that hits an error is recorded whatever the sampling says, the same
|
|
30
|
+
exemption errors get everywhere else here.
|
|
31
|
+
|
|
32
|
+
## What is recorded
|
|
33
|
+
|
|
34
|
+
rrweb's stream: one full snapshot of the DOM, then the mutations after it. The
|
|
35
|
+
dashboard replays them to rebuild the page. Not screenshots — a browser cannot
|
|
36
|
+
photograph itself, which is why this and the mobile package produce different
|
|
37
|
+
data behind the same player.
|
|
38
|
+
|
|
39
|
+
**Every input is masked before it is buffered.** Not a setting: § 32 says
|
|
40
|
+
redaction happens in the SDK before anything is stored, and a DOM recording is
|
|
41
|
+
the one capture path that would otherwise carry a password in plain text.
|
|
42
|
+
|
|
43
|
+
Static text is *not* masked. Mark what should be:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
installReplay({
|
|
47
|
+
endpoint, apiKey,
|
|
48
|
+
maskTextClass: 'sc-mask', // text replaced with asterisks
|
|
49
|
+
blockClass: 'sc-block', // element kept as a box, contents dropped
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Those are the defaults, so `<div class="sc-block">` works without configuring
|
|
54
|
+
anything.
|
|
55
|
+
|
|
56
|
+
## Delivery
|
|
57
|
+
|
|
58
|
+
A segment every thirty seconds, or sooner if the buffer reaches 2 MB. The last
|
|
59
|
+
one is sent with `keepalive` as the page goes away.
|
|
60
|
+
|
|
61
|
+
**Segments are not persisted.** The mobile package keeps failed ones on disk
|
|
62
|
+
because a phone loses connectivity for minutes at a time; a browser tab that
|
|
63
|
+
has gone is gone, and its ~5 MB of `localStorage` is better spent on events —
|
|
64
|
+
those are the bytes that reconstruct a timeline, and a lost segment costs
|
|
65
|
+
pictures rather than facts. A failed send is retried once on the next tick and
|
|
66
|
+
then dropped.
|
|
67
|
+
|
|
68
|
+
rrweb's stream is only meaningful in order, so a lost segment breaks the replay
|
|
69
|
+
from that point. A full snapshot is taken every two minutes to bound the
|
|
70
|
+
damage: the recording resumes at the next one.
|
|
71
|
+
|
|
72
|
+
## Size
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pnpm --filter @nagoorkani/session-capture-vue-replay size
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Measures this package only. rrweb and the SDK are external — including them
|
|
79
|
+
would report the size of the recorder rather than of the glue.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
Proprietary — all rights reserved. See [LICENSE](./LICENSE). No use, copying,
|
|
84
|
+
modification or distribution is permitted without a written agreement with the
|
|
85
|
+
copyright holder.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nagoorkani/session-capture-vue-replay
|
|
3
|
+
*
|
|
4
|
+
* Optional DOM session replay, kept out of `@nagoorkani/session-capture-vue` so it costs
|
|
5
|
+
* nothing in bundle size for applications that do not want it (section 23).
|
|
6
|
+
* rrweb is several times the size of the SDK it attaches to, which is the
|
|
7
|
+
* whole reason this is a package of its own.
|
|
8
|
+
*
|
|
9
|
+
* Records a sampled session from start to finish, cutting a segment every
|
|
10
|
+
* thirty seconds. Which sessions those are is a remote-config decision —
|
|
11
|
+
* `replayEnabled` and `replaySampleRate`, both off by default — because
|
|
12
|
+
* continuous recording is the most expensive thing this SDK can do and a
|
|
13
|
+
* shipped page can only be stopped from the server.
|
|
14
|
+
*
|
|
15
|
+
* An unsampled session costs nothing: the recorder never starts.
|
|
16
|
+
*/
|
|
17
|
+
import { type RecorderOptions } from './recorder.ts';
|
|
18
|
+
import { type UploadResult } from './upload.ts';
|
|
19
|
+
export type { RecorderOptions, Segment } from './recorder.ts';
|
|
20
|
+
export type { UploadResult } from './upload.ts';
|
|
21
|
+
export interface ReplayOptions extends RecorderOptions {
|
|
22
|
+
/** Where to send segments. The same host as the SDK's `endpoint`. */
|
|
23
|
+
endpoint: string;
|
|
24
|
+
apiKey: string;
|
|
25
|
+
/**
|
|
26
|
+
* CSS class on elements to leave out of the recording entirely. Their shape
|
|
27
|
+
* is kept and their content is not.
|
|
28
|
+
*/
|
|
29
|
+
blockClass?: string;
|
|
30
|
+
/** CSS class on elements whose text is replaced with asterisks. */
|
|
31
|
+
maskTextClass?: string;
|
|
32
|
+
/** Observes each upload. For diagnostics and the example app. */
|
|
33
|
+
onUpload?: (result: UploadResult, events: number) => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Start recording.
|
|
37
|
+
*
|
|
38
|
+
* Call after `sessionCapture` has been installed, and only in the browser —
|
|
39
|
+
* during server rendering there is no DOM to record and this does nothing.
|
|
40
|
+
*/
|
|
41
|
+
export declare const installReplay: (options: ReplayOptions) => void | undefined;
|
|
42
|
+
/** Stop recording and unwind everything installed on the page. */
|
|
43
|
+
export declare const stopReplay: () => void | undefined;
|
|
44
|
+
/** Whether the open session is being recorded. */
|
|
45
|
+
export declare const isRecording: () => boolean;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nagoorkani/session-capture-vue-replay
|
|
3
|
+
*
|
|
4
|
+
* Optional DOM session replay, kept out of `@nagoorkani/session-capture-vue` so it costs
|
|
5
|
+
* nothing in bundle size for applications that do not want it (section 23).
|
|
6
|
+
* rrweb is several times the size of the SDK it attaches to, which is the
|
|
7
|
+
* whole reason this is a package of its own.
|
|
8
|
+
*
|
|
9
|
+
* Records a sampled session from start to finish, cutting a segment every
|
|
10
|
+
* thirty seconds. Which sessions those are is a remote-config decision —
|
|
11
|
+
* `replayEnabled` and `replaySampleRate`, both off by default — because
|
|
12
|
+
* continuous recording is the most expensive thing this SDK can do and a
|
|
13
|
+
* shipped page can only be stopped from the server.
|
|
14
|
+
*
|
|
15
|
+
* An unsampled session costs nothing: the recorder never starts.
|
|
16
|
+
*/
|
|
17
|
+
import { isSampled, ReplayGate, safe } from '@nagoorkani/session-capture-core';
|
|
18
|
+
import { currentConfig, keepSessionAlive, onError, onSession, } from '@nagoorkani/session-capture-vue';
|
|
19
|
+
import { record } from 'rrweb';
|
|
20
|
+
import { Recorder } from "./recorder.js";
|
|
21
|
+
import { uploadSegment } from "./upload.js";
|
|
22
|
+
/** Set by `installReplay`; read by the flusher, which outlives one call. */
|
|
23
|
+
const uploadOptions = {
|
|
24
|
+
endpoint: '',
|
|
25
|
+
apiKey: '',
|
|
26
|
+
};
|
|
27
|
+
let recorder = null;
|
|
28
|
+
let flushTimer = null;
|
|
29
|
+
let gateTimer = null;
|
|
30
|
+
let pageHideListener = null;
|
|
31
|
+
const unsubscribes = [];
|
|
32
|
+
let gate = null;
|
|
33
|
+
/** The session being recorded, or null when this one is not. */
|
|
34
|
+
let recordingSession = null;
|
|
35
|
+
/** Held for one more attempt when a send fails while the tab is still alive. */
|
|
36
|
+
let retrying = [];
|
|
37
|
+
let sending = false;
|
|
38
|
+
/**
|
|
39
|
+
* How often the gate is asked, as opposed to how often a segment is cut.
|
|
40
|
+
*
|
|
41
|
+
* Two timers, and the difference matters. Remote config has not arrived when
|
|
42
|
+
* `installReplay` runs, so the first answer is always "wait" — and on the web
|
|
43
|
+
* a reload resumes the session rather than starting one, so the session hook
|
|
44
|
+
* that would have asked again never fires. Asking only on the flush tick would
|
|
45
|
+
* lose the first thirty seconds of every visit, which is the part that
|
|
46
|
+
* explains what the user was doing.
|
|
47
|
+
*/
|
|
48
|
+
const GATE_INTERVAL_MS = 2000;
|
|
49
|
+
/**
|
|
50
|
+
* Start recording.
|
|
51
|
+
*
|
|
52
|
+
* Call after `sessionCapture` has been installed, and only in the browser —
|
|
53
|
+
* during server rendering there is no DOM to record and this does nothing.
|
|
54
|
+
*/
|
|
55
|
+
export const installReplay = safe((options) => {
|
|
56
|
+
if (recorder || typeof globalThis.document === 'undefined')
|
|
57
|
+
return;
|
|
58
|
+
uploadOptions.endpoint = options.endpoint;
|
|
59
|
+
uploadOptions.apiKey = options.apiKey;
|
|
60
|
+
uploadOptions.onUpload = options.onUpload;
|
|
61
|
+
recorder = new Recorder((emit) => record({
|
|
62
|
+
emit: (event) => emit(event),
|
|
63
|
+
// Every field a user types is masked before it is buffered. This is
|
|
64
|
+
// not a preference: § 32 says redaction happens in the SDK before
|
|
65
|
+
// anything is stored, and a DOM recording is the one capture path
|
|
66
|
+
// that would otherwise carry a password in plain text.
|
|
67
|
+
maskAllInputs: true,
|
|
68
|
+
blockClass: options.blockClass ?? 'sc-block',
|
|
69
|
+
maskTextClass: options.maskTextClass ?? 'sc-mask',
|
|
70
|
+
// A fresh full snapshot every two minutes. rrweb's stream is only
|
|
71
|
+
// meaningful in order, so without one a single lost or oversized
|
|
72
|
+
// segment would break every segment after it for the rest of the
|
|
73
|
+
// session; with one, the damage ends at the next checkout.
|
|
74
|
+
checkoutEveryNms: 2 * 60 * 1000,
|
|
75
|
+
}), options,
|
|
76
|
+
// The buffer reached its ceiling before the timer did.
|
|
77
|
+
() => void flushNow());
|
|
78
|
+
gate = new ReplayGate(isSampled);
|
|
79
|
+
unsubscribes.push(onSession((sessionId) => {
|
|
80
|
+
// Whatever the previous session had buffered belongs to the previous
|
|
81
|
+
// session, so cut it before the id changes under it.
|
|
82
|
+
void flushNow();
|
|
83
|
+
gate?.open(sessionId);
|
|
84
|
+
recordingSession = null;
|
|
85
|
+
evaluate();
|
|
86
|
+
}));
|
|
87
|
+
// An error records regardless of sampling — the same exemption errors get
|
|
88
|
+
// everywhere else here — and flushes immediately, because an error often
|
|
89
|
+
// precedes the page being abandoned.
|
|
90
|
+
unsubscribes.push(onError((sessionId) => {
|
|
91
|
+
gate?.force(sessionId);
|
|
92
|
+
evaluate();
|
|
93
|
+
void flushNow();
|
|
94
|
+
}));
|
|
95
|
+
gateTimer = setInterval(evaluate, GATE_INTERVAL_MS);
|
|
96
|
+
gateTimer?.unref?.();
|
|
97
|
+
flushTimer = setInterval(() => {
|
|
98
|
+
evaluate();
|
|
99
|
+
void flushNow();
|
|
100
|
+
}, recorder.segmentMs);
|
|
101
|
+
flushTimer?.unref?.();
|
|
102
|
+
// The last cut. A hidden tab may never run again, and `keepalive` is the
|
|
103
|
+
// only request a browser promises to finish after the page is gone.
|
|
104
|
+
pageHideListener = () => void flushNow(true);
|
|
105
|
+
globalThis.addEventListener?.('pagehide', pageHideListener);
|
|
106
|
+
globalThis.addEventListener?.('visibilitychange', pageHideListener);
|
|
107
|
+
evaluate();
|
|
108
|
+
}, undefined, 'installReplay');
|
|
109
|
+
/** Stop recording and unwind everything installed on the page. */
|
|
110
|
+
export const stopReplay = safe(() => {
|
|
111
|
+
for (const undo of unsubscribes.splice(0)) {
|
|
112
|
+
try {
|
|
113
|
+
undo();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Teardown must never throw into the host page.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (flushTimer)
|
|
120
|
+
clearInterval(flushTimer);
|
|
121
|
+
if (gateTimer)
|
|
122
|
+
clearInterval(gateTimer);
|
|
123
|
+
flushTimer = null;
|
|
124
|
+
gateTimer = null;
|
|
125
|
+
if (pageHideListener) {
|
|
126
|
+
globalThis.removeEventListener?.('pagehide', pageHideListener);
|
|
127
|
+
globalThis.removeEventListener?.('visibilitychange', pageHideListener);
|
|
128
|
+
pageHideListener = null;
|
|
129
|
+
}
|
|
130
|
+
recorder?.pause();
|
|
131
|
+
// Not merely stopped: § 36 — a kill switch that leaves screenshots of
|
|
132
|
+
// somebody's screen sitting in memory has not done its job.
|
|
133
|
+
recorder?.discard();
|
|
134
|
+
recorder = null;
|
|
135
|
+
gate?.reset();
|
|
136
|
+
gate = null;
|
|
137
|
+
recordingSession = null;
|
|
138
|
+
retrying = [];
|
|
139
|
+
}, undefined, 'stopReplay');
|
|
140
|
+
/** Whether the open session is being recorded. */
|
|
141
|
+
export const isRecording = safe(() => recordingSession !== null && (recorder?.recording ?? false), false, 'isRecording');
|
|
142
|
+
/** Ask the gate what this session should do, and act on the answer. */
|
|
143
|
+
function evaluate() {
|
|
144
|
+
if (!gate || !recorder)
|
|
145
|
+
return;
|
|
146
|
+
const action = gate.evaluate(currentConfig());
|
|
147
|
+
switch (action.kind) {
|
|
148
|
+
case 'wait':
|
|
149
|
+
return;
|
|
150
|
+
case 'off':
|
|
151
|
+
recorder.pause();
|
|
152
|
+
recorder.discard();
|
|
153
|
+
retrying = [];
|
|
154
|
+
recordingSession = null;
|
|
155
|
+
return;
|
|
156
|
+
case 'skip':
|
|
157
|
+
recorder.pause();
|
|
158
|
+
recorder.discard();
|
|
159
|
+
recordingSession = null;
|
|
160
|
+
return;
|
|
161
|
+
case 'record':
|
|
162
|
+
recordingSession = action.sessionId;
|
|
163
|
+
recorder.start();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Cut a segment and send it.
|
|
168
|
+
*
|
|
169
|
+
* Recording is activity: a page nobody is clicking emits no events, so without
|
|
170
|
+
* this the session would idle out underneath the recorder and segments would
|
|
171
|
+
* arrive for a session the backend had already closed.
|
|
172
|
+
*/
|
|
173
|
+
async function flushNow(unloading = false) {
|
|
174
|
+
const active = recorder;
|
|
175
|
+
const sessionId = recordingSession;
|
|
176
|
+
if (!active || !sessionId || sending)
|
|
177
|
+
return;
|
|
178
|
+
const segment = [...retrying, ...active.take()];
|
|
179
|
+
retrying = [];
|
|
180
|
+
if (segment.length === 0)
|
|
181
|
+
return;
|
|
182
|
+
keepSessionAlive();
|
|
183
|
+
sending = true;
|
|
184
|
+
try {
|
|
185
|
+
const result = await uploadSegment({
|
|
186
|
+
endpoint: uploadOptions.endpoint,
|
|
187
|
+
apiKey: uploadOptions.apiKey,
|
|
188
|
+
sessionId,
|
|
189
|
+
keepalive: unloading,
|
|
190
|
+
}, segment);
|
|
191
|
+
// One more attempt on the next tick, then it is gone. Holding a failing
|
|
192
|
+
// segment forever is how an offline tab runs out of memory.
|
|
193
|
+
if (result.kind === 'failed' && !unloading)
|
|
194
|
+
retrying = segment;
|
|
195
|
+
uploadOptions.onUpload?.(result, segment.length);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
sending = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording the DOM, and cutting it into segments.
|
|
3
|
+
*
|
|
4
|
+
* A browser cannot photograph itself, so a web recording is not pictures: it
|
|
5
|
+
* is rrweb's stream of one full snapshot followed by mutations. The player
|
|
6
|
+
* replays them to rebuild what the user saw.
|
|
7
|
+
*
|
|
8
|
+
* `record` is injected rather than imported, exactly as the mobile recorder
|
|
9
|
+
* takes its screenshot function: it keeps this module testable in Node, where
|
|
10
|
+
* there is no DOM and no rrweb, and it is the seam that decides masking.
|
|
11
|
+
*/
|
|
12
|
+
export interface RecordedEvent {
|
|
13
|
+
/** rrweb stamps each event with the device clock. Display only, as ever. */
|
|
14
|
+
timestamp?: number;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** What rrweb's `record` is, reduced to what this uses. */
|
|
18
|
+
export type RecordFn = (emit: (event: RecordedEvent) => void) => (() => void) | undefined;
|
|
19
|
+
export interface Segment {
|
|
20
|
+
timestamp: number;
|
|
21
|
+
payload: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
export interface RecorderOptions {
|
|
24
|
+
/**
|
|
25
|
+
* How much of the stream one upload carries. Thirty seconds, matching the
|
|
26
|
+
* mobile package: long enough that a page's chatter is not one request per
|
|
27
|
+
* mutation, short enough that closing the tab loses little.
|
|
28
|
+
*/
|
|
29
|
+
seconds?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Bytes buffered before a segment is cut early.
|
|
32
|
+
*
|
|
33
|
+
* The ceiling exists twice over: ingestion refuses an upload above 8 MB, and
|
|
34
|
+
* a page that mutates heavily would otherwise hold megabytes of DOM in
|
|
35
|
+
* memory between ticks.
|
|
36
|
+
*/
|
|
37
|
+
maxSegmentBytes?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class Recorder {
|
|
40
|
+
private readonly record;
|
|
41
|
+
private readonly maxSegmentBytes;
|
|
42
|
+
private readonly onSegmentFull;
|
|
43
|
+
private readonly encoder;
|
|
44
|
+
private stop;
|
|
45
|
+
private buffered;
|
|
46
|
+
private bufferedBytes;
|
|
47
|
+
private droppedCount;
|
|
48
|
+
readonly segmentMs: number;
|
|
49
|
+
constructor(record: RecordFn, options: RecorderOptions, onSegmentFull: () => void);
|
|
50
|
+
get recording(): boolean;
|
|
51
|
+
/** Events discarded for being individually too large to send. */
|
|
52
|
+
get dropped(): number;
|
|
53
|
+
start(): void;
|
|
54
|
+
/** Stop recording. Anything already buffered stays until it is taken. */
|
|
55
|
+
pause(): void;
|
|
56
|
+
/** Take everything buffered, leaving the recorder empty and still running. */
|
|
57
|
+
take(): Segment[];
|
|
58
|
+
/** Throw away what is buffered — a kill switch, or consent withdrawn. */
|
|
59
|
+
discard(): void;
|
|
60
|
+
private push;
|
|
61
|
+
}
|
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording the DOM, and cutting it into segments.
|
|
3
|
+
*
|
|
4
|
+
* A browser cannot photograph itself, so a web recording is not pictures: it
|
|
5
|
+
* is rrweb's stream of one full snapshot followed by mutations. The player
|
|
6
|
+
* replays them to rebuild what the user saw.
|
|
7
|
+
*
|
|
8
|
+
* `record` is injected rather than imported, exactly as the mobile recorder
|
|
9
|
+
* takes its screenshot function: it keeps this module testable in Node, where
|
|
10
|
+
* there is no DOM and no rrweb, and it is the seam that decides masking.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
seconds: 30,
|
|
14
|
+
maxSegmentBytes: 2 * 1024 * 1024,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* One event larger than this cannot be sent — ingestion's per-frame limit is
|
|
18
|
+
* 4 MB — so it is dropped rather than poisoning the segment it is in.
|
|
19
|
+
*
|
|
20
|
+
* ponytail: dropping one event breaks the replay from that point, because
|
|
21
|
+
* rrweb's stream is only meaningful in order. The alternative is an upload
|
|
22
|
+
* rejected whole, which breaks it just as badly and wastes the request. A
|
|
23
|
+
* periodic checkout is what recovers: the next full snapshot starts again.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_EVENT_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
export class Recorder {
|
|
27
|
+
constructor(record, options, onSegmentFull) {
|
|
28
|
+
this.encoder = new TextEncoder();
|
|
29
|
+
this.buffered = [];
|
|
30
|
+
this.bufferedBytes = 0;
|
|
31
|
+
this.droppedCount = 0;
|
|
32
|
+
this.record = record;
|
|
33
|
+
this.segmentMs = Math.max(1000, (options.seconds ?? DEFAULTS.seconds) * 1000);
|
|
34
|
+
this.maxSegmentBytes = options.maxSegmentBytes ?? DEFAULTS.maxSegmentBytes;
|
|
35
|
+
this.onSegmentFull = onSegmentFull;
|
|
36
|
+
}
|
|
37
|
+
get recording() {
|
|
38
|
+
return this.stop !== undefined;
|
|
39
|
+
}
|
|
40
|
+
/** Events discarded for being individually too large to send. */
|
|
41
|
+
get dropped() {
|
|
42
|
+
return this.droppedCount;
|
|
43
|
+
}
|
|
44
|
+
start() {
|
|
45
|
+
if (this.stop)
|
|
46
|
+
return;
|
|
47
|
+
this.stop = this.record((event) => this.push(event)) ?? (() => { });
|
|
48
|
+
}
|
|
49
|
+
/** Stop recording. Anything already buffered stays until it is taken. */
|
|
50
|
+
pause() {
|
|
51
|
+
try {
|
|
52
|
+
this.stop?.();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// rrweb's own teardown must not throw into the host page.
|
|
56
|
+
}
|
|
57
|
+
this.stop = undefined;
|
|
58
|
+
}
|
|
59
|
+
/** Take everything buffered, leaving the recorder empty and still running. */
|
|
60
|
+
take() {
|
|
61
|
+
const segment = this.buffered;
|
|
62
|
+
this.buffered = [];
|
|
63
|
+
this.bufferedBytes = 0;
|
|
64
|
+
return segment;
|
|
65
|
+
}
|
|
66
|
+
/** Throw away what is buffered — a kill switch, or consent withdrawn. */
|
|
67
|
+
discard() {
|
|
68
|
+
this.buffered = [];
|
|
69
|
+
this.bufferedBytes = 0;
|
|
70
|
+
}
|
|
71
|
+
push(event) {
|
|
72
|
+
let payload;
|
|
73
|
+
try {
|
|
74
|
+
payload = this.encoder.encode(JSON.stringify(event));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A circular or unserializable event. rrweb does not produce one, but a
|
|
78
|
+
// recorder must not throw into the page it is watching.
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (payload.byteLength > MAX_EVENT_BYTES) {
|
|
82
|
+
this.droppedCount += 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.buffered.push({
|
|
86
|
+
timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
|
|
87
|
+
payload,
|
|
88
|
+
});
|
|
89
|
+
this.bufferedBytes += payload.byteLength;
|
|
90
|
+
if (this.bufferedBytes >= this.maxSegmentBytes) {
|
|
91
|
+
// Cut now rather than at the next tick: the ceiling is about memory and
|
|
92
|
+
// the upload limit, and both are reached before the timer is.
|
|
93
|
+
this.onSegmentFull();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending a segment.
|
|
3
|
+
*
|
|
4
|
+
* One attempt, no retry and no queue. The mobile package keeps failed segments
|
|
5
|
+
* on disk because a phone loses connectivity for minutes at a time; a browser
|
|
6
|
+
* tab that has gone is gone, and its ~5 MB of `localStorage` is better spent
|
|
7
|
+
* on events than on DOM snapshots — those are the bytes that reconstruct a
|
|
8
|
+
* timeline, and a lost segment costs pictures rather than facts.
|
|
9
|
+
*
|
|
10
|
+
* ponytail: in-memory only, so a segment that fails while the tab lives is
|
|
11
|
+
* retried once on the next tick and then dropped. Persist it when someone
|
|
12
|
+
* shows a recording worth the quota.
|
|
13
|
+
*/
|
|
14
|
+
import type { Segment } from './recorder.ts';
|
|
15
|
+
export interface UploadOptions {
|
|
16
|
+
endpoint: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
sessionId: string;
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
/** Ask the browser to finish this one after the page is gone. */
|
|
21
|
+
keepalive?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export type UploadResult = {
|
|
24
|
+
kind: 'sent';
|
|
25
|
+
events: number;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'skipped';
|
|
28
|
+
reason: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'failed';
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function uploadSegment(options: UploadOptions, segment: readonly Segment[]): Promise<UploadResult>;
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending a segment.
|
|
3
|
+
*
|
|
4
|
+
* One attempt, no retry and no queue. The mobile package keeps failed segments
|
|
5
|
+
* on disk because a phone loses connectivity for minutes at a time; a browser
|
|
6
|
+
* tab that has gone is gone, and its ~5 MB of `localStorage` is better spent
|
|
7
|
+
* on events than on DOM snapshots — those are the bytes that reconstruct a
|
|
8
|
+
* timeline, and a lost segment costs pictures rather than facts.
|
|
9
|
+
*
|
|
10
|
+
* ponytail: in-memory only, so a segment that fails while the tab lives is
|
|
11
|
+
* retried once on the next tick and then dropped. Persist it when someone
|
|
12
|
+
* shows a recording worth the quota.
|
|
13
|
+
*/
|
|
14
|
+
import { encodeContainer, MAGIC_DOM } from '@nagoorkani/session-capture-core';
|
|
15
|
+
export async function uploadSegment(options, segment) {
|
|
16
|
+
if (segment.length === 0)
|
|
17
|
+
return { kind: 'skipped', reason: 'no events' };
|
|
18
|
+
if (!options.sessionId) {
|
|
19
|
+
// Without a session there is nothing to attach the recording to, and the
|
|
20
|
+
// server would reject it anyway.
|
|
21
|
+
return { kind: 'skipped', reason: 'no session' };
|
|
22
|
+
}
|
|
23
|
+
const base = options.endpoint.replace(/\/$/, '');
|
|
24
|
+
// The same endpoint the mobile package uses. What differs is the container's
|
|
25
|
+
// magic — everything between here and the player treats a frame as opaque
|
|
26
|
+
// bytes with a timestamp, so one intake serves both.
|
|
27
|
+
const url = `${base}/v1/mobile/replay?sessionId=${encodeURIComponent(options.sessionId)}`;
|
|
28
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
29
|
+
try {
|
|
30
|
+
const body = encodeContainer(MAGIC_DOM, segment);
|
|
31
|
+
const response = await doFetch(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'content-type': 'application/octet-stream',
|
|
35
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
36
|
+
},
|
|
37
|
+
body: body,
|
|
38
|
+
keepalive: options.keepalive === true,
|
|
39
|
+
});
|
|
40
|
+
return response.ok
|
|
41
|
+
? { kind: 'sent', events: segment.length }
|
|
42
|
+
: { kind: 'failed', reason: `http ${response.status}` };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { kind: 'failed', reason: err instanceof Error ? err.message : 'network error' };
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nagoorkani/session-capture-vue-replay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Optional DOM session replay for @nagoorkani/session-capture-vue",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@nagoorkani/session-capture-core": "0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"rrweb": "^2.0.0",
|
|
26
|
+
"@nagoorkani/session-capture-vue": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"esbuild": "^0.24.2",
|
|
31
|
+
"rrweb": "^2.0.0",
|
|
32
|
+
"typescript": "^5.6.0"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.6"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/Nagoorkani2393/session-capture.git",
|
|
40
|
+
"directory": "sdk/vue-replay"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/Nagoorkani2393/session-capture/tree/main/sdk/vue-replay#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/Nagoorkani2393/session-capture/issues"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
|
|
53
|
+
"size": "node scripts/size.mjs"
|
|
54
|
+
}
|
|
55
|
+
}
|