@lightworkai.official/debug-capture 0.11.0 → 0.12.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/README.md +58 -0
- package/dist/base64.d.ts +1 -0
- package/dist/capture/replay.d.ts +28 -0
- package/dist/capture/replayRedact.d.ts +6 -0
- package/dist/config.d.ts +40 -0
- package/dist/debug-capture.js +9 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.mjs +20 -14
- package/dist/replayPack.d.ts +20 -0
- package/dist/rrweb-record.mjs +78 -0
- package/dist/submit.d.ts +1 -1
- package/dist/types.d.ts +49 -0
- package/dist/ui/strings.d.ts +3 -0
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +10 -5
- package/src/base64.ts +20 -0
- package/src/budget.ts +3 -3
- package/src/bundle.ts +8 -1
- package/src/capture/consoleBuffer.ts +36 -1
- package/src/capture/networkBuffer.ts +4 -4
- package/src/capture/replay.ts +167 -0
- package/src/capture/replayRedact.ts +69 -0
- package/src/config.ts +62 -0
- package/src/embed.ts +17 -1
- package/src/index.ts +3 -1
- package/src/install.ts +3 -0
- package/src/replayPack.ts +66 -0
- package/src/styles.css +1 -1
- package/src/submit.ts +26 -23
- package/src/types.ts +52 -0
- package/src/ui/element.ts +24 -0
- package/src/ui/strings.ts +7 -0
- package/src/ui/styles.ts +6 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last minute of the page, kept in memory for the moment someone reports.
|
|
3
|
+
*
|
|
4
|
+
* rrweb records the DOM: one full snapshot, then every mutation, scroll,
|
|
5
|
+
* click and (masked) keystroke after it. A replay can only start at a
|
|
6
|
+
* snapshot, so the recorder is asked for a fresh one every half-window and
|
|
7
|
+
* the events are kept in WINDOWS, each beginning with its snapshot. A report
|
|
8
|
+
* takes whole windows from the newest back until the configured length is
|
|
9
|
+
* covered — which is why it always plays from the start and why it may run
|
|
10
|
+
* up to half as long again as asked.
|
|
11
|
+
*
|
|
12
|
+
* Three windows are retained, no more: the ones a report can use, and the one
|
|
13
|
+
* being filled. A page that mutates without pause would otherwise grow the
|
|
14
|
+
* buffer for as long as the tab stayed open. `checkoutEveryNth` is the second
|
|
15
|
+
* guard — a mutation storm cuts a new window early, and the old one ages out
|
|
16
|
+
* instead of ballooning.
|
|
17
|
+
*
|
|
18
|
+
* The recorder is loaded on demand and after the page has settled. It is a
|
|
19
|
+
* quarter of this package's weight again, and a host that did not ask for a
|
|
20
|
+
* replay never downloads it.
|
|
21
|
+
*/
|
|
22
|
+
import { replayOptions } from "../config";
|
|
23
|
+
import type { ReplayCapture, ReplayEvent } from "../types";
|
|
24
|
+
|
|
25
|
+
/** rrweb `EventType.Meta` — the first event of every full snapshot. */
|
|
26
|
+
const META = 4;
|
|
27
|
+
/** Windows kept: two complete, one filling. */
|
|
28
|
+
const KEEP = 3;
|
|
29
|
+
/** Cut a window early past this many events, whatever the clock says. */
|
|
30
|
+
const MAX_EVENTS_PER_WINDOW = 4000;
|
|
31
|
+
/** Our own dialog and toasts are never in the picture. */
|
|
32
|
+
const OWN_UI = "[data-debug-reporter]";
|
|
33
|
+
|
|
34
|
+
type Recorder = (options: Record<string, unknown>) => (() => void) | undefined;
|
|
35
|
+
export interface RecorderModule {
|
|
36
|
+
record: Recorder;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where the recorder comes from. A dynamic import of a declared dependency
|
|
41
|
+
* by default, which a host's bundler turns into a chunk of its own; the
|
|
42
|
+
* script-tag build has no bundler and points this at a file beside itself.
|
|
43
|
+
*/
|
|
44
|
+
let loadRecorder: () => Promise<RecorderModule> = () => import("@rrweb/record") as unknown as Promise<RecorderModule>;
|
|
45
|
+
export function setRecorderLoader(load: () => Promise<RecorderModule>): void {
|
|
46
|
+
loadRecorder = load;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let windows: ReplayEvent[][] = [];
|
|
50
|
+
let stop: (() => void) | null = null;
|
|
51
|
+
let starting = false;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start recording, once the host asked for it and the page has had a moment.
|
|
55
|
+
*
|
|
56
|
+
* Idle-scheduled: the first thing the recorder does is walk the whole
|
|
57
|
+
* document, and doing that in the middle of the host's own start-up is how a
|
|
58
|
+
* widget gets blamed for a slow first paint.
|
|
59
|
+
*/
|
|
60
|
+
export function installReplay(): void {
|
|
61
|
+
if (typeof window === "undefined" || stop || starting) return;
|
|
62
|
+
const options = replayOptions();
|
|
63
|
+
if (!options) return;
|
|
64
|
+
starting = true;
|
|
65
|
+
const begin = () => void start(options);
|
|
66
|
+
if ("requestIdleCallback" in window) {
|
|
67
|
+
(window as Window & { requestIdleCallback: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback(
|
|
68
|
+
begin,
|
|
69
|
+
{ timeout: 3000 },
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
setTimeout(begin, 1000);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function start(options: NonNullable<ReturnType<typeof replayOptions>>): Promise<void> {
|
|
77
|
+
let record: Recorder;
|
|
78
|
+
try {
|
|
79
|
+
record = (await loadRecorder()).record;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
starting = false;
|
|
82
|
+
console.warn("[debug-capture] session replay could not start:", error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (stop) return;
|
|
86
|
+
try {
|
|
87
|
+
stop =
|
|
88
|
+
record({
|
|
89
|
+
emit: (event: ReplayEvent) => push(event),
|
|
90
|
+
checkoutEveryNms: Math.max(1000, (options.seconds * 1000) / 2),
|
|
91
|
+
checkoutEveryNth: MAX_EVENTS_PER_WINDOW,
|
|
92
|
+
// Every value typed into anything, masked before it is recorded. The
|
|
93
|
+
// player shows the field being filled with asterisks.
|
|
94
|
+
maskAllInputs: true,
|
|
95
|
+
...(options.maskText ? { maskTextSelector: "*" } : {}),
|
|
96
|
+
blockSelector: `${options.blockSelector}, ${OWN_UI}`,
|
|
97
|
+
// Scripts, comments and <meta> tags are weight the player never uses.
|
|
98
|
+
slimDOMOptions: "all",
|
|
99
|
+
// Reads the page's stylesheets in, so a replay looks like the page and
|
|
100
|
+
// not like an unstyled document — the desk cannot reach the host's CSS.
|
|
101
|
+
inlineStylesheet: true,
|
|
102
|
+
sampling: {
|
|
103
|
+
// A mouse trail every 50 ms is enough to see where they went.
|
|
104
|
+
mousemove: 50,
|
|
105
|
+
scroll: 150,
|
|
106
|
+
media: 800,
|
|
107
|
+
// The final value of a field, not every keystroke — the value is
|
|
108
|
+
// masked anyway, and a keystroke each is most of a form's events.
|
|
109
|
+
input: "last",
|
|
110
|
+
},
|
|
111
|
+
// Charts and PDFs render to canvas; a frame each is megabytes and the
|
|
112
|
+
// ticket does not need the chart, it needs what was pressed next to it.
|
|
113
|
+
recordCanvas: false,
|
|
114
|
+
errorHandler: () => true,
|
|
115
|
+
}) ?? null;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.warn("[debug-capture] session replay could not start:", error);
|
|
118
|
+
} finally {
|
|
119
|
+
starting = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function push(event: ReplayEvent): void {
|
|
124
|
+
// A Meta event opens every full snapshot — the first, and each checkout.
|
|
125
|
+
if (event.type === META || windows.length === 0) {
|
|
126
|
+
windows.push([]);
|
|
127
|
+
if (windows.length > KEEP) windows = windows.slice(-KEEP);
|
|
128
|
+
}
|
|
129
|
+
windows[windows.length - 1]!.push(event);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The events a report should carry: whole windows, newest first, until the
|
|
134
|
+
* configured length is covered — or everything, early in a session.
|
|
135
|
+
*/
|
|
136
|
+
export function takeReplay(now = Date.now()): ReplayCapture | null {
|
|
137
|
+
const options = replayOptions();
|
|
138
|
+
if (!options || !windows.length) return null;
|
|
139
|
+
const cutoff = now - options.seconds * 1000;
|
|
140
|
+
let first = windows.length - 1;
|
|
141
|
+
while (first > 0 && (windows[first]![0]?.timestamp ?? now) > cutoff) first -= 1;
|
|
142
|
+
// Copied: the buffers stay live, and the report is being written while the
|
|
143
|
+
// page keeps changing underneath it.
|
|
144
|
+
const events = windows.slice(first).flat();
|
|
145
|
+
const startedAt = events[0]?.timestamp;
|
|
146
|
+
const endedAt = events[events.length - 1]?.timestamp;
|
|
147
|
+
if (startedAt === undefined || endedAt === undefined) return null;
|
|
148
|
+
return {
|
|
149
|
+
events,
|
|
150
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
151
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
152
|
+
durationMs: Math.max(0, endedAt - startedAt),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isReplayRecording(): boolean {
|
|
157
|
+
return stop !== null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function stopReplay(): void {
|
|
161
|
+
stop?.();
|
|
162
|
+
stop = null;
|
|
163
|
+
windows = [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Test seam: feed events as the recorder would. */
|
|
167
|
+
export const _replayInternals = { push, reset: () => void (windows = []) };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widget's URL policy, applied to the replay.
|
|
3
|
+
*
|
|
4
|
+
* rrweb records the page as it is, and a page is full of URLs: every link's
|
|
5
|
+
* href, every image's src, the address bar in the Meta event. The network
|
|
6
|
+
* log and the action trail already mask the query keys that carry secrets
|
|
7
|
+
* (`redactUrl` — tokens, signatures, one-time codes); a replay that kept
|
|
8
|
+
* them would undo that in the one file nobody reads by eye.
|
|
9
|
+
*
|
|
10
|
+
* Every URL-bearing attribute in the full snapshot and in every mutation is
|
|
11
|
+
* passed through the same function. Text is untouched: what the page SAYS
|
|
12
|
+
* is what a replay is for, and a host whose text is itself the secret has
|
|
13
|
+
* `maskText`.
|
|
14
|
+
*/
|
|
15
|
+
import { redactUrl } from "./redact";
|
|
16
|
+
import type { ReplayEvent } from "../types";
|
|
17
|
+
|
|
18
|
+
const META = 4;
|
|
19
|
+
const FULL_SNAPSHOT = 2;
|
|
20
|
+
const INCREMENTAL = 3;
|
|
21
|
+
const MUTATION = 0;
|
|
22
|
+
|
|
23
|
+
const URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "xlink:href", "data", "poster", "srcset"]);
|
|
24
|
+
|
|
25
|
+
interface SerializedNode {
|
|
26
|
+
attributes?: Record<string, unknown>;
|
|
27
|
+
childNodes?: SerializedNode[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function redactAttributes(attributes: Record<string, unknown> | undefined): void {
|
|
31
|
+
if (!attributes) return;
|
|
32
|
+
for (const key of Object.keys(attributes)) {
|
|
33
|
+
const value = attributes[key];
|
|
34
|
+
if (typeof value !== "string" || !URL_ATTRIBUTES.has(key) || !value.includes("?")) continue;
|
|
35
|
+
attributes[key] = redactUrl(value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walk(node: SerializedNode | undefined): void {
|
|
40
|
+
if (!node) return;
|
|
41
|
+
redactAttributes(node.attributes);
|
|
42
|
+
for (const child of node.childNodes ?? []) walk(child);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* In place, on a copy: the recorder's buffers are live and a report may be
|
|
47
|
+
* filed again, so the events handed in are cloned before anything is masked.
|
|
48
|
+
*/
|
|
49
|
+
export function redactReplay(events: readonly ReplayEvent[]): ReplayEvent[] {
|
|
50
|
+
// A JSON round trip rather than structuredClone: the events are JSON by
|
|
51
|
+
// construction, and this is the one clone every browser the widget runs in
|
|
52
|
+
// can make.
|
|
53
|
+
const out: ReplayEvent[] = JSON.parse(JSON.stringify(events)) as ReplayEvent[];
|
|
54
|
+
for (const event of out) {
|
|
55
|
+
const data = event.data as Record<string, unknown> | null;
|
|
56
|
+
if (!data || typeof data !== "object") continue;
|
|
57
|
+
if (event.type === META && typeof data.href === "string") {
|
|
58
|
+
data.href = redactUrl(data.href);
|
|
59
|
+
} else if (event.type === FULL_SNAPSHOT) {
|
|
60
|
+
walk(data.node as SerializedNode | undefined);
|
|
61
|
+
} else if (event.type === INCREMENTAL && data.source === MUTATION) {
|
|
62
|
+
for (const add of (data.adds as Array<{ node?: SerializedNode }> | undefined) ?? []) walk(add.node);
|
|
63
|
+
for (const change of (data.attributes as Array<{ attributes?: Record<string, unknown> }> | undefined) ?? []) {
|
|
64
|
+
redactAttributes(change.attributes);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -114,6 +114,27 @@ export interface DebugCaptureConfig {
|
|
|
114
114
|
capture?: CaptureOptions;
|
|
115
115
|
redact?: RedactOptions;
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Keep a replay of the last minute and attach it to a report.
|
|
119
|
+
*
|
|
120
|
+
* Off unless set. What is kept is the DOM, not pixels — rrweb records a
|
|
121
|
+
* snapshot of the page and every change to it after that, and the desk plays
|
|
122
|
+
* it back as the page the person was looking at, with their clicks, scrolls
|
|
123
|
+
* and typing (masked) on top. It is the difference between "I pressed save
|
|
124
|
+
* and it broke" and watching them press save and it break.
|
|
125
|
+
*
|
|
126
|
+
* It is also the screen, which is why it is opt-in and why the defaults hide
|
|
127
|
+
* everything they can: every input's value is masked before it is recorded,
|
|
128
|
+
* and anything under `blockSelector` is recorded as a grey box of the same
|
|
129
|
+
* size. Mark the parts of your app that must never leave the browser —
|
|
130
|
+
* document viewers, a citizen's record — with `data-lw-private`, and set
|
|
131
|
+
* `maskText` if the text on your screens is itself the secret.
|
|
132
|
+
*
|
|
133
|
+
* replay: true // 60 s, masked inputs
|
|
134
|
+
* replay: { seconds: 90, maskText: true }
|
|
135
|
+
*/
|
|
136
|
+
replay?: boolean | ReplayOptions;
|
|
137
|
+
|
|
117
138
|
/**
|
|
118
139
|
* A DOM-rendering screenshot fallback, for browsers without the Screen
|
|
119
140
|
* Capture API. Supplied by the host so this package never names a dependency
|
|
@@ -181,6 +202,25 @@ export interface CaptureOptions {
|
|
|
181
202
|
overlaySelectors?: string[];
|
|
182
203
|
}
|
|
183
204
|
|
|
205
|
+
export interface ReplayOptions {
|
|
206
|
+
/** How much history to send with a report. Default 60. */
|
|
207
|
+
seconds?: number;
|
|
208
|
+
/**
|
|
209
|
+
* Elements never recorded — drawn as a box of their size, contents and all
|
|
210
|
+
* changes inside them dropped. Default `[data-lw-private]`; the widget's own
|
|
211
|
+
* dialog and toasts are always excluded.
|
|
212
|
+
*/
|
|
213
|
+
blockSelector?: string;
|
|
214
|
+
/** Mask every piece of text too, not only what is typed. Default false. */
|
|
215
|
+
maskText?: boolean;
|
|
216
|
+
/**
|
|
217
|
+
* The most a replay may weigh once compressed. A report still files when its
|
|
218
|
+
* replay is over this; the replay is left out and the desk is told why.
|
|
219
|
+
* Default 4 MB, under the host's 5 MB per-file cap.
|
|
220
|
+
*/
|
|
221
|
+
maxBytes?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
184
224
|
export interface RedactOptions {
|
|
185
225
|
/** Extra header names to mask. Matched case-insensitively, as whole names. */
|
|
186
226
|
headers?: string[];
|
|
@@ -301,6 +341,28 @@ export function overlaySelectors(): string[] {
|
|
|
301
341
|
return current?.capture?.overlaySelectors ?? [];
|
|
302
342
|
}
|
|
303
343
|
|
|
344
|
+
const REPLAY_DEFAULTS: Required<ReplayOptions> = {
|
|
345
|
+
seconds: 60,
|
|
346
|
+
blockSelector: "[data-lw-private]",
|
|
347
|
+
maskText: false,
|
|
348
|
+
maxBytes: 4_000_000,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** The replay settings, filled in — or null when the host did not ask for one. */
|
|
352
|
+
export function replayOptions(): Required<ReplayOptions> | null {
|
|
353
|
+
const set = current?.replay;
|
|
354
|
+
if (!set) return null;
|
|
355
|
+
const given = set === true ? {} : set;
|
|
356
|
+
const seconds = typeof given.seconds === "number" && given.seconds > 0 ? given.seconds : REPLAY_DEFAULTS.seconds;
|
|
357
|
+
const maxBytes = typeof given.maxBytes === "number" && given.maxBytes > 0 ? given.maxBytes : REPLAY_DEFAULTS.maxBytes;
|
|
358
|
+
return {
|
|
359
|
+
seconds,
|
|
360
|
+
blockSelector: given.blockSelector?.trim() || REPLAY_DEFAULTS.blockSelector,
|
|
361
|
+
maskText: given.maskText === true,
|
|
362
|
+
maxBytes,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
304
366
|
/**
|
|
305
367
|
* Whether a response body from this URL may be stored.
|
|
306
368
|
*
|
package/src/embed.ts
CHANGED
|
@@ -8,16 +8,31 @@
|
|
|
8
8
|
* data-app-name="ระบบสารบรรณ"
|
|
9
9
|
* data-app-version="1.4.2"
|
|
10
10
|
* data-environment="production"
|
|
11
|
-
* data-modules='{"/saraban":"งานสารบรรณ","/asset":"งานพัสดุ"}'
|
|
11
|
+
* data-modules='{"/saraban":"งานสารบรรณ","/asset":"งานพัสดุ"}'
|
|
12
|
+
* data-replay="60"></script>
|
|
13
|
+
*
|
|
14
|
+
* `data-replay` records the last N seconds of the page for a report (see
|
|
15
|
+
* `replay` in config.ts). The recorder is `rrweb-record.mjs`, shipped beside
|
|
16
|
+
* this file and fetched only when asked for — keep the two together.
|
|
12
17
|
*/
|
|
13
18
|
import { configureDebugCapture } from "./config";
|
|
14
19
|
import { moduleResolver, type ModuleMap } from "./modules";
|
|
15
20
|
import { installDebugCapture } from "./install";
|
|
16
21
|
import { launch } from "./reporter";
|
|
22
|
+
import { setRecorderLoader } from "./capture/replay";
|
|
17
23
|
|
|
18
24
|
const script = document.currentScript as HTMLScriptElement | null;
|
|
19
25
|
const data = script?.dataset ?? {};
|
|
20
26
|
|
|
27
|
+
/*
|
|
28
|
+
* Beside this script, wherever the host put it. A bare `import("@rrweb/record")`
|
|
29
|
+
* means nothing to a browser without a bundler, and inlining the recorder
|
|
30
|
+
* here would nearly triple this file for every host, most of whom never turn
|
|
31
|
+
* the replay on.
|
|
32
|
+
*/
|
|
33
|
+
const RECORDER_FILE = "rrweb-record.mjs";
|
|
34
|
+
setRecorderLoader(() => import(/* @vite-ignore */ new URL(RECORDER_FILE, script?.src ?? location.href).href));
|
|
35
|
+
|
|
21
36
|
/**
|
|
22
37
|
* Bad JSON becomes an empty map, not an exception.
|
|
23
38
|
*
|
|
@@ -49,6 +64,7 @@ if (data.host && data.realmKey) {
|
|
|
49
64
|
// A declared map, because a data- attribute carries text and not a function.
|
|
50
65
|
module: moduleResolver(parseModules(data.modules)),
|
|
51
66
|
locale: data.locale === "en" ? "en" : "th",
|
|
67
|
+
...(data.replay !== undefined ? { replay: { seconds: Number(data.replay) || undefined } } : {}),
|
|
52
68
|
});
|
|
53
69
|
installDebugCapture();
|
|
54
70
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
export { configureDebugCapture, isConfigured, getConfig, appInfo } from "./config";
|
|
13
13
|
export { moduleResolver } from "./modules";
|
|
14
14
|
export type { ModuleMap } from "./modules";
|
|
15
|
-
export type { DebugCaptureConfig, CaptureOptions, RedactOptions, ToastNotice } from "./config";
|
|
15
|
+
export type { DebugCaptureConfig, CaptureOptions, RedactOptions, ReplayOptions, ToastNotice } from "./config";
|
|
16
16
|
|
|
17
17
|
export { installDebugCapture } from "./install";
|
|
18
18
|
export type { InstallOptions } from "./install";
|
|
@@ -29,6 +29,8 @@ export { captureScreen } from "./screenshot";
|
|
|
29
29
|
export { getNetworkLog } from "./capture/networkBuffer";
|
|
30
30
|
export { getConsoleLog } from "./capture/consoleBuffer";
|
|
31
31
|
export { getActionTrail, actionCountSuffix } from "./capture/actionTrail";
|
|
32
|
+
export { takeReplay, isReplayRecording, stopReplay } from "./capture/replay";
|
|
33
|
+
export { packReplay, REPLAY_FILENAME } from "./replayPack";
|
|
32
34
|
export { correlateActionTrail, isQuietStep, isFailedRequest, requestPath, formatDuration } from "./capture/stepCorrelation";
|
|
33
35
|
export type { StepActivity } from "./capture/stepCorrelation";
|
|
34
36
|
export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./capture/redact";
|
package/src/install.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { installActionTrail } from "./capture/actionTrail";
|
|
|
7
7
|
import { installConsoleCapture } from "./capture/consoleBuffer";
|
|
8
8
|
import { installCrashWatcher } from "./capture/crashWatcher";
|
|
9
9
|
import { installNetworkCapture } from "./capture/networkBuffer";
|
|
10
|
+
import { installReplay } from "./capture/replay";
|
|
10
11
|
import { getConfig, isConfigured } from "./config";
|
|
11
12
|
import { launch, onCrash } from "./reporter";
|
|
12
13
|
import { strings } from "./ui/strings";
|
|
@@ -34,6 +35,8 @@ export function installDebugCapture(options: InstallOptions = {}): void {
|
|
|
34
35
|
installConsoleCapture();
|
|
35
36
|
installActionTrail();
|
|
36
37
|
installCrashWatcher();
|
|
38
|
+
// Only when asked for, and only once the page has settled — see the module.
|
|
39
|
+
installReplay();
|
|
37
40
|
|
|
38
41
|
if (options.ui === false) return;
|
|
39
42
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A replay, made into the attachment that carries it.
|
|
3
|
+
*
|
|
4
|
+
* rrweb events compress about ten to one — a full snapshot is mostly
|
|
5
|
+
* repeated markup — so the browser gzips them before they travel, with the
|
|
6
|
+
* stream API every current browser has. Where it is missing the JSON goes as
|
|
7
|
+
* it is, if it fits; a replay is the one part of a report worth losing rather
|
|
8
|
+
* than failing the report over.
|
|
9
|
+
*
|
|
10
|
+
* Its own file, like the screenshot and for the same reason: `context` is a
|
|
11
|
+
* column on the ticket row, read by every query that returns the ticket, and
|
|
12
|
+
* the JSON bundle is opened by whoever reads the network log. A megabyte of
|
|
13
|
+
* DOM belongs in neither.
|
|
14
|
+
*/
|
|
15
|
+
import { toBase64 } from "./base64";
|
|
16
|
+
import { redactReplay } from "./capture/replayRedact";
|
|
17
|
+
import type { ReplayCapture, ReplaySummary } from "./types";
|
|
18
|
+
|
|
19
|
+
/** The name the desk looks for. Changing it here changes it nowhere else. */
|
|
20
|
+
export const REPLAY_FILENAME = "session-replay.json.gz";
|
|
21
|
+
const PLAIN_FILENAME = "session-replay.json";
|
|
22
|
+
|
|
23
|
+
export interface PackedReplay {
|
|
24
|
+
dataUri: string;
|
|
25
|
+
filename: string;
|
|
26
|
+
summary: ReplaySummary;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Is there something to attach, before the work of packing it. */
|
|
30
|
+
export function hasReplayEvents(replay: ReplayCapture | null | undefined): replay is ReplayCapture {
|
|
31
|
+
return Boolean(replay && replay.events.length > 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function gzip(bytes: Uint8Array): Promise<Uint8Array | null> {
|
|
35
|
+
if (typeof CompressionStream === "undefined") return null;
|
|
36
|
+
try {
|
|
37
|
+
const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
38
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pack, or say why not. Never throws: the report goes on without it.
|
|
46
|
+
*
|
|
47
|
+
* The cap is applied to what is SENT — compressed bytes when compression is
|
|
48
|
+
* available — because that is the number the server's per-file limit is
|
|
49
|
+
* measured against.
|
|
50
|
+
*/
|
|
51
|
+
export async function packReplay(replay: ReplayCapture | null | undefined, maxBytes: number): Promise<PackedReplay | { summary: ReplaySummary }> {
|
|
52
|
+
if (!hasReplayEvents(replay)) return { summary: { omitted: "empty" } };
|
|
53
|
+
const seconds = Math.round(replay.durationMs / 1000);
|
|
54
|
+
const base = { seconds, events: replay.events.length, startedAt: replay.startedAt, endedAt: replay.endedAt };
|
|
55
|
+
// The same URL policy as the network log — see capture/replayRedact.
|
|
56
|
+
const plain = new TextEncoder().encode(JSON.stringify({ events: redactReplay(replay.events) }));
|
|
57
|
+
const zipped = await gzip(plain);
|
|
58
|
+
const bytes = zipped ?? plain;
|
|
59
|
+
if (bytes.byteLength > maxBytes) return { summary: { ...base, bytes: bytes.byteLength, omitted: "too-large" } };
|
|
60
|
+
const compressed = zipped !== null;
|
|
61
|
+
return {
|
|
62
|
+
dataUri: `data:${compressed ? "application/gzip" : "application/json"};base64,${toBase64(bytes)}`,
|
|
63
|
+
filename: compressed ? REPLAY_FILENAME : PLAIN_FILENAME,
|
|
64
|
+
summary: { ...base, bytes: bytes.byteLength, compressed },
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -147,7 +147,7 @@
|
|
|
147
147
|
reasonable because the full size is one click away, and `zoom-in` says so
|
|
148
148
|
before you try. Matches the app's own thread. */
|
|
149
149
|
.lw-body img {
|
|
150
|
-
margin: 4px 0; max-height:
|
|
150
|
+
margin: 4px 0; max-height: 128px; max-width: 100%; width: auto; height: auto;
|
|
151
151
|
object-fit: contain; border: 1px solid var(--lw-border); border-radius: 8px;
|
|
152
152
|
cursor: zoom-in;
|
|
153
153
|
}
|
package/src/submit.ts
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* cookie, and nothing here that a reader of the bundle could not already see.
|
|
8
8
|
*/
|
|
9
9
|
import { compressScreenshot } from "./screenshot";
|
|
10
|
-
import { appInfo, getConfig } from "./config";
|
|
10
|
+
import { appInfo, getConfig, replayOptions } from "./config";
|
|
11
11
|
import { errorSignature } from "./signature";
|
|
12
12
|
import { trimToBudget, BUNDLE_BUDGET_BYTES } from "./budget";
|
|
13
|
-
import
|
|
13
|
+
import { toBase64 } from "./base64";
|
|
14
|
+
import { packReplay } from "./replayPack";
|
|
15
|
+
import type { DebugBundle, ReplaySummary } from "./types";
|
|
14
16
|
|
|
15
17
|
export interface FiledTicket {
|
|
16
18
|
id: string;
|
|
@@ -18,26 +20,7 @@ export interface FiledTicket {
|
|
|
18
20
|
status: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
* Base64 for a payload of any size.
|
|
23
|
-
*
|
|
24
|
-
* This was `btoa(String.fromCharCode(...bytes))`, which spreads every byte into
|
|
25
|
-
* the argument list — and an argument list has a hard limit. It worked on the
|
|
26
|
-
* tiny bundles a test produces and threw "Maximum call stack size exceeded" on
|
|
27
|
-
* a real one, at the last possible moment: the user has written the report,
|
|
28
|
-
* drawn on the screenshot, and pressed send.
|
|
29
|
-
*
|
|
30
|
-
* Chunked, so the argument count is bounded no matter how large the bundle is.
|
|
31
|
-
*/
|
|
32
|
-
const CHUNK = 0x8000;
|
|
33
|
-
|
|
34
|
-
export function toBase64(bytes: Uint8Array): string {
|
|
35
|
-
let binary = "";
|
|
36
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
37
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
38
|
-
}
|
|
39
|
-
return btoa(binary);
|
|
40
|
-
}
|
|
23
|
+
export { toBase64 } from "./base64";
|
|
41
24
|
|
|
42
25
|
/**
|
|
43
26
|
* A data URI for the JSON bundle, so the bulk lands in object storage.
|
|
@@ -51,7 +34,9 @@ export function toBase64(bytes: Uint8Array): string {
|
|
|
51
34
|
* (`budget.ts`), which is what made the duplication invisible.
|
|
52
35
|
*/
|
|
53
36
|
export function bundleAttachment(bundle: DebugBundle): { dataUri: string; filename: string } {
|
|
54
|
-
|
|
37
|
+
// The replay too: it is the largest thing in a report by an order of
|
|
38
|
+
// magnitude, and it has its own file (replayPack.ts).
|
|
39
|
+
const { screenshotDataUrl: _sentSeparately, replay: _alsoSeparately, ...rest } = bundle;
|
|
55
40
|
// btoa is latin1-only and a bundle is full of Thai. Encode to bytes first.
|
|
56
41
|
const base64 = toBase64(new TextEncoder().encode(JSON.stringify(rest)));
|
|
57
42
|
return { dataUri: `data:application/json;base64,${base64}`, filename: "debug-bundle.json" };
|
|
@@ -90,6 +75,23 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
90
75
|
}
|
|
91
76
|
attachments.push(bundleAttachment(trimmed));
|
|
92
77
|
|
|
78
|
+
/*
|
|
79
|
+
* The replay, when there is one, as the third file. `replay: null` on a
|
|
80
|
+
* bundle from a recording host is the reporter's choice — the dialog offers
|
|
81
|
+
* a box to leave it out — and the desk is told so, rather than left to
|
|
82
|
+
* wonder why this report has none.
|
|
83
|
+
*/
|
|
84
|
+
let replay: ReplaySummary | undefined;
|
|
85
|
+
const recording = replayOptions();
|
|
86
|
+
if (recording) {
|
|
87
|
+
if (trimmed.replay === null) replay = { omitted: "declined" };
|
|
88
|
+
else {
|
|
89
|
+
const packed = await packReplay(trimmed.replay, recording.maxBytes);
|
|
90
|
+
replay = packed.summary;
|
|
91
|
+
if ("dataUri" in packed) attachments.push({ dataUri: packed.dataUri, filename: packed.filename });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
93
95
|
/*
|
|
94
96
|
* `context` is a jsonb column on the ticket row, read back by every query that
|
|
95
97
|
* returns the ticket — so it gets the SUMMARY, not the bundle. The full thing
|
|
@@ -154,6 +156,7 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
154
156
|
actions: trimmed.actionTrail.length,
|
|
155
157
|
},
|
|
156
158
|
screenshotMethod: trimmed.screenshotMethod,
|
|
159
|
+
...(replay ? { replay } : {}),
|
|
157
160
|
},
|
|
158
161
|
attachments,
|
|
159
162
|
};
|
package/src/types.ts
CHANGED
|
@@ -154,6 +154,50 @@ export interface DebugContext {
|
|
|
154
154
|
|
|
155
155
|
export type ScreenshotMethod = "display-media" | "html-to-image" | "none" | "cancelled";
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* One rrweb event. Typed by shape rather than imported from `@rrweb/types`,
|
|
159
|
+
* so a host that reads a bundle needs nothing of rrweb's to do it — the
|
|
160
|
+
* recorder is loaded on demand and its types stay its own.
|
|
161
|
+
*/
|
|
162
|
+
export interface ReplayEvent {
|
|
163
|
+
type: number;
|
|
164
|
+
timestamp: number;
|
|
165
|
+
data: unknown;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The last stretch of the session, as the desk will play it back.
|
|
170
|
+
*
|
|
171
|
+
* Always begins with a full snapshot of the page: rrweb can only replay from
|
|
172
|
+
* one, so the window is cut at a checkpoint and never mid-stream. That is why
|
|
173
|
+
* `durationMs` is at least the configured length and can be up to half as
|
|
174
|
+
* much again.
|
|
175
|
+
*/
|
|
176
|
+
export interface ReplayCapture {
|
|
177
|
+
events: ReplayEvent[];
|
|
178
|
+
startedAt: string;
|
|
179
|
+
endedAt: string;
|
|
180
|
+
durationMs: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What the ticket says about its replay, without the replay: the summary that
|
|
185
|
+
* travels in `context` and lets the desk label the play button before the
|
|
186
|
+
* attachment loads — or say why there is none.
|
|
187
|
+
*/
|
|
188
|
+
export interface ReplaySummary {
|
|
189
|
+
/** Whole seconds covered, when one was attached. */
|
|
190
|
+
seconds?: number;
|
|
191
|
+
events?: number;
|
|
192
|
+
startedAt?: string;
|
|
193
|
+
endedAt?: string;
|
|
194
|
+
/** Size of the attachment as sent, and whether it is gzip. */
|
|
195
|
+
bytes?: number;
|
|
196
|
+
compressed?: boolean;
|
|
197
|
+
/** Present when the host records but this report carries no replay. */
|
|
198
|
+
omitted?: "declined" | "too-large" | "empty";
|
|
199
|
+
}
|
|
200
|
+
|
|
157
201
|
export interface DebugBundle {
|
|
158
202
|
context: DebugContext;
|
|
159
203
|
reason: DebugReason;
|
|
@@ -165,5 +209,13 @@ export interface DebugBundle {
|
|
|
165
209
|
/** PNG data URL, or null if capture failed / was skipped. */
|
|
166
210
|
screenshotDataUrl: string | null;
|
|
167
211
|
screenshotMethod: ScreenshotMethod;
|
|
212
|
+
/**
|
|
213
|
+
* The session replay, when the host records one. Its own attachment on the
|
|
214
|
+
* way out, like the screenshot — never inside the JSON bundle.
|
|
215
|
+
*
|
|
216
|
+
* Absent: nothing was recorded. `null`: there was one and the reporter
|
|
217
|
+
* chose to leave it out — the desk is told which.
|
|
218
|
+
*/
|
|
219
|
+
replay?: ReplayCapture | null;
|
|
168
220
|
meta: { generatedBy: string; schemaVersion: number };
|
|
169
221
|
}
|
package/src/ui/element.ts
CHANGED
|
@@ -197,6 +197,15 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
197
197
|
meta.append(row);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/*
|
|
201
|
+
* The replay is offered, not imposed. A recording of the screen is the
|
|
202
|
+
* most personal thing in a report, and the person filing it decides
|
|
203
|
+
* whether it goes — ticked by default because it is why the host turned
|
|
204
|
+
* it on, with one line saying what it is and is not.
|
|
205
|
+
*/
|
|
206
|
+
const replayRow = bundle.replay ? this.replayConsent(t, bundle.replay.durationMs) : null;
|
|
207
|
+
if (replayRow) meta.append(replayRow.row);
|
|
208
|
+
|
|
200
209
|
const status = el("p", "status", "");
|
|
201
210
|
const submitBtn = button(t.submit, "primary");
|
|
202
211
|
submitBtn.disabled = true;
|
|
@@ -222,6 +231,8 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
222
231
|
const filed = await submitBundle(summary.value.trim(), {
|
|
223
232
|
...bundle,
|
|
224
233
|
note: detail.value,
|
|
234
|
+
// Null, not absent: "left out on purpose" is worth telling the desk.
|
|
235
|
+
...(replayRow && !replayRow.box.checked ? { replay: null } : {}),
|
|
225
236
|
// Take the annotated image, not the raw capture — the drawing IS the
|
|
226
237
|
// report for most people.
|
|
227
238
|
screenshotDataUrl: this.annotator?.hasShapes()
|
|
@@ -257,6 +268,19 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
257
268
|
summary.focus();
|
|
258
269
|
}
|
|
259
270
|
|
|
271
|
+
private replayConsent(t: Strings, durationMs: number): { row: HTMLElement; box: HTMLInputElement } {
|
|
272
|
+
const row = el("label", "check");
|
|
273
|
+
const box = document.createElement("input");
|
|
274
|
+
box.type = "checkbox";
|
|
275
|
+
box.checked = true;
|
|
276
|
+
const text = el("span", "", t.replayAttach.replace("{seconds}", String(Math.round(durationMs / 1000))));
|
|
277
|
+
const what = el("small", "hint", t.replayWhat);
|
|
278
|
+
const words = el("span", "grow");
|
|
279
|
+
words.append(text, what);
|
|
280
|
+
row.append(box, words);
|
|
281
|
+
return { row, box };
|
|
282
|
+
}
|
|
283
|
+
|
|
260
284
|
private screenshotSection(t: Strings, dataUrl: string): HTMLElement[] {
|
|
261
285
|
const label = labelFor(t.annotateHint);
|
|
262
286
|
const tools = el("div", "tools");
|