@usero/sdk 0.3.1 → 0.3.4
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/dist/plugins/session-replay.cjs +324 -67
- package/dist/plugins/session-replay.cjs.map +1 -1
- package/dist/plugins/session-replay.d.cts +33 -11
- package/dist/plugins/session-replay.d.ts +33 -11
- package/dist/plugins/session-replay.js +324 -68
- package/dist/plugins/session-replay.js.map +1 -1
- package/dist/plugins/user-test.cjs +528 -0
- package/dist/plugins/user-test.cjs.map +1 -0
- package/dist/plugins/user-test.d.cts +63 -0
- package/dist/plugins/user-test.d.ts +63 -0
- package/dist/plugins/user-test.js +525 -0
- package/dist/plugins/user-test.js.map +1 -0
- package/dist/react.cjs +12 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +12 -0
- package/dist/react.js.map +1 -1
- package/dist/usero.iife.js +27 -27
- package/dist/usero.iife.js.map +1 -1
- package/dist/vanilla.cjs +12 -0
- package/dist/vanilla.cjs.map +1 -1
- package/dist/vanilla.d.cts +2 -0
- package/dist/vanilla.d.ts +2 -0
- package/dist/vanilla.js +12 -0
- package/dist/vanilla.js.map +1 -1
- package/package.json +6 -1
|
@@ -19,6 +19,8 @@ interface FeedbackSubmission {
|
|
|
19
19
|
screenshots?: ScreenshotData[];
|
|
20
20
|
metadata?: Record<string, unknown>;
|
|
21
21
|
replayEvents?: string;
|
|
22
|
+
sessionReplayId?: string;
|
|
23
|
+
replayOffsetMs?: number;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface PluginLogger {
|
|
@@ -48,7 +50,6 @@ interface ReplaySampling {
|
|
|
48
50
|
input?: number | 'last';
|
|
49
51
|
}
|
|
50
52
|
interface SessionReplayOptions {
|
|
51
|
-
bufferSeconds?: number;
|
|
52
53
|
startAfterMs?: number;
|
|
53
54
|
sampleRate?: number;
|
|
54
55
|
sampling?: ReplaySampling;
|
|
@@ -56,20 +57,41 @@ interface SessionReplayOptions {
|
|
|
56
57
|
maskTextSelector?: string;
|
|
57
58
|
inlineStylesheet?: boolean;
|
|
58
59
|
blockSelector?: string;
|
|
60
|
+
chunkSeconds?: number;
|
|
61
|
+
chunkMaxEvents?: number;
|
|
62
|
+
chunkMaxAttempts?: number;
|
|
63
|
+
apiUrl?: string;
|
|
59
64
|
}
|
|
60
|
-
interface RrwebEvent {
|
|
61
|
-
type: number;
|
|
62
|
-
data: unknown;
|
|
63
|
-
timestamp: number;
|
|
64
|
-
}
|
|
65
|
-
declare function evictOldEvents(events: RrwebEvent[], bufferSeconds: number, now: number): void;
|
|
66
65
|
declare function uint8ToBase64(bytes: Uint8Array): string;
|
|
67
|
-
declare function
|
|
66
|
+
declare function gzipBytes(input: string): Promise<Uint8Array>;
|
|
67
|
+
declare function mintSdkSessionId(): string;
|
|
68
|
+
declare function joinUrl(apiUrl: string, path: string): string;
|
|
69
|
+
interface CreateSessionResult {
|
|
70
|
+
accepted: boolean;
|
|
71
|
+
sessionReplayId?: string;
|
|
72
|
+
dropReason?: string;
|
|
73
|
+
}
|
|
74
|
+
declare function createSession(apiUrl: string, clientId: string, sdkSessionId: string): Promise<CreateSessionResult | null>;
|
|
75
|
+
interface ChunkUploadResult {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
stopSession: boolean;
|
|
78
|
+
}
|
|
79
|
+
declare function uploadChunk(apiUrl: string, sessionReplayId: string, clientId: string, seq: number, bytes: Uint8Array, eventCount: number, durationMs: number, logger: PluginContext['logger'], maxAttempts: number): Promise<ChunkUploadResult>;
|
|
80
|
+
interface CurrentSessionHandle {
|
|
81
|
+
id: string;
|
|
82
|
+
offsetMs: number;
|
|
83
|
+
}
|
|
68
84
|
declare function sessionReplay(options?: SessionReplayOptions): UseroPlugin;
|
|
85
|
+
declare function getCurrentSession(ctx: PluginContext): CurrentSessionHandle | null;
|
|
69
86
|
declare const __test__: {
|
|
70
|
-
evictOldEvents: typeof evictOldEvents;
|
|
71
|
-
gzipString: typeof gzipString;
|
|
72
87
|
uint8ToBase64: typeof uint8ToBase64;
|
|
88
|
+
gzipBytes: typeof gzipBytes;
|
|
89
|
+
mintSdkSessionId: typeof mintSdkSessionId;
|
|
90
|
+
uploadChunk: typeof uploadChunk;
|
|
91
|
+
createSession: typeof createSession;
|
|
92
|
+
joinUrl: typeof joinUrl;
|
|
93
|
+
HARD_CHUNK_BYTE_CAP: number;
|
|
94
|
+
SDK_SESSION_STORAGE_KEY: string;
|
|
73
95
|
};
|
|
74
96
|
|
|
75
|
-
export { type ReplaySampling, type SessionReplayOptions, __test__, sessionReplay };
|
|
97
|
+
export { type CurrentSessionHandle, type ReplaySampling, type SessionReplayOptions, __test__, getCurrentSession, sessionReplay };
|
|
@@ -19,6 +19,8 @@ interface FeedbackSubmission {
|
|
|
19
19
|
screenshots?: ScreenshotData[];
|
|
20
20
|
metadata?: Record<string, unknown>;
|
|
21
21
|
replayEvents?: string;
|
|
22
|
+
sessionReplayId?: string;
|
|
23
|
+
replayOffsetMs?: number;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface PluginLogger {
|
|
@@ -48,7 +50,6 @@ interface ReplaySampling {
|
|
|
48
50
|
input?: number | 'last';
|
|
49
51
|
}
|
|
50
52
|
interface SessionReplayOptions {
|
|
51
|
-
bufferSeconds?: number;
|
|
52
53
|
startAfterMs?: number;
|
|
53
54
|
sampleRate?: number;
|
|
54
55
|
sampling?: ReplaySampling;
|
|
@@ -56,20 +57,41 @@ interface SessionReplayOptions {
|
|
|
56
57
|
maskTextSelector?: string;
|
|
57
58
|
inlineStylesheet?: boolean;
|
|
58
59
|
blockSelector?: string;
|
|
60
|
+
chunkSeconds?: number;
|
|
61
|
+
chunkMaxEvents?: number;
|
|
62
|
+
chunkMaxAttempts?: number;
|
|
63
|
+
apiUrl?: string;
|
|
59
64
|
}
|
|
60
|
-
interface RrwebEvent {
|
|
61
|
-
type: number;
|
|
62
|
-
data: unknown;
|
|
63
|
-
timestamp: number;
|
|
64
|
-
}
|
|
65
|
-
declare function evictOldEvents(events: RrwebEvent[], bufferSeconds: number, now: number): void;
|
|
66
65
|
declare function uint8ToBase64(bytes: Uint8Array): string;
|
|
67
|
-
declare function
|
|
66
|
+
declare function gzipBytes(input: string): Promise<Uint8Array>;
|
|
67
|
+
declare function mintSdkSessionId(): string;
|
|
68
|
+
declare function joinUrl(apiUrl: string, path: string): string;
|
|
69
|
+
interface CreateSessionResult {
|
|
70
|
+
accepted: boolean;
|
|
71
|
+
sessionReplayId?: string;
|
|
72
|
+
dropReason?: string;
|
|
73
|
+
}
|
|
74
|
+
declare function createSession(apiUrl: string, clientId: string, sdkSessionId: string): Promise<CreateSessionResult | null>;
|
|
75
|
+
interface ChunkUploadResult {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
stopSession: boolean;
|
|
78
|
+
}
|
|
79
|
+
declare function uploadChunk(apiUrl: string, sessionReplayId: string, clientId: string, seq: number, bytes: Uint8Array, eventCount: number, durationMs: number, logger: PluginContext['logger'], maxAttempts: number): Promise<ChunkUploadResult>;
|
|
80
|
+
interface CurrentSessionHandle {
|
|
81
|
+
id: string;
|
|
82
|
+
offsetMs: number;
|
|
83
|
+
}
|
|
68
84
|
declare function sessionReplay(options?: SessionReplayOptions): UseroPlugin;
|
|
85
|
+
declare function getCurrentSession(ctx: PluginContext): CurrentSessionHandle | null;
|
|
69
86
|
declare const __test__: {
|
|
70
|
-
evictOldEvents: typeof evictOldEvents;
|
|
71
|
-
gzipString: typeof gzipString;
|
|
72
87
|
uint8ToBase64: typeof uint8ToBase64;
|
|
88
|
+
gzipBytes: typeof gzipBytes;
|
|
89
|
+
mintSdkSessionId: typeof mintSdkSessionId;
|
|
90
|
+
uploadChunk: typeof uploadChunk;
|
|
91
|
+
createSession: typeof createSession;
|
|
92
|
+
joinUrl: typeof joinUrl;
|
|
93
|
+
HARD_CHUNK_BYTE_CAP: number;
|
|
94
|
+
SDK_SESSION_STORAGE_KEY: string;
|
|
73
95
|
};
|
|
74
96
|
|
|
75
|
-
export { type ReplaySampling, type SessionReplayOptions, __test__, sessionReplay };
|
|
97
|
+
export { type CurrentSessionHandle, type ReplaySampling, type SessionReplayOptions, __test__, getCurrentSession, sessionReplay };
|
|
@@ -1,41 +1,133 @@
|
|
|
1
1
|
// src/plugins/session-replay.ts
|
|
2
|
-
var
|
|
3
|
-
bufferSeconds: 30,
|
|
2
|
+
var DEFAULTS = {
|
|
4
3
|
startAfterMs: 0,
|
|
5
4
|
sampleRate: 1,
|
|
6
5
|
sampling: { mousemove: 50, scroll: 100 },
|
|
7
6
|
maskAllInputs: true,
|
|
8
7
|
maskTextSelector: "[data-usero-mask]",
|
|
9
8
|
inlineStylesheet: true,
|
|
10
|
-
blockSelector: "[data-usero-block]"
|
|
9
|
+
blockSelector: "[data-usero-block]",
|
|
10
|
+
chunkSeconds: 10,
|
|
11
|
+
chunkMaxEvents: 5e3,
|
|
12
|
+
chunkMaxAttempts: 5,
|
|
13
|
+
apiUrl: ""
|
|
11
14
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const cutoff = now - bufferSeconds * 1e3;
|
|
15
|
-
let dropCount = 0;
|
|
16
|
-
for (const e of events) {
|
|
17
|
-
if (e.timestamp >= cutoff) break;
|
|
18
|
-
dropCount++;
|
|
19
|
-
}
|
|
20
|
-
if (dropCount > 0) events.splice(0, dropCount);
|
|
21
|
-
}
|
|
15
|
+
var SDK_SESSION_STORAGE_KEY = "usero:session-replay:sdk-session-id";
|
|
16
|
+
var HARD_CHUNK_BYTE_CAP = 4 * 1024 * 1024;
|
|
22
17
|
function uint8ToBase64(bytes) {
|
|
23
18
|
let binary = "";
|
|
24
19
|
const chunkSize = 32768;
|
|
25
20
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
26
|
-
const
|
|
27
|
-
binary += String.fromCharCode.apply(null, Array.from(
|
|
21
|
+
const slice = bytes.subarray(i, i + chunkSize);
|
|
22
|
+
binary += String.fromCharCode.apply(null, Array.from(slice));
|
|
28
23
|
}
|
|
29
24
|
return typeof btoa === "function" ? btoa(binary) : "";
|
|
30
25
|
}
|
|
31
|
-
async function
|
|
26
|
+
async function gzipBytes(input) {
|
|
32
27
|
if (typeof CompressionStream === "undefined") {
|
|
33
|
-
|
|
34
|
-
return uint8ToBase64(bytes);
|
|
28
|
+
return new TextEncoder().encode(input);
|
|
35
29
|
}
|
|
36
30
|
const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
37
|
-
const
|
|
38
|
-
return
|
|
31
|
+
const buf = await new Response(stream).arrayBuffer();
|
|
32
|
+
return new Uint8Array(buf);
|
|
33
|
+
}
|
|
34
|
+
function generateRandomId() {
|
|
35
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
36
|
+
return crypto.randomUUID();
|
|
37
|
+
}
|
|
38
|
+
const bytes = new Uint8Array(16);
|
|
39
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
40
|
+
crypto.getRandomValues(bytes);
|
|
41
|
+
} else {
|
|
42
|
+
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
|
|
43
|
+
}
|
|
44
|
+
let out = "";
|
|
45
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function mintSdkSessionId() {
|
|
49
|
+
try {
|
|
50
|
+
const existing = window.sessionStorage?.getItem(SDK_SESSION_STORAGE_KEY);
|
|
51
|
+
if (existing && /^[a-z0-9-]{8,}$/i.test(existing)) return existing;
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
const id = generateRandomId();
|
|
55
|
+
try {
|
|
56
|
+
window.sessionStorage?.setItem(SDK_SESSION_STORAGE_KEY, id);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
function joinUrl(apiUrl, path) {
|
|
62
|
+
return `${apiUrl.replace(/\/$/, "")}${path}`;
|
|
63
|
+
}
|
|
64
|
+
async function createSession(apiUrl, clientId, sdkSessionId) {
|
|
65
|
+
try {
|
|
66
|
+
const startUrl = typeof window !== "undefined" && window.location ? window.location.href : void 0;
|
|
67
|
+
const userAgent = typeof navigator !== "undefined" && navigator.userAgent ? navigator.userAgent : void 0;
|
|
68
|
+
const res = await fetch(joinUrl(apiUrl, "/api/replay-sessions"), {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
clientId,
|
|
73
|
+
sdkSessionId,
|
|
74
|
+
startUrl,
|
|
75
|
+
userAgent,
|
|
76
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) return null;
|
|
80
|
+
const json = await res.json();
|
|
81
|
+
if (typeof json.accepted !== "boolean") return null;
|
|
82
|
+
const result = { accepted: json.accepted };
|
|
83
|
+
if (typeof json.sessionReplayId === "string") result.sessionReplayId = json.sessionReplayId;
|
|
84
|
+
if (typeof json.dropReason === "string") result.dropReason = json.dropReason;
|
|
85
|
+
return result;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function uploadChunk(apiUrl, sessionReplayId, clientId, seq, bytes, eventCount, durationMs, logger, maxAttempts) {
|
|
91
|
+
const url = joinUrl(
|
|
92
|
+
apiUrl,
|
|
93
|
+
`/api/replay-sessions/${encodeURIComponent(sessionReplayId)}/chunks/${seq}`
|
|
94
|
+
);
|
|
95
|
+
let attempt = 0;
|
|
96
|
+
while (attempt < maxAttempts) {
|
|
97
|
+
try {
|
|
98
|
+
const buffer = bytes.buffer.slice(
|
|
99
|
+
bytes.byteOffset,
|
|
100
|
+
bytes.byteOffset + bytes.byteLength
|
|
101
|
+
);
|
|
102
|
+
const blob = new Blob([buffer], { type: "application/octet-stream" });
|
|
103
|
+
const res = await fetch(url, {
|
|
104
|
+
method: "PUT",
|
|
105
|
+
body: blob,
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/octet-stream",
|
|
108
|
+
"X-Usero-Client-Id": clientId,
|
|
109
|
+
"X-Usero-Event-Count": String(eventCount),
|
|
110
|
+
"X-Usero-Duration-Ms": String(Math.max(0, Math.round(durationMs)))
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (res.ok) return { ok: true, stopSession: false };
|
|
114
|
+
if (res.status === 409) {
|
|
115
|
+
logger.warn(`chunk ${seq} rejected with 409, stopping session`);
|
|
116
|
+
return { ok: false, stopSession: true };
|
|
117
|
+
}
|
|
118
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
|
|
119
|
+
logger.error(`chunk ${seq} rejected with ${res.status}`);
|
|
120
|
+
return { ok: false, stopSession: false };
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.warn(`chunk ${seq} attempt ${attempt + 1} failed`, err);
|
|
124
|
+
}
|
|
125
|
+
attempt += 1;
|
|
126
|
+
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
128
|
+
}
|
|
129
|
+
logger.error(`chunk ${seq} dropped after ${maxAttempts} attempts`);
|
|
130
|
+
return { ok: false, stopSession: false };
|
|
39
131
|
}
|
|
40
132
|
async function loadRrwebRecord() {
|
|
41
133
|
try {
|
|
@@ -51,20 +143,95 @@ async function loadRrwebRecord() {
|
|
|
51
143
|
return null;
|
|
52
144
|
}
|
|
53
145
|
}
|
|
146
|
+
function scheduleChunkUpload(store, ctx) {
|
|
147
|
+
if (!store.sessionReplayId) return;
|
|
148
|
+
if (store.pendingEvents.length === 0) return;
|
|
149
|
+
const events = store.pendingEvents;
|
|
150
|
+
const eventCount = events.length;
|
|
151
|
+
const firstTs = store.pendingFirstTs ?? 0;
|
|
152
|
+
const lastTs = store.pendingLastTs ?? firstTs;
|
|
153
|
+
const durationMs = Math.max(0, lastTs - firstTs);
|
|
154
|
+
const seq = store.nextChunkSeq;
|
|
155
|
+
store.nextChunkSeq += 1;
|
|
156
|
+
store.pendingEvents = [];
|
|
157
|
+
store.pendingFirstTs = null;
|
|
158
|
+
store.pendingLastTs = null;
|
|
159
|
+
const sessionReplayId = store.sessionReplayId;
|
|
160
|
+
const apiUrl = store.options.apiUrl;
|
|
161
|
+
const clientId = store.clientId;
|
|
162
|
+
const maxAttempts = store.options.chunkMaxAttempts;
|
|
163
|
+
store.pendingUploads += 1;
|
|
164
|
+
store.uploadQueue = store.uploadQueue.then(async () => {
|
|
165
|
+
try {
|
|
166
|
+
if (store.cancelled) return;
|
|
167
|
+
const json = JSON.stringify(events);
|
|
168
|
+
const bytes = await gzipBytes(json);
|
|
169
|
+
if (bytes.byteLength > HARD_CHUNK_BYTE_CAP) {
|
|
170
|
+
ctx.logger.error(
|
|
171
|
+
`chunk ${seq} exceeds 4MB hard cap (${bytes.byteLength} bytes), dropping`
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const result = await uploadChunk(
|
|
176
|
+
apiUrl,
|
|
177
|
+
sessionReplayId,
|
|
178
|
+
clientId,
|
|
179
|
+
seq,
|
|
180
|
+
bytes,
|
|
181
|
+
eventCount,
|
|
182
|
+
durationMs,
|
|
183
|
+
ctx.logger,
|
|
184
|
+
maxAttempts
|
|
185
|
+
);
|
|
186
|
+
if (result.stopSession) {
|
|
187
|
+
store.stopped = true;
|
|
188
|
+
stopRrweb(store);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
ctx.logger.error(`chunk ${seq} encode failed`, err);
|
|
192
|
+
} finally {
|
|
193
|
+
store.pendingUploads -= 1;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function flushPendingChunk(store, ctx) {
|
|
198
|
+
if (store.stopped || store.cancelled) return;
|
|
199
|
+
if (store.pendingEvents.length === 0) return;
|
|
200
|
+
scheduleChunkUpload(store, ctx);
|
|
201
|
+
}
|
|
202
|
+
function stopRrweb(store) {
|
|
203
|
+
if (store.stopRecording) {
|
|
204
|
+
try {
|
|
205
|
+
store.stopRecording();
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
store.stopRecording = null;
|
|
209
|
+
}
|
|
210
|
+
if (store.chunkFlushTimer) {
|
|
211
|
+
clearInterval(store.chunkFlushTimer);
|
|
212
|
+
store.chunkFlushTimer = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
54
215
|
function startRecording(store, ctx) {
|
|
55
|
-
if (store.cancelled || store.stopRecording || store.loadInProgress) return;
|
|
216
|
+
if (store.cancelled || store.stopped || store.stopRecording || store.loadInProgress) return;
|
|
56
217
|
store.loadInProgress = true;
|
|
57
218
|
void loadRrwebRecord().then((record) => {
|
|
58
219
|
store.loadInProgress = false;
|
|
59
|
-
if (store.cancelled || !record) {
|
|
220
|
+
if (store.cancelled || store.stopped || !record) {
|
|
60
221
|
if (!record) ctx.logger.warn("rrweb failed to load, replay disabled");
|
|
61
222
|
return;
|
|
62
223
|
}
|
|
63
224
|
try {
|
|
64
225
|
const stop = record({
|
|
65
226
|
emit: (event) => {
|
|
66
|
-
store.
|
|
67
|
-
|
|
227
|
+
if (store.stopped || store.cancelled) return;
|
|
228
|
+
if (store.recordingStartedAt === null) store.recordingStartedAt = event.timestamp;
|
|
229
|
+
store.pendingEvents.push(event);
|
|
230
|
+
if (store.pendingFirstTs === null) store.pendingFirstTs = event.timestamp;
|
|
231
|
+
store.pendingLastTs = event.timestamp;
|
|
232
|
+
if (store.pendingEvents.length >= store.options.chunkMaxEvents) {
|
|
233
|
+
scheduleChunkUpload(store, ctx);
|
|
234
|
+
}
|
|
68
235
|
},
|
|
69
236
|
maskAllInputs: store.options.maskAllInputs,
|
|
70
237
|
maskTextSelector: store.options.maskTextSelector || void 0,
|
|
@@ -73,45 +240,124 @@ function startRecording(store, ctx) {
|
|
|
73
240
|
sampling: store.options.sampling
|
|
74
241
|
});
|
|
75
242
|
store.stopRecording = stop;
|
|
243
|
+
store.record = record;
|
|
244
|
+
scheduleShadowSnapshot(store, ctx);
|
|
245
|
+
store.chunkFlushTimer = setInterval(
|
|
246
|
+
() => flushPendingChunk(store, ctx),
|
|
247
|
+
store.options.chunkSeconds * 1e3
|
|
248
|
+
);
|
|
76
249
|
} catch (err) {
|
|
77
250
|
ctx.logger.error("rrweb record() threw", err);
|
|
78
251
|
}
|
|
79
252
|
});
|
|
80
253
|
}
|
|
254
|
+
function scheduleShadowSnapshot(store, ctx) {
|
|
255
|
+
if (store.cancelled || store.stopped || !store.record || !store.stopRecording) return;
|
|
256
|
+
const fn = store.record.takeFullSnapshot;
|
|
257
|
+
if (typeof fn !== "function") return;
|
|
258
|
+
try {
|
|
259
|
+
fn(true);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
ctx.logger.warn("takeFullSnapshot threw", err);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function finalise(store, ctx, opts) {
|
|
265
|
+
if (!store.sessionReplayId) return;
|
|
266
|
+
if (store.pendingEvents.length > 0) flushPendingChunk(store, ctx);
|
|
267
|
+
const url = joinUrl(
|
|
268
|
+
store.options.apiUrl,
|
|
269
|
+
`/api/replay-sessions/${encodeURIComponent(store.sessionReplayId)}/finalise`
|
|
270
|
+
);
|
|
271
|
+
const body = JSON.stringify({ clientId: store.clientId, endedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
272
|
+
if (opts.useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
|
|
273
|
+
try {
|
|
274
|
+
const blob = new Blob([body], { type: "application/json" });
|
|
275
|
+
navigator.sendBeacon(url, blob);
|
|
276
|
+
return;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
ctx.logger.warn("finalise sendBeacon threw", err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
void fetch(url, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
body,
|
|
284
|
+
headers: { "Content-Type": "application/json" },
|
|
285
|
+
keepalive: true
|
|
286
|
+
}).catch((err) => ctx.logger.warn("finalise fetch failed", err));
|
|
287
|
+
}
|
|
81
288
|
function sessionReplay(options = {}) {
|
|
82
289
|
const merged = {
|
|
83
|
-
...
|
|
290
|
+
...DEFAULTS,
|
|
84
291
|
...options,
|
|
85
|
-
sampling: { ...
|
|
292
|
+
sampling: { ...DEFAULTS.sampling, ...options.sampling ?? {} }
|
|
86
293
|
};
|
|
87
294
|
return {
|
|
88
295
|
name: "session-replay",
|
|
89
296
|
onInit(ctx) {
|
|
297
|
+
if (typeof window === "undefined") return;
|
|
90
298
|
if (merged.sampleRate < 1 && Math.random() >= merged.sampleRate) {
|
|
91
299
|
ctx.logger.debug("skipped by sampleRate");
|
|
92
300
|
return;
|
|
93
301
|
}
|
|
94
|
-
|
|
302
|
+
const apiUrl = merged.apiUrl || ctx.baseUrl;
|
|
303
|
+
if (!apiUrl) {
|
|
304
|
+
ctx.logger.error("session-replay needs an apiUrl (via options or PluginContext)");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const sdkSessionId = mintSdkSessionId();
|
|
95
308
|
const store = {
|
|
96
|
-
|
|
97
|
-
|
|
309
|
+
options: { ...merged, apiUrl },
|
|
310
|
+
clientId: ctx.clientId,
|
|
311
|
+
sdkSessionId,
|
|
312
|
+
sessionReplayId: null,
|
|
313
|
+
recordingStartedAt: null,
|
|
314
|
+
pendingEvents: [],
|
|
315
|
+
pendingFirstTs: null,
|
|
316
|
+
pendingLastTs: null,
|
|
317
|
+
nextChunkSeq: 0,
|
|
318
|
+
uploadQueue: Promise.resolve(),
|
|
319
|
+
pendingUploads: 0,
|
|
320
|
+
chunkFlushTimer: null,
|
|
98
321
|
startTimer: null,
|
|
99
322
|
pageHideHandler: null,
|
|
323
|
+
shadowUpdateHandler: null,
|
|
324
|
+
record: null,
|
|
325
|
+
stopRecording: null,
|
|
100
326
|
loadInProgress: false,
|
|
101
327
|
cancelled: false,
|
|
102
|
-
|
|
328
|
+
stopped: false
|
|
103
329
|
};
|
|
104
330
|
ctx.setStore(store);
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
331
|
+
const onShadowUpdate = () => scheduleShadowSnapshot(store, ctx);
|
|
332
|
+
store.shadowUpdateHandler = onShadowUpdate;
|
|
333
|
+
window.addEventListener("usero:shadow-update", onShadowUpdate);
|
|
334
|
+
const onPageHide = () => {
|
|
335
|
+
finalise(store, ctx, { useBeacon: true });
|
|
336
|
+
store.stopped = true;
|
|
337
|
+
stopRrweb(store);
|
|
338
|
+
};
|
|
339
|
+
store.pageHideHandler = onPageHide;
|
|
340
|
+
window.addEventListener("pagehide", onPageHide);
|
|
341
|
+
const begin = async () => {
|
|
342
|
+
if (store.cancelled) return;
|
|
343
|
+
const created = await createSession(apiUrl, ctx.clientId, sdkSessionId);
|
|
344
|
+
if (!created) {
|
|
345
|
+
ctx.logger.warn("session create failed, replay disabled");
|
|
346
|
+
store.stopped = true;
|
|
347
|
+
return;
|
|
109
348
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
349
|
+
if (!created.accepted) {
|
|
350
|
+
ctx.logger.info(`session-replay declined: ${created.dropReason ?? "unknown"}`);
|
|
351
|
+
store.stopped = true;
|
|
352
|
+
return;
|
|
114
353
|
}
|
|
354
|
+
if (!created.sessionReplayId) {
|
|
355
|
+
ctx.logger.error("server accepted but returned no sessionReplayId");
|
|
356
|
+
store.stopped = true;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
store.sessionReplayId = created.sessionReplayId;
|
|
360
|
+
store.recordingStartedAt = Date.now();
|
|
115
361
|
startRecording(store, ctx);
|
|
116
362
|
};
|
|
117
363
|
if (merged.startAfterMs > 0) {
|
|
@@ -121,55 +367,65 @@ function sessionReplay(options = {}) {
|
|
|
121
367
|
clearTimeout(store.startTimer);
|
|
122
368
|
store.startTimer = null;
|
|
123
369
|
}
|
|
124
|
-
if (store.pageHideHandler) {
|
|
125
|
-
window.removeEventListener("pagehide", store.pageHideHandler);
|
|
126
|
-
window.removeEventListener("beforeunload", store.pageHideHandler);
|
|
127
|
-
store.pageHideHandler = null;
|
|
128
|
-
}
|
|
129
370
|
};
|
|
130
|
-
store.pageHideHandler = cancelOnExit;
|
|
131
371
|
window.addEventListener("pagehide", cancelOnExit, { once: true });
|
|
132
372
|
window.addEventListener("beforeunload", cancelOnExit, { once: true });
|
|
133
|
-
store.startTimer = setTimeout(
|
|
373
|
+
store.startTimer = setTimeout(() => {
|
|
374
|
+
void begin();
|
|
375
|
+
}, merged.startAfterMs);
|
|
134
376
|
} else {
|
|
135
|
-
begin();
|
|
377
|
+
void begin();
|
|
136
378
|
}
|
|
137
379
|
},
|
|
138
|
-
|
|
380
|
+
onFeedbackSubmit(ctx) {
|
|
139
381
|
const store = ctx.getStore();
|
|
140
|
-
if (!store || store.cancelled || store.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const replayEvents = await gzipString(json);
|
|
145
|
-
return { replayEvents };
|
|
146
|
-
} catch (err) {
|
|
147
|
-
ctx.logger.error("failed to encode replay buffer", err);
|
|
148
|
-
return void 0;
|
|
149
|
-
}
|
|
382
|
+
if (!store || store.cancelled || store.stopped) return void 0;
|
|
383
|
+
if (!store.sessionReplayId) return void 0;
|
|
384
|
+
const offsetMs = store.recordingStartedAt !== null ? Math.max(0, Date.now() - store.recordingStartedAt) : 0;
|
|
385
|
+
return { sessionReplayId: store.sessionReplayId, replayOffsetMs: offsetMs };
|
|
150
386
|
},
|
|
151
387
|
onDestroy(ctx) {
|
|
152
388
|
const store = ctx.getStore();
|
|
153
389
|
if (!store) return;
|
|
154
390
|
store.cancelled = true;
|
|
155
|
-
if (store.startTimer)
|
|
391
|
+
if (store.startTimer) {
|
|
392
|
+
clearTimeout(store.startTimer);
|
|
393
|
+
store.startTimer = null;
|
|
394
|
+
}
|
|
156
395
|
if (store.pageHideHandler) {
|
|
157
396
|
window.removeEventListener("pagehide", store.pageHideHandler);
|
|
158
|
-
|
|
397
|
+
store.pageHideHandler = null;
|
|
159
398
|
}
|
|
160
|
-
if (store.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
399
|
+
if (store.shadowUpdateHandler) {
|
|
400
|
+
window.removeEventListener("usero:shadow-update", store.shadowUpdateHandler);
|
|
401
|
+
store.shadowUpdateHandler = null;
|
|
402
|
+
}
|
|
403
|
+
if (store.sessionReplayId && !store.stopped) {
|
|
404
|
+
finalise(store, ctx, { useBeacon: false });
|
|
166
405
|
}
|
|
167
|
-
store.
|
|
406
|
+
store.stopped = true;
|
|
407
|
+
stopRrweb(store);
|
|
408
|
+
store.pendingEvents.length = 0;
|
|
168
409
|
}
|
|
169
410
|
};
|
|
170
411
|
}
|
|
171
|
-
|
|
412
|
+
function getCurrentSession(ctx) {
|
|
413
|
+
const store = ctx.getStore();
|
|
414
|
+
if (!store || store.cancelled || store.stopped || !store.sessionReplayId) return null;
|
|
415
|
+
const offsetMs = store.recordingStartedAt !== null ? Math.max(0, Date.now() - store.recordingStartedAt) : 0;
|
|
416
|
+
return { id: store.sessionReplayId, offsetMs };
|
|
417
|
+
}
|
|
418
|
+
var __test__ = {
|
|
419
|
+
uint8ToBase64,
|
|
420
|
+
gzipBytes,
|
|
421
|
+
mintSdkSessionId,
|
|
422
|
+
uploadChunk,
|
|
423
|
+
createSession,
|
|
424
|
+
joinUrl,
|
|
425
|
+
HARD_CHUNK_BYTE_CAP,
|
|
426
|
+
SDK_SESSION_STORAGE_KEY
|
|
427
|
+
};
|
|
172
428
|
|
|
173
|
-
export { __test__, sessionReplay };
|
|
429
|
+
export { __test__, getCurrentSession, sessionReplay };
|
|
174
430
|
//# sourceMappingURL=session-replay.js.map
|
|
175
431
|
//# sourceMappingURL=session-replay.js.map
|