@lightworkai.official/debug-capture 0.11.2 → 0.12.1
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 +61 -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 +3 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.mjs +14 -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 +47 -0
- package/dist/ui/strings.d.ts +2 -0
- package/package.json +10 -5
- package/src/base64.ts +20 -0
- package/src/budget.ts +3 -3
- package/src/bundle.ts +6 -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 +23 -23
- package/src/types.ts +50 -0
- package/src/ui/element.ts +9 -1
- package/src/ui/strings.ts +4 -0
|
@@ -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,20 @@ 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. Taken the way the
|
|
80
|
+
* network and console logs are — it is the support team's debug log, not
|
|
81
|
+
* a question put to the reporter — and when a recording host has none to
|
|
82
|
+
* send, the desk is told why rather than left to wonder.
|
|
83
|
+
*/
|
|
84
|
+
let replay: ReplaySummary | undefined;
|
|
85
|
+
const recording = replayOptions();
|
|
86
|
+
if (recording) {
|
|
87
|
+
const packed = await packReplay(trimmed.replay, recording.maxBytes);
|
|
88
|
+
replay = packed.summary;
|
|
89
|
+
if ("dataUri" in packed) attachments.push({ dataUri: packed.dataUri, filename: packed.filename });
|
|
90
|
+
}
|
|
91
|
+
|
|
93
92
|
/*
|
|
94
93
|
* `context` is a jsonb column on the ticket row, read back by every query that
|
|
95
94
|
* returns the ticket — so it gets the SUMMARY, not the bundle. The full thing
|
|
@@ -154,6 +153,7 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
154
153
|
actions: trimmed.actionTrail.length,
|
|
155
154
|
},
|
|
156
155
|
screenshotMethod: trimmed.screenshotMethod,
|
|
156
|
+
...(replay ? { replay } : {}),
|
|
157
157
|
},
|
|
158
158
|
attachments,
|
|
159
159
|
};
|
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?: "too-large" | "empty";
|
|
199
|
+
}
|
|
200
|
+
|
|
157
201
|
export interface DebugBundle {
|
|
158
202
|
context: DebugContext;
|
|
159
203
|
reason: DebugReason;
|
|
@@ -165,5 +209,11 @@ 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. Absent (or
|
|
215
|
+
* null) when nothing was recorded.
|
|
216
|
+
*/
|
|
217
|
+
replay?: ReplayCapture | null;
|
|
168
218
|
meta: { generatedBy: string; schemaVersion: number };
|
|
169
219
|
}
|
package/src/ui/element.ts
CHANGED
|
@@ -183,9 +183,17 @@ function createReporterClass(): CustomElementConstructor {
|
|
|
183
183
|
// environment became optional, and it was telling the person filing the
|
|
184
184
|
// report something only the person reading it needs — both still travel
|
|
185
185
|
// with the bundle.
|
|
186
|
+
//
|
|
187
|
+
// The replay counts as one more thing captured, listed with the rest.
|
|
188
|
+
// It is the support team's debug log, taken the way the network and
|
|
189
|
+
// console logs are — not a choice put to the reporter. There was a
|
|
190
|
+
// tick box here once; the platform owner's call was that a report
|
|
191
|
+
// should always carry what makes it reproducible.
|
|
186
192
|
[
|
|
187
193
|
t.captured,
|
|
188
|
-
`${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions
|
|
194
|
+
`${bundle.network.length} network · ${bundle.console.length} console · ${bundle.actionTrail.length} actions${
|
|
195
|
+
bundle.replay ? ` · ${t.replay} ${Math.round(bundle.replay.durationMs / 1000)}s` : ""
|
|
196
|
+
}`,
|
|
189
197
|
],
|
|
190
198
|
] as [string, string][]) {
|
|
191
199
|
const row = document.createElement("div");
|
package/src/ui/strings.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface Strings {
|
|
|
38
38
|
captured: string;
|
|
39
39
|
reporter: string;
|
|
40
40
|
page: string;
|
|
41
|
+
/** In the captured row: "… · replay 14s". */
|
|
42
|
+
replay: string;
|
|
41
43
|
/** The hand-off dialog, shown before leaving for the support portal. */
|
|
42
44
|
handoffTitle: string;
|
|
43
45
|
handoffBody: string;
|
|
@@ -80,6 +82,7 @@ const th: Strings = {
|
|
|
80
82
|
captured: "เก็บข้อมูลแล้ว",
|
|
81
83
|
reporter: "ผู้แจ้ง",
|
|
82
84
|
page: "หน้า",
|
|
85
|
+
replay: "บันทึกหน้าจอ",
|
|
83
86
|
handoffTitle: "กำลังพาไปที่ระบบแจ้งปัญหา",
|
|
84
87
|
handoffBody: "ระบบจะลงชื่อเข้าใช้ให้อัตโนมัติ เพื่อดูเรื่องที่คุณแจ้งไว้",
|
|
85
88
|
handoffGo: "ไปเลย",
|
|
@@ -121,6 +124,7 @@ const en: Strings = {
|
|
|
121
124
|
captured: "Captured",
|
|
122
125
|
reporter: "Reporter",
|
|
123
126
|
page: "Page",
|
|
127
|
+
replay: "replay",
|
|
124
128
|
handoffTitle: "Opening the support portal",
|
|
125
129
|
handoffBody: "You will be signed in automatically to see what you reported",
|
|
126
130
|
handoffGo: "Go now",
|