@paikko/widget 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/package.json +41 -0
- package/src/PaikkoNav.tsx +92 -0
- package/src/PaikkoProvider.tsx +79 -0
- package/src/ReportButton.tsx +591 -0
- package/src/build/provenancePlugin.cjs +200 -0
- package/src/capture.ts +991 -0
- package/src/index.ts +33 -0
package/src/capture.ts
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paikko client capture machinery.
|
|
3
|
+
*
|
|
4
|
+
* Everything the agent needs to reproduce a bug, captured at the moment the user
|
|
5
|
+
* hits report. The guiding rule is "photograph, not live window": the buffers
|
|
6
|
+
* (console, network) accumulate continuously while the app runs, but the storage
|
|
7
|
+
* / client-state / DOM snapshots are taken on demand at report time, and the
|
|
8
|
+
* whole thing is frozen into immutable {@link ArtifactPayload}s by
|
|
9
|
+
* {@link snapshotArtifacts}.
|
|
10
|
+
*
|
|
11
|
+
* The frontend half of the trace spine lives here: every patched fetch/XHR mints
|
|
12
|
+
* a {@link TraceId}, records it on its {@link NetworkEntry}, and propagates it as
|
|
13
|
+
* the `x-paikko-trace` request header so the backend `withCapture()` wrapper can
|
|
14
|
+
* stitch its {@link TraceRequest} back to this exact call.
|
|
15
|
+
*
|
|
16
|
+
* Shape note: every record produced here is built to the contract types and
|
|
17
|
+
* validated through {@link ArtifactPayloadSchemas} before it leaves the client,
|
|
18
|
+
* so the API only ever sees contract-valid payloads.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
type ConsoleArtifact,
|
|
22
|
+
type ConsoleEntry,
|
|
23
|
+
type NetworkArtifact,
|
|
24
|
+
type NetworkEntry,
|
|
25
|
+
type ClientStateArtifact,
|
|
26
|
+
type StorageArtifact,
|
|
27
|
+
type DomArtifact,
|
|
28
|
+
type ScreenshotArtifact,
|
|
29
|
+
type ReportTarget,
|
|
30
|
+
type TraceId,
|
|
31
|
+
ArtifactPayloadSchemas,
|
|
32
|
+
} from "@paikko/contract";
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
/* Config */
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
|
|
38
|
+
/** Header that carries the frontend trace id to the backend capture wrapper. */
|
|
39
|
+
export const TRACE_HEADER = "x-paikko-trace";
|
|
40
|
+
|
|
41
|
+
/** Header that carries the stable capture session id to the backend. */
|
|
42
|
+
export const SESSION_HEADER = "x-paikko-session";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Header carrying the project's PUBLISHABLE api key (pk_...) on the report POST.
|
|
46
|
+
* Public by nature (it ships in the browser); the backend only honours it when
|
|
47
|
+
* auth is enforced (the default; off only via PAIKKO_AUTH=disabled) and relies on the CORS origin allowlist
|
|
48
|
+
* as the real defense. Never put a SECRET key (sk_...) here.
|
|
49
|
+
*/
|
|
50
|
+
export const PUBLISHABLE_KEY_HEADER = "x-paikko-key";
|
|
51
|
+
|
|
52
|
+
/** sessionStorage key under which the stable session id is persisted. */
|
|
53
|
+
const SESSION_KEY = "paikko.sessionId";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mint (once) and return the stable capture session id for this browsing session.
|
|
57
|
+
* Persisted in sessionStorage so every request and the final report bundle share
|
|
58
|
+
* the same id - that is what lets the server drain exactly this session's buffered
|
|
59
|
+
* backend requests into the `trace` artifact. Falls back to an in-memory id when
|
|
60
|
+
* sessionStorage is unavailable (SSR, sandboxed contexts).
|
|
61
|
+
*/
|
|
62
|
+
let memorySessionId: string | null = null;
|
|
63
|
+
export function getSessionId(): string {
|
|
64
|
+
if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
|
|
65
|
+
if (!memorySessionId) memorySessionId = genTraceId();
|
|
66
|
+
return memorySessionId;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
let id = sessionStorage.getItem(SESSION_KEY);
|
|
70
|
+
if (!id) {
|
|
71
|
+
id = genTraceId();
|
|
72
|
+
sessionStorage.setItem(SESSION_KEY, id);
|
|
73
|
+
}
|
|
74
|
+
return id;
|
|
75
|
+
} catch {
|
|
76
|
+
if (!memorySessionId) memorySessionId = genTraceId();
|
|
77
|
+
return memorySessionId;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Provenance attribute injected at build time (see PROVENANCE.md). */
|
|
82
|
+
const SRC_ATTR = "data-src";
|
|
83
|
+
const COMPONENT_ATTR = "data-paikko-component";
|
|
84
|
+
|
|
85
|
+
export interface CaptureConfig {
|
|
86
|
+
/** Console ring buffer capacity (lines kept, oldest evicted). */
|
|
87
|
+
consoleBufferSize: number;
|
|
88
|
+
/** Network ring buffer capacity (calls kept, oldest evicted). */
|
|
89
|
+
networkBufferSize: number;
|
|
90
|
+
/**
|
|
91
|
+
* Reader for the mandated client-state store. The store is owned by another
|
|
92
|
+
* seam, so capture depends on a getter rather than importing it directly.
|
|
93
|
+
* Returns the snapshot object, or `{}` if no store is wired.
|
|
94
|
+
*/
|
|
95
|
+
getClientState: () => Record<string, unknown>;
|
|
96
|
+
/** Max serialized length of a captured request/response body, in chars. */
|
|
97
|
+
maxBodyChars: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DEFAULT_CONFIG: CaptureConfig = {
|
|
101
|
+
consoleBufferSize: 200,
|
|
102
|
+
networkBufferSize: 100,
|
|
103
|
+
getClientState: () => ({}),
|
|
104
|
+
maxBodyChars: 16_384,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
/* Helpers */
|
|
109
|
+
/* ------------------------------------------------------------------ */
|
|
110
|
+
|
|
111
|
+
function now(): string {
|
|
112
|
+
return new Date().toISOString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True when `url` resolves to the same origin as the page. Relative URLs are
|
|
117
|
+
* same-origin by definition; absolute URLs are compared by origin. Used to gate
|
|
118
|
+
* header injection: the paikko trace/session headers are custom request headers,
|
|
119
|
+
* so adding them to a CROSS-origin request forces a CORS preflight that the
|
|
120
|
+
* third party's `Access-Control-Allow-Headers` will reject - breaking the
|
|
121
|
+
* consumer's cross-origin script/wasm/media loads. We therefore only inject on
|
|
122
|
+
* same-origin calls (the consumer's own backend, which `withCapture()` wraps).
|
|
123
|
+
* The cross-origin report POST sets the session header explicitly itself.
|
|
124
|
+
*/
|
|
125
|
+
function isSameOrigin(url: string): boolean {
|
|
126
|
+
if (typeof window === "undefined" || !window.location) return true;
|
|
127
|
+
try {
|
|
128
|
+
return new URL(url, window.location.href).origin === window.location.origin;
|
|
129
|
+
} catch {
|
|
130
|
+
// Unparseable - treat as same-origin (relative-ish); never block the request.
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function genTraceId(): TraceId {
|
|
136
|
+
// crypto.randomUUID is available in all modern browsers; fall back just in case.
|
|
137
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
138
|
+
return crypto.randomUUID();
|
|
139
|
+
}
|
|
140
|
+
return `tr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Best-effort serialize an arbitrary console arg / body to a JSON-able value. */
|
|
144
|
+
function safeSerialize(value: unknown, maxChars: number): unknown {
|
|
145
|
+
if (value == null) return value;
|
|
146
|
+
if (typeof value === "string") {
|
|
147
|
+
return value.length > maxChars ? value.slice(0, maxChars) + "…[truncated]" : value;
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
typeof value === "number" ||
|
|
151
|
+
typeof value === "boolean"
|
|
152
|
+
) {
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const seen = new WeakSet<object>();
|
|
157
|
+
const json = JSON.stringify(value, (_k, v) => {
|
|
158
|
+
if (typeof v === "bigint") return v.toString();
|
|
159
|
+
if (typeof v === "function") return `[Function ${v.name || "anonymous"}]`;
|
|
160
|
+
if (v instanceof Error) {
|
|
161
|
+
return { name: v.name, message: v.message, stack: v.stack };
|
|
162
|
+
}
|
|
163
|
+
if (typeof v === "object" && v !== null) {
|
|
164
|
+
if (seen.has(v)) return "[Circular]";
|
|
165
|
+
seen.add(v);
|
|
166
|
+
}
|
|
167
|
+
return v;
|
|
168
|
+
});
|
|
169
|
+
if (json === undefined) return String(value);
|
|
170
|
+
const parsed = JSON.parse(json);
|
|
171
|
+
// Bound size after the fact so we don't ship megabytes.
|
|
172
|
+
if (json.length > maxChars) {
|
|
173
|
+
return { __truncated: true, preview: json.slice(0, maxChars) };
|
|
174
|
+
}
|
|
175
|
+
return parsed;
|
|
176
|
+
} catch {
|
|
177
|
+
try {
|
|
178
|
+
return String(value);
|
|
179
|
+
} catch {
|
|
180
|
+
return "[unserializable]";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Format a console.* call's args into a single human-readable message line. */
|
|
186
|
+
function formatConsoleMessage(args: unknown[]): string {
|
|
187
|
+
return args
|
|
188
|
+
.map((a) => {
|
|
189
|
+
if (typeof a === "string") return a;
|
|
190
|
+
if (a instanceof Error) return `${a.name}: ${a.message}`;
|
|
191
|
+
try {
|
|
192
|
+
return JSON.stringify(a);
|
|
193
|
+
} catch {
|
|
194
|
+
return String(a);
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
.join(" ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ------------------------------------------------------------------ */
|
|
201
|
+
/* Capture core */
|
|
202
|
+
/* ------------------------------------------------------------------ */
|
|
203
|
+
|
|
204
|
+
type ConsoleMethod = "log" | "info" | "warn" | "error" | "debug";
|
|
205
|
+
const CONSOLE_METHODS: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The live capture controller. Construct once, call {@link install} on mount to
|
|
209
|
+
* patch console/fetch/XHR, and call {@link snapshotArtifacts} at report time to
|
|
210
|
+
* freeze the immutable payloads. {@link uninstall} restores the originals.
|
|
211
|
+
*/
|
|
212
|
+
export class Capture {
|
|
213
|
+
private readonly config: CaptureConfig;
|
|
214
|
+
|
|
215
|
+
private consoleBuffer: ConsoleEntry[] = [];
|
|
216
|
+
private networkBuffer: NetworkEntry[] = [];
|
|
217
|
+
|
|
218
|
+
private installed = false;
|
|
219
|
+
private originalConsole: Partial<Record<ConsoleMethod, (...a: unknown[]) => void>> = {};
|
|
220
|
+
private originalFetch: typeof fetch | null = null;
|
|
221
|
+
private originalXhrOpen: typeof XMLHttpRequest.prototype.open | null = null;
|
|
222
|
+
private originalXhrSend: typeof XMLHttpRequest.prototype.send | null = null;
|
|
223
|
+
private originalXhrSetHeader: typeof XMLHttpRequest.prototype.setRequestHeader | null = null;
|
|
224
|
+
|
|
225
|
+
constructor(config: Partial<CaptureConfig> = {}) {
|
|
226
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ---- lifecycle ---- */
|
|
230
|
+
|
|
231
|
+
install(): void {
|
|
232
|
+
if (this.installed || typeof window === "undefined") return;
|
|
233
|
+
this.installed = true;
|
|
234
|
+
this.patchConsole();
|
|
235
|
+
this.patchFetch();
|
|
236
|
+
this.patchXhr();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
uninstall(): void {
|
|
240
|
+
if (!this.installed) return;
|
|
241
|
+
this.installed = false;
|
|
242
|
+
for (const m of CONSOLE_METHODS) {
|
|
243
|
+
const orig = this.originalConsole[m];
|
|
244
|
+
if (orig) (console as unknown as Record<string, unknown>)[m] = orig;
|
|
245
|
+
}
|
|
246
|
+
if (this.originalFetch) window.fetch = this.originalFetch;
|
|
247
|
+
if (this.originalXhrOpen) XMLHttpRequest.prototype.open = this.originalXhrOpen;
|
|
248
|
+
if (this.originalXhrSend) XMLHttpRequest.prototype.send = this.originalXhrSend;
|
|
249
|
+
if (this.originalXhrSetHeader) {
|
|
250
|
+
XMLHttpRequest.prototype.setRequestHeader = this.originalXhrSetHeader;
|
|
251
|
+
}
|
|
252
|
+
this.originalConsole = {};
|
|
253
|
+
this.originalFetch = null;
|
|
254
|
+
this.originalXhrOpen = null;
|
|
255
|
+
this.originalXhrSend = null;
|
|
256
|
+
this.originalXhrSetHeader = null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ---- console ring buffer ---- */
|
|
260
|
+
|
|
261
|
+
private patchConsole(): void {
|
|
262
|
+
for (const method of CONSOLE_METHODS) {
|
|
263
|
+
const original = console[method] as ((...a: unknown[]) => void) | undefined;
|
|
264
|
+
if (!original) continue;
|
|
265
|
+
this.originalConsole[method] = original.bind(console);
|
|
266
|
+
(console as unknown as Record<string, unknown>)[method] = (...args: unknown[]) => {
|
|
267
|
+
try {
|
|
268
|
+
this.pushConsole(method, args);
|
|
269
|
+
} catch {
|
|
270
|
+
/* never let capture break the app's logging */
|
|
271
|
+
}
|
|
272
|
+
this.originalConsole[method]?.(...args);
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private pushConsole(level: ConsoleMethod, args: unknown[]): void {
|
|
278
|
+
const entry: ConsoleEntry = {
|
|
279
|
+
level,
|
|
280
|
+
message: redactText(formatConsoleMessage(args)),
|
|
281
|
+
args: args.map((a) => safeSerialize(redactConsoleArg(a), this.config.maxBodyChars)),
|
|
282
|
+
at: now(),
|
|
283
|
+
};
|
|
284
|
+
this.consoleBuffer.push(entry);
|
|
285
|
+
if (this.consoleBuffer.length > this.config.consoleBufferSize) {
|
|
286
|
+
this.consoleBuffer.splice(
|
|
287
|
+
0,
|
|
288
|
+
this.consoleBuffer.length - this.config.consoleBufferSize,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ---- network: fetch ---- */
|
|
294
|
+
|
|
295
|
+
private patchFetch(): void {
|
|
296
|
+
if (typeof window.fetch !== "function") return;
|
|
297
|
+
this.originalFetch = window.fetch.bind(window);
|
|
298
|
+
const original = this.originalFetch;
|
|
299
|
+
const self = this;
|
|
300
|
+
|
|
301
|
+
window.fetch = async function patchedFetch(
|
|
302
|
+
input: RequestInfo | URL,
|
|
303
|
+
init?: RequestInit,
|
|
304
|
+
): Promise<Response> {
|
|
305
|
+
const traceId = genTraceId();
|
|
306
|
+
const method = (
|
|
307
|
+
init?.method ??
|
|
308
|
+
(input instanceof Request ? input.method : undefined) ??
|
|
309
|
+
"GET"
|
|
310
|
+
).toUpperCase();
|
|
311
|
+
const url =
|
|
312
|
+
typeof input === "string"
|
|
313
|
+
? input
|
|
314
|
+
: input instanceof URL
|
|
315
|
+
? input.toString()
|
|
316
|
+
: input.url;
|
|
317
|
+
|
|
318
|
+
// Inject the trace/session headers ONLY on same-origin calls. Adding
|
|
319
|
+
// custom headers to a cross-origin fetch forces a CORS preflight the third
|
|
320
|
+
// party will reject, breaking the consumer's cross-origin loads; such a
|
|
321
|
+
// request is still recorded below, just with its headers untouched.
|
|
322
|
+
let callInit = init;
|
|
323
|
+
if (isSameOrigin(url)) {
|
|
324
|
+
const headers = new Headers(
|
|
325
|
+
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
326
|
+
);
|
|
327
|
+
headers.set(TRACE_HEADER, traceId);
|
|
328
|
+
headers.set(SESSION_HEADER, getSessionId());
|
|
329
|
+
callInit = { ...init, headers };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const reqBody = await self.readRequestBody(input, init);
|
|
333
|
+
const startedAt = now();
|
|
334
|
+
const startMs = performance.now();
|
|
335
|
+
|
|
336
|
+
const entry: NetworkEntry = {
|
|
337
|
+
traceId,
|
|
338
|
+
method,
|
|
339
|
+
url: redactUrl(url),
|
|
340
|
+
status: null,
|
|
341
|
+
reqBody,
|
|
342
|
+
resBody: null,
|
|
343
|
+
startedAt,
|
|
344
|
+
durationMs: null,
|
|
345
|
+
};
|
|
346
|
+
self.pushNetwork(entry);
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const res = await original(input, callInit);
|
|
350
|
+
entry.status = res.status;
|
|
351
|
+
entry.durationMs = Math.round(performance.now() - startMs);
|
|
352
|
+
// Clone so the app still consumes the body.
|
|
353
|
+
entry.resBody = await self.readResponseBody(res.clone());
|
|
354
|
+
return res;
|
|
355
|
+
} catch (err) {
|
|
356
|
+
entry.durationMs = Math.round(performance.now() - startMs);
|
|
357
|
+
entry.resBody = safeSerialize(err, self.config.maxBodyChars);
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async readRequestBody(
|
|
364
|
+
input: RequestInfo | URL,
|
|
365
|
+
init?: RequestInit,
|
|
366
|
+
): Promise<unknown> {
|
|
367
|
+
try {
|
|
368
|
+
const body =
|
|
369
|
+
init?.body ?? (input instanceof Request ? await input.clone().text() : undefined);
|
|
370
|
+
if (body == null) return null;
|
|
371
|
+
if (typeof body === "string") {
|
|
372
|
+
try {
|
|
373
|
+
return safeSerialize(redactDeep(JSON.parse(body)), this.config.maxBodyChars);
|
|
374
|
+
} catch {
|
|
375
|
+
return safeSerialize(redactBodyString(body), this.config.maxBodyChars);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// FormData / Blob / etc. - record a marker rather than the raw object.
|
|
379
|
+
return `[${(body as object).constructor?.name ?? typeof body}]`;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async readResponseBody(res: Response): Promise<unknown> {
|
|
386
|
+
try {
|
|
387
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
388
|
+
const text = await res.text();
|
|
389
|
+
if (!text) return null;
|
|
390
|
+
if (ct.includes("application/json")) {
|
|
391
|
+
try {
|
|
392
|
+
return safeSerialize(redactDeep(JSON.parse(text)), this.config.maxBodyChars);
|
|
393
|
+
} catch {
|
|
394
|
+
return safeSerialize(redactBodyString(text), this.config.maxBodyChars);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return safeSerialize(redactBodyString(text), this.config.maxBodyChars);
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* ---- network: XHR ---- */
|
|
404
|
+
|
|
405
|
+
private patchXhr(): void {
|
|
406
|
+
if (typeof XMLHttpRequest === "undefined") return;
|
|
407
|
+
const self = this;
|
|
408
|
+
const proto = XMLHttpRequest.prototype;
|
|
409
|
+
|
|
410
|
+
this.originalXhrOpen = proto.open;
|
|
411
|
+
this.originalXhrSend = proto.send;
|
|
412
|
+
this.originalXhrSetHeader = proto.setRequestHeader;
|
|
413
|
+
|
|
414
|
+
const META = Symbol.for("paikko.xhr.meta");
|
|
415
|
+
type Meta = {
|
|
416
|
+
traceId: TraceId;
|
|
417
|
+
method: string;
|
|
418
|
+
url: string;
|
|
419
|
+
entry?: NetworkEntry;
|
|
420
|
+
startMs?: number;
|
|
421
|
+
headerInjected?: boolean;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const openOrig = this.originalXhrOpen;
|
|
425
|
+
proto.open = function open(
|
|
426
|
+
this: XMLHttpRequest,
|
|
427
|
+
method: string,
|
|
428
|
+
url: string | URL,
|
|
429
|
+
...rest: unknown[]
|
|
430
|
+
) {
|
|
431
|
+
const meta: Meta = {
|
|
432
|
+
traceId: genTraceId(),
|
|
433
|
+
method: method.toUpperCase(),
|
|
434
|
+
url: url.toString(),
|
|
435
|
+
};
|
|
436
|
+
(this as unknown as Record<symbol, Meta>)[META] = meta;
|
|
437
|
+
// @ts-expect-error - forward through to the native signature
|
|
438
|
+
return openOrig.call(this, method, url, ...rest);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const setHeaderOrig = this.originalXhrSetHeader;
|
|
442
|
+
proto.setRequestHeader = function setRequestHeader(
|
|
443
|
+
this: XMLHttpRequest,
|
|
444
|
+
name: string,
|
|
445
|
+
value: string,
|
|
446
|
+
) {
|
|
447
|
+
return setHeaderOrig.call(this, name, value);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const sendOrig = this.originalXhrSend;
|
|
451
|
+
proto.send = function send(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
|
452
|
+
const meta = (this as unknown as Record<symbol, Meta>)[META];
|
|
453
|
+
if (meta) {
|
|
454
|
+
try {
|
|
455
|
+
// Same-origin only - see isSameOrigin: custom headers on a cross-origin
|
|
456
|
+
// XHR trip a CORS preflight the third party rejects.
|
|
457
|
+
if (!meta.headerInjected && isSameOrigin(meta.url)) {
|
|
458
|
+
setHeaderOrig.call(this, TRACE_HEADER, meta.traceId);
|
|
459
|
+
setHeaderOrig.call(this, SESSION_HEADER, getSessionId());
|
|
460
|
+
meta.headerInjected = true;
|
|
461
|
+
}
|
|
462
|
+
} catch {
|
|
463
|
+
/* setRequestHeader can throw if state is wrong; ignore */
|
|
464
|
+
}
|
|
465
|
+
const entry: NetworkEntry = {
|
|
466
|
+
traceId: meta.traceId,
|
|
467
|
+
method: meta.method,
|
|
468
|
+
url: redactUrl(meta.url),
|
|
469
|
+
status: null,
|
|
470
|
+
reqBody: self.serializeXhrBody(body),
|
|
471
|
+
resBody: null,
|
|
472
|
+
startedAt: now(),
|
|
473
|
+
durationMs: null,
|
|
474
|
+
};
|
|
475
|
+
meta.entry = entry;
|
|
476
|
+
meta.startMs = performance.now();
|
|
477
|
+
self.pushNetwork(entry);
|
|
478
|
+
|
|
479
|
+
this.addEventListener("loadend", () => {
|
|
480
|
+
if (!meta.entry) return;
|
|
481
|
+
meta.entry.status = this.status || null;
|
|
482
|
+
meta.entry.durationMs =
|
|
483
|
+
meta.startMs != null ? Math.round(performance.now() - meta.startMs) : null;
|
|
484
|
+
meta.entry.resBody = self.readXhrResponse(this);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return sendOrig.call(this, body ?? null);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private serializeXhrBody(body?: Document | XMLHttpRequestBodyInit | null): unknown {
|
|
492
|
+
if (body == null) return null;
|
|
493
|
+
if (typeof body === "string") {
|
|
494
|
+
try {
|
|
495
|
+
return safeSerialize(redactDeep(JSON.parse(body)), this.config.maxBodyChars);
|
|
496
|
+
} catch {
|
|
497
|
+
return safeSerialize(redactBodyString(body), this.config.maxBodyChars);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return `[${(body as object).constructor?.name ?? typeof body}]`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private readXhrResponse(xhr: XMLHttpRequest): unknown {
|
|
504
|
+
try {
|
|
505
|
+
const type = xhr.responseType;
|
|
506
|
+
if (type === "" || type === "text") {
|
|
507
|
+
const text = xhr.responseText;
|
|
508
|
+
if (!text) return null;
|
|
509
|
+
try {
|
|
510
|
+
return safeSerialize(redactDeep(JSON.parse(text)), this.config.maxBodyChars);
|
|
511
|
+
} catch {
|
|
512
|
+
return safeSerialize(redactBodyString(text), this.config.maxBodyChars);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (type === "json") return safeSerialize(redactDeep(xhr.response), this.config.maxBodyChars);
|
|
516
|
+
return `[responseType:${type}]`;
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private pushNetwork(entry: NetworkEntry): void {
|
|
523
|
+
this.networkBuffer.push(entry);
|
|
524
|
+
if (this.networkBuffer.length > this.config.networkBufferSize) {
|
|
525
|
+
this.networkBuffer.splice(
|
|
526
|
+
0,
|
|
527
|
+
this.networkBuffer.length - this.config.networkBufferSize,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/* ---- on-demand snapshots ---- */
|
|
533
|
+
|
|
534
|
+
/** Console ring buffer, oldest first. Deep-cloned so the snapshot is frozen. */
|
|
535
|
+
snapshotConsole(): ConsoleArtifact {
|
|
536
|
+
return this.consoleBuffer.map((e) => ({ ...e, args: e.args ? [...e.args] : undefined }));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Network log, oldest first. Deep-cloned so later mutation can't leak in. */
|
|
540
|
+
snapshotNetwork(): NetworkArtifact {
|
|
541
|
+
return this.networkBuffer.map((e) => ({ ...e }));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Read the mandated client-state store via the injected getter. */
|
|
545
|
+
snapshotClientState(): ClientStateArtifact {
|
|
546
|
+
try {
|
|
547
|
+
const state = this.config.getClientState();
|
|
548
|
+
// Redact: app stores (Redux/Zustand) routinely hold access tokens / session.
|
|
549
|
+
return (
|
|
550
|
+
(safeSerialize(redactDeep(state), this.config.maxBodyChars) as ClientStateArtifact) ?? {}
|
|
551
|
+
);
|
|
552
|
+
} catch {
|
|
553
|
+
return {};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Returns true if there is anything in the network buffer. */
|
|
558
|
+
hasNetwork(): boolean {
|
|
559
|
+
return this.networkBuffer.length > 0;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/* ------------------------------------------------------------------ */
|
|
564
|
+
/* Stateless snapshots (no patching required) */
|
|
565
|
+
/* ------------------------------------------------------------------ */
|
|
566
|
+
|
|
567
|
+
/*
|
|
568
|
+
* Redaction. Capture ships storage, cookies, client state, console, and
|
|
569
|
+
* request/response bodies/URLs to the ticket backend (a possibly different
|
|
570
|
+
* origin), so credentials/PII are masked BEFORE they leave the page - the agent
|
|
571
|
+
* still sees the SHAPE, not the secret. Two complementary passes:
|
|
572
|
+
* 1. by KEY - a property/param/storage key whose name names a secret.
|
|
573
|
+
* 2. by VALUE - a string that LOOKS like a secret (JWT, known token prefixes,
|
|
574
|
+
* long high-entropy blobs) regardless of its key, since apps routinely store
|
|
575
|
+
* a token under a benign key like `data`.
|
|
576
|
+
* Key matching is token-based (split camelCase/snake/kebab and match whole
|
|
577
|
+
* tokens) so it does NOT over-redact benign keys like `author`, `dashboard`,
|
|
578
|
+
* `cardId`, `oauth_client_id` that merely contain a sensitive substring.
|
|
579
|
+
*/
|
|
580
|
+
const REDACTED = "[redacted]";
|
|
581
|
+
|
|
582
|
+
/** Key name-segments that denote a secret/PII value. Matched as whole tokens. */
|
|
583
|
+
const SENSITIVE_TOKENS = new Set([
|
|
584
|
+
"password", "passwd", "pwd", "pass", "secret", "secrets", "token", "tokens",
|
|
585
|
+
"auth", "authorization", "jwt", "bearer", "apikey", "apitoken", "accesskey",
|
|
586
|
+
"accesstoken", "refreshtoken", "refresh", "session", "sessionid", "sid",
|
|
587
|
+
"cookie", "credential", "credentials", "privatekey", "clientsecret", "otp",
|
|
588
|
+
"pin", "ssn", "cvv", "cvc", "cardnumber", "creditcard", "pan",
|
|
589
|
+
]);
|
|
590
|
+
/** Unambiguous compound substrings (no benign collisions) to also catch. */
|
|
591
|
+
const SENSITIVE_KEY_SUBSTR =
|
|
592
|
+
/password|passwd|secret|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|private[-_]?key|credential/i;
|
|
593
|
+
|
|
594
|
+
/** Split a key into lowercase word tokens across camelCase / snake / kebab. */
|
|
595
|
+
function keyTokens(key: string): string[] {
|
|
596
|
+
return key
|
|
597
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
598
|
+
.split(/[^A-Za-z0-9]+/)
|
|
599
|
+
.map((t) => t.toLowerCase())
|
|
600
|
+
.filter(Boolean);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** True when a key NAME denotes a secret (token-exact, or unambiguous substring). */
|
|
604
|
+
function isSensitiveKey(key: string): boolean {
|
|
605
|
+
if (keyTokens(key).some((t) => SENSITIVE_TOKENS.has(t))) return true;
|
|
606
|
+
return SENSITIVE_KEY_SUBSTR.test(key);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const JWT_RE = /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/;
|
|
610
|
+
/**
|
|
611
|
+
* Known secret-token prefixes. Stripe/OpenAI `sk_`/`sk-` allow INTERNAL `_`/`-`
|
|
612
|
+
* in the run so `sk_live_...`/`sk_test_...` are caught (their second underscore
|
|
613
|
+
* would otherwise cut the run short). Also GitHub gh*_, AWS AKIA, Google AIza,
|
|
614
|
+
* Slack xox*.
|
|
615
|
+
*/
|
|
616
|
+
const TOKEN_PREFIX_RE =
|
|
617
|
+
/\b(?:sk|rk)[-_][A-Za-z0-9][A-Za-z0-9_-]{10,}\b|\bgh[pousr]_[A-Za-z0-9]{16,}\b|\bAKIA[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{20,}\b|\bxox[baprs]-[0-9A-Za-z-]{8,}\b/;
|
|
618
|
+
|
|
619
|
+
/** A canonical UUID - an entity id, not a secret; exempt from the entropy heuristic. */
|
|
620
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
621
|
+
|
|
622
|
+
/** True when a STRING VALUE looks like a secret regardless of the key it's under. */
|
|
623
|
+
function looksLikeSecretValue(s: unknown): boolean {
|
|
624
|
+
if (typeof s !== "string" || s.length < 16) return false;
|
|
625
|
+
if (JWT_RE.test(s) || TOKEN_PREFIX_RE.test(s)) return true;
|
|
626
|
+
// UUIDs are 36-char hyphenated entity ids - keep them so the agent can still
|
|
627
|
+
// follow `GET /orders/<uuid>` etc. (they'd otherwise trip the entropy branch).
|
|
628
|
+
if (UUID_RE.test(s)) return false;
|
|
629
|
+
// Long, spaceless, base64url/hex blob with >=2 character classes (or pure hex):
|
|
630
|
+
// catches opaque access tokens stored under benign keys, without masking prose.
|
|
631
|
+
if (s.length >= 32 && !/\s/.test(s) && /^[A-Za-z0-9._\-+/=]+$/.test(s)) {
|
|
632
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) => re.test(s)).length;
|
|
633
|
+
return classes >= 2 || /^[0-9a-f]{32,}$/i.test(s);
|
|
634
|
+
}
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Mask secret-shaped substrings inline within a free-text string (console, non-JSON bodies). */
|
|
639
|
+
function redactText(s: string): string {
|
|
640
|
+
if (!s) return s;
|
|
641
|
+
return s
|
|
642
|
+
.replace(new RegExp(JWT_RE, "g"), REDACTED)
|
|
643
|
+
.replace(new RegExp(TOKEN_PREFIX_RE, "g"), REDACTED)
|
|
644
|
+
.replace(
|
|
645
|
+
/\b(bearer|token|password|secret|api[-_]?key)\b(\s*[:=]\s*|\s+)([^\s"',;&)]{8,})/gi,
|
|
646
|
+
(_m, label, sep) => `${label}${sep}${REDACTED}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Mask a flat string map (storage / cookies) by key name OR value shape. */
|
|
651
|
+
function redactStringMap(map: Record<string, string>): Record<string, string> {
|
|
652
|
+
const out: Record<string, string> = {};
|
|
653
|
+
for (const k of Object.keys(map)) {
|
|
654
|
+
out[k] = isSensitiveKey(k) || looksLikeSecretValue(map[k]) ? REDACTED : map[k];
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Recursively mask an arbitrary JSON value: by key name, and by leaf-value shape. */
|
|
660
|
+
function redactDeep(value: unknown, depth = 0): unknown {
|
|
661
|
+
if (depth > 8) return value;
|
|
662
|
+
if (typeof value === "string") return looksLikeSecretValue(value) ? REDACTED : value;
|
|
663
|
+
if (value == null || typeof value !== "object") return value;
|
|
664
|
+
if (Array.isArray(value)) return value.map((v) => redactDeep(v, depth + 1));
|
|
665
|
+
const out: Record<string, unknown> = {};
|
|
666
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
667
|
+
out[k] = isSensitiveKey(k) ? REDACTED : redactDeep(v, depth + 1);
|
|
668
|
+
}
|
|
669
|
+
return out;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Mask sensitive query params (by name or value shape) in a URL string. */
|
|
673
|
+
function redactUrl(url: string): string {
|
|
674
|
+
try {
|
|
675
|
+
const base = typeof window !== "undefined" ? window.location?.href : undefined;
|
|
676
|
+
const u = new URL(url, base);
|
|
677
|
+
let changed = false;
|
|
678
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
679
|
+
if (isSensitiveKey(key) || looksLikeSecretValue(u.searchParams.get(key) ?? "")) {
|
|
680
|
+
u.searchParams.set(key, REDACTED);
|
|
681
|
+
changed = true;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return changed ? u.toString() : url;
|
|
685
|
+
} catch {
|
|
686
|
+
return url;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Redact a non-JSON request/response body string: form-urlencoded params, else free text. */
|
|
691
|
+
function redactBodyString(s: string): string {
|
|
692
|
+
// form-urlencoded shape: key=value(&key=value)*, no whitespace.
|
|
693
|
+
if (/^[^=&\s]+=[^&\s]*(?:&[^=&\s]+=[^&\s]*)*$/.test(s)) {
|
|
694
|
+
try {
|
|
695
|
+
const p = new URLSearchParams(s);
|
|
696
|
+
let changed = false;
|
|
697
|
+
for (const key of [...p.keys()]) {
|
|
698
|
+
if (isSensitiveKey(key) || looksLikeSecretValue(p.get(key) ?? "")) {
|
|
699
|
+
p.set(key, REDACTED);
|
|
700
|
+
changed = true;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (changed) return p.toString();
|
|
704
|
+
} catch {
|
|
705
|
+
/* fall through to text redaction */
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return redactText(s);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Redact a single console argument: objects deep, strings by shape + inline. */
|
|
712
|
+
function redactConsoleArg(a: unknown): unknown {
|
|
713
|
+
if (typeof a === "string") return looksLikeSecretValue(a) ? REDACTED : redactText(a);
|
|
714
|
+
return redactDeep(a);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Snapshot localStorage / sessionStorage / cookies as flat string maps. */
|
|
718
|
+
export function snapshotStorage(): StorageArtifact {
|
|
719
|
+
const readWebStorage = (store: Storage | undefined): Record<string, string> => {
|
|
720
|
+
const out: Record<string, string> = {};
|
|
721
|
+
if (!store) return out;
|
|
722
|
+
try {
|
|
723
|
+
for (let i = 0; i < store.length; i++) {
|
|
724
|
+
const key = store.key(i);
|
|
725
|
+
if (key == null) continue;
|
|
726
|
+
out[key] = store.getItem(key) ?? "";
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
/* storage access can throw in some sandboxed contexts */
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const readCookies = (): Record<string, string> => {
|
|
735
|
+
const out: Record<string, string> = {};
|
|
736
|
+
if (typeof document === "undefined" || !document.cookie) return out;
|
|
737
|
+
for (const pair of document.cookie.split(";")) {
|
|
738
|
+
const idx = pair.indexOf("=");
|
|
739
|
+
if (idx === -1) continue;
|
|
740
|
+
const key = pair.slice(0, idx).trim();
|
|
741
|
+
if (!key) continue;
|
|
742
|
+
out[key] = decodeURIComponent(pair.slice(idx + 1).trim());
|
|
743
|
+
}
|
|
744
|
+
return out;
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
local: redactStringMap(
|
|
749
|
+
readWebStorage(typeof window !== "undefined" ? window.localStorage : undefined),
|
|
750
|
+
),
|
|
751
|
+
session: redactStringMap(
|
|
752
|
+
readWebStorage(typeof window !== "undefined" ? window.sessionStorage : undefined),
|
|
753
|
+
),
|
|
754
|
+
cookies: redactStringMap(readCookies()),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Snapshot the DOM. `targetSelector` points back at the clicked element within
|
|
760
|
+
* the serialized `html`. Captures the full document outerHTML plus viewport.
|
|
761
|
+
*/
|
|
762
|
+
export function snapshotDom(targetSelector: string | null): DomArtifact {
|
|
763
|
+
const html =
|
|
764
|
+
typeof document !== "undefined" && document.documentElement
|
|
765
|
+
? document.documentElement.outerHTML
|
|
766
|
+
: "";
|
|
767
|
+
return {
|
|
768
|
+
html,
|
|
769
|
+
targetSelector,
|
|
770
|
+
viewport: {
|
|
771
|
+
width: typeof window !== "undefined" ? Math.round(window.innerWidth) : 0,
|
|
772
|
+
height: typeof window !== "undefined" ? Math.round(window.innerHeight) : 0,
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Longest side (px) the captured screenshot is downscaled to. Keeps the
|
|
779
|
+
* base64 payload small - a JPEG at this cap lands well under ~1MB.
|
|
780
|
+
*/
|
|
781
|
+
const SCREENSHOT_MAX_SIDE = 1280;
|
|
782
|
+
|
|
783
|
+
/** JPEG quality for the exported screenshot (0..1). Lower = smaller payload. */
|
|
784
|
+
const SCREENSHOT_JPEG_QUALITY = 0.7;
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Render the page to an image at report time, so the agent (which can see images)
|
|
788
|
+
* and the human reviewer can directly judge visual / "looks wrong" reports that a
|
|
789
|
+
* DOM/CSS snapshot can't convey.
|
|
790
|
+
*
|
|
791
|
+
* Best-effort and non-blocking: html2canvas is loaded ONLY here, via a lazy
|
|
792
|
+
* dynamic import, so it never sits on the page-load critical path - it is fetched
|
|
793
|
+
* the first time a report is actually filed. Any failure (import failed, canvas
|
|
794
|
+
* tainted, unsupported context) returns `null` and the screenshot artifact is
|
|
795
|
+
* simply omitted; it must never break report submission.
|
|
796
|
+
*
|
|
797
|
+
* The widget's own UI (FAB / report form / nav) carries `data-paikko-ui`; we pass
|
|
798
|
+
* html2canvas's `ignoreElements` so none of it appears in the shot - the reviewer
|
|
799
|
+
* sees the page as the user saw it, not the open form.
|
|
800
|
+
*
|
|
801
|
+
* Size control: the longest side is capped to {@link SCREENSHOT_MAX_SIDE} via the
|
|
802
|
+
* `scale` option and the image is exported as JPEG at
|
|
803
|
+
* {@link SCREENSHOT_JPEG_QUALITY}.
|
|
804
|
+
*/
|
|
805
|
+
export async function snapshotScreenshot(): Promise<ScreenshotArtifact | null> {
|
|
806
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
const target = document.body ?? document.documentElement;
|
|
811
|
+
if (!target) return null;
|
|
812
|
+
|
|
813
|
+
const mod = await import("html2canvas");
|
|
814
|
+
const html2canvas = (mod.default ?? mod) as typeof import("html2canvas").default;
|
|
815
|
+
|
|
816
|
+
// Downscale: cap the longest side. html2canvas renders at CSS px * scale, so
|
|
817
|
+
// a scale < 1 shrinks the output. Never upscale (cap scale at the device-ish
|
|
818
|
+
// baseline of 1) so small pages aren't blown up.
|
|
819
|
+
const longestSide = Math.max(
|
|
820
|
+
target.scrollWidth || window.innerWidth,
|
|
821
|
+
target.scrollHeight || window.innerHeight,
|
|
822
|
+
1,
|
|
823
|
+
);
|
|
824
|
+
const scale = Math.min(1, SCREENSHOT_MAX_SIDE / longestSide);
|
|
825
|
+
|
|
826
|
+
const canvas = await html2canvas(target, {
|
|
827
|
+
scale,
|
|
828
|
+
logging: false,
|
|
829
|
+
useCORS: true,
|
|
830
|
+
// Exclude the paikko widget UI (FAB / form / nav) from the shot.
|
|
831
|
+
ignoreElements: (el: Element) =>
|
|
832
|
+
el instanceof Element && el.closest("[data-paikko-ui]") !== null,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const dataUrl = canvas.toDataURL("image/jpeg", SCREENSHOT_JPEG_QUALITY);
|
|
836
|
+
if (!dataUrl || !dataUrl.startsWith("data:image/")) return null;
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
dataUrl,
|
|
840
|
+
width: canvas.width,
|
|
841
|
+
height: canvas.height,
|
|
842
|
+
format: "jpeg",
|
|
843
|
+
};
|
|
844
|
+
} catch {
|
|
845
|
+
// Best-effort: a failed screenshot must never block a report.
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/* ------------------------------------------------------------------ */
|
|
851
|
+
/* Element resolution (point mode) */
|
|
852
|
+
/* ------------------------------------------------------------------ */
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Resolve a clicked element to a {@link ReportTarget}: a CSS selector that finds
|
|
856
|
+
* it again, its build-time `data-src` provenance, and the nearest owning
|
|
857
|
+
* component's name (`data-component`). Walks ancestors for provenance because
|
|
858
|
+
* leaf nodes (a bare <span>) often don't carry the attribute themselves.
|
|
859
|
+
*/
|
|
860
|
+
export function resolveTarget(el: Element | null): ReportTarget {
|
|
861
|
+
if (!el) return { selector: null, src: null, component: null };
|
|
862
|
+
|
|
863
|
+
let src: string | null = null;
|
|
864
|
+
let component: string | null = null;
|
|
865
|
+
let cursor: Element | null = el;
|
|
866
|
+
while (cursor && (src == null || component == null)) {
|
|
867
|
+
if (src == null) {
|
|
868
|
+
const s = cursor.getAttribute(SRC_ATTR);
|
|
869
|
+
if (s) src = s;
|
|
870
|
+
}
|
|
871
|
+
if (component == null) {
|
|
872
|
+
const c = cursor.getAttribute(COMPONENT_ATTR);
|
|
873
|
+
if (c) component = c;
|
|
874
|
+
}
|
|
875
|
+
cursor = cursor.parentElement;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return { selector: buildSelector(el), src, component };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Build a reasonably stable, reasonably unique CSS selector for an element.
|
|
883
|
+
* Prefers id, else builds a path of tag + nth-of-type from the nearest id'd
|
|
884
|
+
* ancestor (or document root).
|
|
885
|
+
*/
|
|
886
|
+
export function buildSelector(el: Element): string {
|
|
887
|
+
if (el.id) return `#${cssEscape(el.id)}`;
|
|
888
|
+
|
|
889
|
+
const parts: string[] = [];
|
|
890
|
+
let cursor: Element | null = el;
|
|
891
|
+
while (cursor && cursor.nodeType === Node.ELEMENT_NODE) {
|
|
892
|
+
if (cursor.id) {
|
|
893
|
+
parts.unshift(`#${cssEscape(cursor.id)}`);
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
const tag = cursor.tagName.toLowerCase();
|
|
897
|
+
if (tag === "html" || tag === "body") {
|
|
898
|
+
parts.unshift(tag);
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
const parent: Element | null = cursor.parentElement;
|
|
902
|
+
if (!parent) {
|
|
903
|
+
parts.unshift(tag);
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
const sameTag = Array.from(parent.children).filter(
|
|
907
|
+
(c) => c.tagName === cursor!.tagName,
|
|
908
|
+
);
|
|
909
|
+
if (sameTag.length > 1) {
|
|
910
|
+
const idx = sameTag.indexOf(cursor) + 1;
|
|
911
|
+
parts.unshift(`${tag}:nth-of-type(${idx})`);
|
|
912
|
+
} else {
|
|
913
|
+
parts.unshift(tag);
|
|
914
|
+
}
|
|
915
|
+
cursor = parent;
|
|
916
|
+
}
|
|
917
|
+
return parts.join(" > ");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function cssEscape(value: string): string {
|
|
921
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
922
|
+
return CSS.escape(value);
|
|
923
|
+
}
|
|
924
|
+
return value.replace(/([^\w-])/g, "\\$1");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* ------------------------------------------------------------------ */
|
|
928
|
+
/* Bundle assembly */
|
|
929
|
+
/* ------------------------------------------------------------------ */
|
|
930
|
+
|
|
931
|
+
/** The set of artifacts captured at report time, ready to inline into a bundle. */
|
|
932
|
+
export interface CapturedArtifacts {
|
|
933
|
+
console?: ConsoleArtifact;
|
|
934
|
+
network?: NetworkArtifact;
|
|
935
|
+
clientState?: ClientStateArtifact;
|
|
936
|
+
storage?: StorageArtifact;
|
|
937
|
+
dom?: DomArtifact;
|
|
938
|
+
screenshot?: ScreenshotArtifact;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Freeze every available artifact into immutable, contract-valid payloads. This
|
|
943
|
+
* is the report-time photograph: the live buffers are read, the stateless
|
|
944
|
+
* snapshots are taken, and each payload is parsed through its contract schema so
|
|
945
|
+
* only valid data leaves the client. Note `trace` is intentionally absent - the
|
|
946
|
+
* backend trace artifact is produced server-side from the propagated traceIds.
|
|
947
|
+
*
|
|
948
|
+
* Async because the `screenshot` artifact is rendered lazily (html2canvas is
|
|
949
|
+
* dynamically imported only here, at report time) and is best-effort: if it fails
|
|
950
|
+
* or html2canvas isn't available, the screenshot is simply omitted and the rest of
|
|
951
|
+
* the snapshot is returned unchanged - a failed screenshot never breaks a report.
|
|
952
|
+
*/
|
|
953
|
+
export async function snapshotArtifacts(
|
|
954
|
+
capture: Capture,
|
|
955
|
+
targetSelector: string | null,
|
|
956
|
+
): Promise<CapturedArtifacts> {
|
|
957
|
+
const out: CapturedArtifacts = {};
|
|
958
|
+
|
|
959
|
+
const console_ = ArtifactPayloadSchemas.console.safeParse(capture.snapshotConsole());
|
|
960
|
+
if (console_.success && console_.data.length) out.console = console_.data;
|
|
961
|
+
|
|
962
|
+
const network = ArtifactPayloadSchemas.network.safeParse(capture.snapshotNetwork());
|
|
963
|
+
if (network.success && network.data.length) out.network = network.data;
|
|
964
|
+
|
|
965
|
+
const clientState = ArtifactPayloadSchemas.clientState.safeParse(
|
|
966
|
+
capture.snapshotClientState(),
|
|
967
|
+
);
|
|
968
|
+
if (clientState.success && Object.keys(clientState.data).length) {
|
|
969
|
+
out.clientState = clientState.data;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const storage = ArtifactPayloadSchemas.storage.safeParse(snapshotStorage());
|
|
973
|
+
if (storage.success) out.storage = storage.data;
|
|
974
|
+
|
|
975
|
+
const dom = ArtifactPayloadSchemas.dom.safeParse(snapshotDom(targetSelector));
|
|
976
|
+
if (dom.success && dom.data.html) out.dom = dom.data;
|
|
977
|
+
|
|
978
|
+
// Best-effort screenshot: lazily rendered, validated against the contract, and
|
|
979
|
+
// omitted on any failure so it can never block report submission.
|
|
980
|
+
try {
|
|
981
|
+
const shot = await snapshotScreenshot();
|
|
982
|
+
if (shot) {
|
|
983
|
+
const screenshot = ArtifactPayloadSchemas.screenshot.safeParse(shot);
|
|
984
|
+
if (screenshot.success) out.screenshot = screenshot.data;
|
|
985
|
+
}
|
|
986
|
+
} catch {
|
|
987
|
+
/* never let the screenshot break the snapshot */
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return out;
|
|
991
|
+
}
|