@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.
@@ -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":"งานพัสดุ"}'></script>
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: 160px; max-width: 100%; width: auto; height: auto;
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 type { DebugBundle } from "./types";
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
- const { screenshotDataUrl: _sentSeparately, ...rest } = bundle;
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");