@lightworkai.official/debug-capture 0.6.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.
Files changed (65) hide show
  1. package/README.md +241 -0
  2. package/dist/budget.d.ts +22 -0
  3. package/dist/bundle.d.ts +20 -0
  4. package/dist/capture/actionTrail.d.ts +7 -0
  5. package/dist/capture/cause.d.ts +3 -0
  6. package/dist/capture/consoleBuffer.d.ts +4 -0
  7. package/dist/capture/crashWatcher.d.ts +1 -0
  8. package/dist/capture/networkBuffer.d.ts +4 -0
  9. package/dist/capture/redact.d.ts +9 -0
  10. package/dist/capture/stepCorrelation.d.ts +41 -0
  11. package/dist/config.d.ts +217 -0
  12. package/dist/context.d.ts +2 -0
  13. package/dist/debug-capture.js +116 -0
  14. package/dist/embed.d.ts +1 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.mjs +129 -0
  17. package/dist/install.d.ts +5 -0
  18. package/dist/mytickets/api.d.ts +115 -0
  19. package/dist/mytickets/format.d.ts +84 -0
  20. package/dist/mytickets/sanitize.d.ts +66 -0
  21. package/dist/mytickets/strings.d.ts +74 -0
  22. package/dist/mytickets/toolbar.d.ts +17 -0
  23. package/dist/reporter.d.ts +27 -0
  24. package/dist/screenshot.d.ts +7 -0
  25. package/dist/signature.d.ts +18 -0
  26. package/dist/submit.d.ts +8 -0
  27. package/dist/types.d.ts +175 -0
  28. package/dist/ui/annotator.d.ts +34 -0
  29. package/dist/ui/arrow.d.ts +25 -0
  30. package/dist/ui/element.d.ts +9 -0
  31. package/dist/ui/strings.d.ts +42 -0
  32. package/dist/ui/styles.d.ts +13 -0
  33. package/dist/ui/toast.d.ts +11 -0
  34. package/package.json +53 -0
  35. package/src/budget.ts +62 -0
  36. package/src/bundle.ts +274 -0
  37. package/src/capture/actionTrail.ts +693 -0
  38. package/src/capture/cause.ts +49 -0
  39. package/src/capture/consoleBuffer.ts +80 -0
  40. package/src/capture/crashWatcher.ts +61 -0
  41. package/src/capture/networkBuffer.ts +315 -0
  42. package/src/capture/redact.ts +117 -0
  43. package/src/capture/stepCorrelation.ts +160 -0
  44. package/src/config.ts +299 -0
  45. package/src/context.ts +81 -0
  46. package/src/embed.ts +35 -0
  47. package/src/index.ts +109 -0
  48. package/src/install.ts +59 -0
  49. package/src/mytickets/api.ts +226 -0
  50. package/src/mytickets/format.ts +191 -0
  51. package/src/mytickets/sanitize.ts +221 -0
  52. package/src/mytickets/strings.ts +217 -0
  53. package/src/mytickets/toolbar.ts +48 -0
  54. package/src/reporter.ts +53 -0
  55. package/src/screenshot.ts +238 -0
  56. package/src/signature.ts +40 -0
  57. package/src/styles.css +400 -0
  58. package/src/submit.ts +143 -0
  59. package/src/types.ts +169 -0
  60. package/src/ui/annotator.ts +698 -0
  61. package/src/ui/arrow.ts +62 -0
  62. package/src/ui/element.ts +362 -0
  63. package/src/ui/strings.ts +113 -0
  64. package/src/ui/styles.ts +96 -0
  65. package/src/ui/toast.ts +138 -0
@@ -0,0 +1,160 @@
1
+ // Joins the action trail to the network + console it produced.
2
+ //
3
+ // The bundle already captures everything needed to reproduce an issue, but as
4
+ // three PARALLEL lists: "ขั้นตอน (7)", "Network 27", "Console 7". Reading it
5
+ // means joining ~35 timestamped rows against 7 steps by eye to answer the only
6
+ // question that matters — "which click broke it?". This does that join, so a
7
+ // step reads "6. คลิก บันทึก · 3 คำขอ · POST /edocs 500" instead of sending the
8
+ // reader to another tab to guess.
9
+ //
10
+ // Derived at RENDER time from timestamps already in the payload: no new capture,
11
+ // no privacy change, and it works on tickets reported before this existed.
12
+
13
+ import type {
14
+ ActionTrailEntry,
15
+ CapturedConsoleEntry,
16
+ CapturedRequest,
17
+ } from '../types';
18
+
19
+ export interface StepActivity {
20
+ /** ms since the previous step. Null on the first — nothing to measure from. */
21
+ elapsedMs: number | null;
22
+ /** Requests that STARTED while this step was the most recent one. */
23
+ requests: CapturedRequest[];
24
+ /** Subset that errored or came back >= 400 — usually the answer. */
25
+ failedRequests: CapturedRequest[];
26
+ /** error / exception / unhandledrejection logged in the same window. */
27
+ consoleErrors: CapturedConsoleEntry[];
28
+ /** Longest request in the window — surfaces the click that appeared to hang. */
29
+ slowestMs: number | null;
30
+ }
31
+
32
+ const ERROR_LEVELS = new Set(['error', 'exception', 'unhandledrejection']);
33
+
34
+ /** A request counts as failed if it rejected outright or returned >= 400. */
35
+ export function isFailedRequest(req: CapturedRequest): boolean {
36
+ if (req.error) return true;
37
+ return req.status !== null && req.status >= 400;
38
+ }
39
+
40
+ /** Epoch ms, or null when the timestamp is missing/unparseable — a bundle from
41
+ * an older schema must degrade to "no correlation", never to NaN windows. */
42
+ function timeOf(iso: string | undefined): number | null {
43
+ if (!iso) return null;
44
+ const ms = Date.parse(iso);
45
+ return Number.isNaN(ms) ? null : ms;
46
+ }
47
+
48
+ /** Path (no origin, no query) for compact display — the origin is identical on
49
+ * every row and the query is often a long redacted filter string.
50
+ *
51
+ * Decoded before display: `URL` percent-encodes non-ASCII, and these paths
52
+ * routinely carry Thai, so the raw `pathname` would render as a wall of
53
+ * `%E0%B9%84…` exactly where a human is trying to read which endpoint failed. */
54
+ export function requestPath(url: string): string {
55
+ try {
56
+ const { pathname } = new URL(url, 'http://x');
57
+ try {
58
+ return decodeURIComponent(pathname);
59
+ } catch {
60
+ return pathname; // malformed escape sequence — better raw than thrown
61
+ }
62
+ } catch {
63
+ // split always yields at least one element; the ?? keeps the strict
64
+ // index check happy without pretending the array could be empty.
65
+ return url.split(/[?#]/)[0] ?? url;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Attribute network + console activity to the step that was current at the time.
71
+ *
72
+ * Step `i` owns `[at(i), at(i+1))`; the last step owns everything after it.
73
+ * Returns one entry per trail row, in the same order, so callers can index by
74
+ * position without a lookup.
75
+ *
76
+ * Activity BEFORE the first recorded step belongs to no step and is omitted:
77
+ * the trail is a bounded ring buffer, so the earliest rows are frequently
78
+ * mid-session and anything before them was caused by actions already evicted —
79
+ * attributing it to step 1 would invent a link that isn't there. The Network and
80
+ * Console sections still list every row.
81
+ */
82
+ export function correlateActionTrail(
83
+ trail: readonly ActionTrailEntry[],
84
+ network: readonly CapturedRequest[],
85
+ consoleEntries: readonly CapturedConsoleEntry[],
86
+ ): StepActivity[] {
87
+ const empty = (elapsedMs: number | null): StepActivity => ({
88
+ elapsedMs,
89
+ requests: [],
90
+ failedRequests: [],
91
+ consoleErrors: [],
92
+ slowestMs: null,
93
+ });
94
+ if (trail.length === 0) return [];
95
+
96
+ const stepTimes = trail.map((entry) => timeOf(entry.at));
97
+
98
+ // Window end = the next step that HAS a usable timestamp. Skipping unparseable
99
+ // ones keeps a single bad row from swallowing the rest of the trail.
100
+ const windowEnd = (index: number): number => {
101
+ for (let i = index + 1; i < stepTimes.length; i += 1) {
102
+ const next = stepTimes[i];
103
+ if (next != null) return next;
104
+ }
105
+ return Number.POSITIVE_INFINITY;
106
+ };
107
+
108
+ return trail.map((_entry, index) => {
109
+ const start = stepTimes[index] ?? null;
110
+ const prev = (index > 0 ? stepTimes[index - 1] : null) ?? null;
111
+ // Clamp: a collapsed duplicate refreshes its row's timestamp to the LAST
112
+ // occurrence, which can put it after a row recorded later. A negative
113
+ // "elapsed" would read as time travel, so floor it at zero.
114
+ const elapsedMs =
115
+ start !== null && prev !== null ? Math.max(0, start - prev) : null;
116
+ if (start === null) return empty(elapsedMs);
117
+
118
+ const from = start;
119
+ const end = Math.max(from, windowEnd(index));
120
+ const inWindow = (ms: number | null): boolean =>
121
+ ms !== null && ms >= from && (ms < end || end === Number.POSITIVE_INFINITY);
122
+
123
+ const requests = network.filter((req) => inWindow(timeOf(req.startedAt)));
124
+ const consoleErrors = consoleEntries.filter(
125
+ (entry) => ERROR_LEVELS.has(entry.level) && inWindow(timeOf(entry.at)),
126
+ );
127
+ const durations = requests
128
+ .map((req) => req.durationMs)
129
+ .filter((ms): ms is number => typeof ms === 'number');
130
+
131
+ return {
132
+ elapsedMs,
133
+ requests,
134
+ failedRequests: requests.filter(isFailedRequest),
135
+ consoleErrors,
136
+ slowestMs: durations.length > 0 ? Math.max(...durations) : null,
137
+ };
138
+ });
139
+ }
140
+
141
+ /** True when a step produced nothing worth showing — lets the UI skip the row
142
+ * rather than print a line of zeroes under every step. */
143
+ export function isQuietStep(activity: StepActivity, gapThresholdMs: number): boolean {
144
+ return (
145
+ activity.requests.length === 0 &&
146
+ activity.consoleErrors.length === 0 &&
147
+ (activity.elapsedMs === null || activity.elapsedMs < gapThresholdMs)
148
+ );
149
+ }
150
+
151
+ /** Compact Thai duration: "1.4 วิ" / "12 วิ" / "2 นาที 5 วิ". */
152
+ export function formatDuration(ms: number): string {
153
+ if (ms < 1000) return `${Math.round(ms)} มิลลิวินาที`;
154
+ const seconds = ms / 1000;
155
+ if (seconds < 10) return `${seconds.toFixed(1)} วิ`;
156
+ if (seconds < 60) return `${Math.round(seconds)} วิ`;
157
+ const minutes = Math.floor(seconds / 60);
158
+ const rest = Math.round(seconds % 60);
159
+ return rest === 0 ? `${minutes} นาที` : `${minutes} นาที ${rest} วิ`;
160
+ }
package/src/config.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Everything this library refuses to guess.
3
+ *
4
+ * The original read the app's env module, its user store and its API-header
5
+ * helper directly — three imports that made it un-shippable. They are callbacks
6
+ * now, and the rule they follow is: if a fact belongs to ONE app, the app
7
+ * supplies it.
8
+ *
9
+ * That includes the module vocabulary. The original hard-coded a map of Thai
10
+ * department names; a package that ships another organisation's org chart is
11
+ * not a package.
12
+ *
13
+ * Nothing here is secret. `realmKey` is public by design — the support ingest
14
+ * endpoint authenticates the widget with it and rate-limits per IP *and* per
15
+ * realm precisely because it travels in client code. Do not put a bearer token
16
+ * in `headers()`; it is captured into every bundle (redacted, but still).
17
+ */
18
+ import type { DebugUser } from "./types";
19
+
20
+ /** The shape of html-to-image's `toPng`, without depending on it. */
21
+ export type ToPng = (node: HTMLElement, options?: Record<string, unknown>) => Promise<string>;
22
+
23
+ export interface DebugCaptureConfig {
24
+ /** Support host origin, e.g. https://support.example.com */
25
+ host: string;
26
+ /** The realm's public embed key. */
27
+ realmKey: string;
28
+
29
+ app: {
30
+ /**
31
+ * What this app is called, in the words a support agent would use.
32
+ *
33
+ * It becomes the ticket's category, so a desk taking reports from several
34
+ * apps can filter by the one that broke. The module was doing that job,
35
+ * derived from the first path segment — which produced badges like "admin"
36
+ * and told a reader nothing about WHICH admin, of which app. A name the app
37
+ * states about itself cannot be wrong; one inferred from a URL usually is.
38
+ */
39
+ name: string;
40
+ /** Defaults to "0.0.0". Worth setting: it ties a report to a build. */
41
+ version?: string;
42
+ /**
43
+ * "production" | "uat" | "development" — free text; it is only reported.
44
+ *
45
+ * Inferred from the hostname when omitted, which gets localhost right and
46
+ * everything else wrong-but-harmless. A staging environment is worth naming
47
+ * yourself; nothing can guess the difference between uat and production
48
+ * from a URL.
49
+ */
50
+ environment?: string;
51
+ };
52
+
53
+ /** Who is using the app. Called at capture time, not at config time. */
54
+ user?: () => DebugUser;
55
+
56
+ /**
57
+ * Proof of who that is, minted by the HOST'S SERVER. Required only by the
58
+ * "my tickets" surface.
59
+ *
60
+ * `user()` above is a claim; this is evidence. Filing a report needs no
61
+ * evidence — the worst a false name buys is a mislabelled ticket — but reading
62
+ * reports back does, because `realmKey` is public and "show me the tickets for
63
+ * this email" behind a public key is an endpoint that reads everyone's.
64
+ *
65
+ * Your server signs a short-lived HS256 JWT with the realm's reporter secret:
66
+ *
67
+ * jwt.sign({ sub: user.id, email: user.email, name: user.fullName },
68
+ * process.env.SUPPORT_REPORTER_SECRET,
69
+ * { algorithm: 'HS256', expiresIn: '1h' })
70
+ *
71
+ * The SECRET NEVER REACHES THE BROWSER. This callback fetches the finished
72
+ * token from your own backend; it is called per request, so cache it on your
73
+ * side until it is close to expiring.
74
+ *
75
+ * Returning null means "nobody is signed in" — the panel says so rather than
76
+ * failing.
77
+ */
78
+ identity?: () => string | null | Promise<string | null>;
79
+
80
+ /**
81
+ * Extra request headers the app sends, for the record — acting-as ids and
82
+ * the like. Redacted before it reaches a bundle.
83
+ */
84
+ headers?: () => Record<string, string>;
85
+
86
+ /**
87
+ * Which part of the app the user was in — สารบรรณ, ครุภัณฑ์, งบประมาณ.
88
+ *
89
+ * This becomes the ticket's category, because on a desk that supports web
90
+ * applications "which module broke" is the question worth filtering by. It
91
+ * falls back to `app.name`, so a single-module app still lands somewhere
92
+ * sensible without configuring anything.
93
+ *
94
+ * A DECLARED module is worth having; a guessed one is not. This used to
95
+ * default to the first path segment, which produced categories like "admin"
96
+ * — true of the URL and meaningless to whoever picks the report up. If the app
97
+ * cannot say, it should not guess, so the fallback is the app's own name.
98
+ *
99
+ * Return null for a path you have no name for. Most hosts declare a map of
100
+ * the screens they care about, and the rule this callback exists to enforce
101
+ * applies to THEM too: without a way to say "not one of mine", the only ways
102
+ * out are inventing a label or throwing, and a host that throws here looks
103
+ * like a host with no modules at all.
104
+ */
105
+ module?: (pathname: string) => { key: string; label: string } | null;
106
+
107
+ /** Anything else worth recording — feature flags, build id, tenant. */
108
+ extra?: () => Record<string, unknown>;
109
+
110
+ capture?: CaptureOptions;
111
+ redact?: RedactOptions;
112
+
113
+ /**
114
+ * A DOM-rendering screenshot fallback, for browsers without the Screen
115
+ * Capture API. Supplied by the host so this package never names a dependency
116
+ * it does not have — a bare `import('html-to-image')` here survives our build
117
+ * and then fails the CONSUMER's, because a minifier folds any runtime
118
+ * specifier back into a literal Rollup tries to resolve.
119
+ *
120
+ * screenshotFallback: () => import('html-to-image').then((m) => m.toPng)
121
+ */
122
+ screenshotFallback?: () => Promise<ToPng | null>;
123
+
124
+ /** UI language. Only affects the built-in reporter's own strings. */
125
+ locale?: "th" | "en";
126
+ }
127
+
128
+ export interface CaptureOptions {
129
+ /** Rolling buffer sizes. */
130
+ maxRequests?: number;
131
+ maxConsoleEntries?: number;
132
+ maxActions?: number;
133
+ /** Per-response body cap, in characters. */
134
+ maxBodyChars?: number;
135
+ /**
136
+ * Origins whose RESPONSE BODIES may be captured. Same-origin is always
137
+ * included; anything else records method/status/duration only.
138
+ */
139
+ bodyOrigins?: string[];
140
+ /**
141
+ * CSS selectors for overlays worth recording when they appear — dialogs,
142
+ * menus, toasts. Defaults cover ARIA roles, which is most component libraries.
143
+ */
144
+ overlaySelectors?: string[];
145
+ }
146
+
147
+ export interface RedactOptions {
148
+ /** Extra header names to mask. Matched case-insensitively, as whole names. */
149
+ headers?: string[];
150
+ /** Extra query-string keys to mask. */
151
+ queryKeys?: string[];
152
+ /** Extra body keys to mask. Matched as a substring, like the built-ins. */
153
+ bodyKeys?: string[];
154
+ }
155
+
156
+ const DEFAULTS = {
157
+ maxRequests: 60,
158
+ maxConsoleEntries: 120,
159
+ maxActions: 80,
160
+ maxBodyChars: 20_000,
161
+ };
162
+
163
+ let current: DebugCaptureConfig | null = null;
164
+
165
+ export function configureDebugCapture(config: DebugCaptureConfig): void {
166
+ current = config;
167
+ }
168
+
169
+ /**
170
+ * Throws rather than returning a default. A bundle assembled against a
171
+ * half-configured library would be submitted to nowhere and look like it
172
+ * worked — and that failure surfaces as a missing ticket, days later.
173
+ */
174
+ export function getConfig(): DebugCaptureConfig {
175
+ if (!current) throw new Error("[debug-capture] configureDebugCapture() has not been called");
176
+ return current;
177
+ }
178
+
179
+ export function isConfigured(): boolean {
180
+ return current !== null;
181
+ }
182
+
183
+ /**
184
+ * The app's identity, with the parts it did not bother to state filled in.
185
+ *
186
+ * Everything optional in this library is optional because a sensible default
187
+ * exists — the config a host writes should be the things only that host knows.
188
+ */
189
+ export function appInfo(): { name: string; version: string; environment: string } {
190
+ const app = getConfig().app;
191
+ return {
192
+ name: app.name,
193
+ version: app.version ?? "0.0.0",
194
+ environment: app.environment ?? guessEnvironment(),
195
+ };
196
+ }
197
+
198
+ function guessEnvironment(): string {
199
+ if (typeof window === "undefined") return "unknown";
200
+ const host = window.location.hostname;
201
+ if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".local")) return "development";
202
+ return "production";
203
+ }
204
+
205
+ export function captureOption(key: keyof typeof DEFAULTS): number {
206
+ const value = current?.capture?.[key];
207
+ return typeof value === "number" ? value : DEFAULTS[key];
208
+ }
209
+
210
+ /**
211
+ * The host's module for this path, or nothing.
212
+ *
213
+ * Nothing, deliberately: an undeclared module is not the first path segment. See
214
+ * the note on `module` above.
215
+ */
216
+ export function resolveModule(pathname: string): { key: string; label: string } | null {
217
+ const custom = current?.module;
218
+ if (!custom) return null;
219
+ try {
220
+ return custom(pathname) ?? null;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ /** Redaction extras, always a usable shape even before configure() runs. */
227
+ export function getRedactOptions(): Required<RedactOptions> {
228
+ const r = current?.redact;
229
+ return {
230
+ headers: r?.headers ?? [],
231
+ queryKeys: (r?.queryKeys ?? []).map((k) => k.toLowerCase()),
232
+ bodyKeys: (r?.bodyKeys ?? []).map((k) => k.toLowerCase()),
233
+ };
234
+ }
235
+
236
+ export function overlaySelectors(): string[] {
237
+ return current?.capture?.overlaySelectors ?? [];
238
+ }
239
+
240
+ /**
241
+ * Whether a response body from this URL may be stored.
242
+ *
243
+ * Same-origin always: the app's own API is the whole reason anyone opens a
244
+ * report. Anything else only if the host named it in `bodyOrigins` — a body
245
+ * from a third party the host merely calls is not ours to collect.
246
+ *
247
+ * The one exclusion is the WIDGET'S OWN traffic: `/ingest/*` on the support
248
+ * host. Those calls are this library talking to itself, and a report that
249
+ * contains the request which fetched the report's own config tells a reader
250
+ * nothing.
251
+ *
252
+ * It used to exclude the support host's WHOLE origin, as a guard against a
253
+ * report filed from a ticket screen storing ticket bodies that themselves carry
254
+ * earlier reports' captured data. That was too blunt by half: when the app IS
255
+ * the support product filing its own bugs, the support host is its
256
+ * own origin, so the guard dropped every body it had and reports arrived with
257
+ * nothing but URLs and timings.
258
+ *
259
+ * The compounding it worried about is already bounded, and was before: every
260
+ * body is truncated at `maxBodyChars` (20k), a response over 1 MB is never read
261
+ * at all, and `trimToBudget` caps the finished bundle at 2 MB. A second, much
262
+ * coarser cap on top of those cost far more than it saved.
263
+ */
264
+ export function mayCaptureBody(url: string): boolean {
265
+ if (typeof window === "undefined") return false;
266
+ let target: URL;
267
+ try {
268
+ target = new URL(url, window.location.href);
269
+ } catch {
270
+ return false;
271
+ }
272
+ if (isWidgetTraffic(target)) return false;
273
+ if (target.origin === window.location.origin) return true;
274
+ return (current?.capture?.bodyOrigins ?? []).some((allowed) => {
275
+ try {
276
+ return new URL(allowed).origin === target.origin;
277
+ } catch {
278
+ return false;
279
+ }
280
+ });
281
+ }
282
+
283
+ /**
284
+ * This library talking to the support host — `/ingest/config`, `/ingest/me/…`.
285
+ *
286
+ * Matched by PATH as well as origin, deliberately. Matching the origin alone is
287
+ * what made a self-hosted support app unable to capture anything: its own API
288
+ * lives on that origin too.
289
+ */
290
+ export function isWidgetTraffic(target: URL): boolean {
291
+ if (!current?.host) return false;
292
+ try {
293
+ if (new URL(current.host).origin !== target.origin) return false;
294
+ } catch {
295
+ // A malformed host is the consumer's problem, not a reason to throw here.
296
+ return false;
297
+ }
298
+ return target.pathname.startsWith("/ingest/");
299
+ }
package/src/context.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The safe session / page / environment context for a bundle.
3
+ *
4
+ * Everything app-specific arrives through config callbacks; this file only
5
+ * reads what the browser itself knows. No token is ever read, and the host's
6
+ * own headers go through the same redaction as captured traffic — a header the
7
+ * app considers routine ("X-Employee-Id") is fine, one that turns out to be a
8
+ * credential is not, and this cannot tell them apart.
9
+ */
10
+ import { appInfo, getConfig, resolveModule } from "./config";
11
+ import { redactHeaders } from "./capture/redact";
12
+ import type { DebugContext } from "./types";
13
+
14
+ interface PerfMemory {
15
+ usedJSHeapSize: number;
16
+ jsHeapSizeLimit: number;
17
+ }
18
+
19
+ function readPerformance(): DebugContext["performance"] {
20
+ const result: DebugContext["performance"] = {
21
+ memoryUsedMb: null,
22
+ memoryLimitMb: null,
23
+ domContentLoadedMs: null,
24
+ loadEventMs: null,
25
+ };
26
+ if (typeof performance === "undefined") return result;
27
+
28
+ const memory = (performance as Performance & { memory?: PerfMemory }).memory;
29
+ if (memory) {
30
+ result.memoryUsedMb = Math.round(memory.usedJSHeapSize / 1024 / 1024);
31
+ result.memoryLimitMb = Math.round(memory.jsHeapSizeLimit / 1024 / 1024);
32
+ }
33
+ const [nav] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[];
34
+ if (nav) {
35
+ result.domContentLoadedMs = Math.round(nav.domContentLoadedEventEnd);
36
+ result.loadEventMs = Math.round(nav.loadEventEnd);
37
+ }
38
+ return result;
39
+ }
40
+
41
+ /** Never throws: a context that half-collected still beats no report at all. */
42
+ function safely<T>(read: (() => T) | undefined, fallback: T): T {
43
+ if (!read) return fallback;
44
+ try {
45
+ return read();
46
+ } catch {
47
+ return fallback;
48
+ }
49
+ }
50
+
51
+ export function collectContext(): DebugContext {
52
+ const config = getConfig();
53
+ const { pathname, search, href } = window.location;
54
+
55
+ return {
56
+ capturedAt: new Date().toISOString(),
57
+ route: { href, pathname, search },
58
+ module: resolveModule(pathname),
59
+ user: safely(config.user, {}),
60
+ apiHeaders: redactHeaders(safely(config.headers, {})),
61
+ app: {
62
+ name: appInfo().name,
63
+ version: appInfo().version,
64
+ environment: appInfo().environment,
65
+ },
66
+ client: {
67
+ userAgent: navigator.userAgent,
68
+ language: navigator.language,
69
+ platform: navigator.platform,
70
+ online: navigator.onLine,
71
+ viewport: {
72
+ width: window.innerWidth,
73
+ height: window.innerHeight,
74
+ dpr: window.devicePixelRatio,
75
+ },
76
+ screen: { width: window.screen.width, height: window.screen.height },
77
+ },
78
+ performance: readPerformance(),
79
+ extra: safely(config.extra, {}),
80
+ };
81
+ }
package/src/embed.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Script-tag build. For an app with no bundler — configure from a global and
3
+ * arm on load, so adding the reporter is two lines of HTML.
4
+ *
5
+ * <script src="debug-capture.js"
6
+ * data-host="https://support.example.com"
7
+ * data-realm-key="pk_live_…"
8
+ * data-app-name="ระบบสารบรรณ"
9
+ * data-app-version="1.4.2"
10
+ * data-environment="production"></script>
11
+ */
12
+ import { configureDebugCapture } from "./config";
13
+ import { installDebugCapture } from "./install";
14
+ import { launch } from "./reporter";
15
+
16
+ const script = document.currentScript as HTMLScriptElement | null;
17
+ const data = script?.dataset ?? {};
18
+
19
+ if (data.host && data.realmKey) {
20
+ configureDebugCapture({
21
+ host: data.host,
22
+ realmKey: data.realmKey,
23
+ app: {
24
+ name: data.appName ?? document.title ?? "app",
25
+ version: data.appVersion ?? "0.0.0",
26
+ environment: data.environment ?? "production",
27
+ },
28
+ locale: data.locale === "en" ? "en" : "th",
29
+ });
30
+ installDebugCapture();
31
+ }
32
+
33
+ // The only handle a no-bundler page gets. Call it from a button's onclick —
34
+ // directly, so the click is still the active gesture when screen capture asks.
35
+ (window as unknown as { lwReportProblem: typeof launch }).lwReportProblem = launch;
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @lightworkai.official/debug-capture
3
+ *
4
+ * Rolling capture of what a user was doing — network, console, actions — plus a
5
+ * screenshot they can draw on, filed as a ticket on a Lightwork Support host.
6
+ *
7
+ * Extracted from an in-app reporter built for a single app. What changed is
8
+ * everything that made it one app's: the user store, the env module, the API
9
+ * client and a hard-coded Thai module vocabulary are all config now. Nothing in
10
+ * this package is confidential, and the realm key it takes is public by design.
11
+ */
12
+ export { configureDebugCapture, isConfigured, getConfig, appInfo } from "./config";
13
+ export type { DebugCaptureConfig, CaptureOptions, RedactOptions } from "./config";
14
+
15
+ export { installDebugCapture } from "./install";
16
+ export type { InstallOptions } from "./install";
17
+
18
+ export { launch, onLaunch, onCrash, emitCrash } from "./reporter";
19
+
20
+ export { buildBundle, bundleToMarkdown, hasCause, bundleFileName, toCurl } from "./bundle";
21
+ export { submitBundle } from "./submit";
22
+ export type { FiledTicket } from "./submit";
23
+ export { errorSignature } from "./signature";
24
+ export { trimToBudget, BUNDLE_BUDGET_BYTES } from "./budget";
25
+ export { captureScreen } from "./screenshot";
26
+
27
+ export { getNetworkLog } from "./capture/networkBuffer";
28
+ export { getConsoleLog } from "./capture/consoleBuffer";
29
+ export { getActionTrail, actionCountSuffix } from "./capture/actionTrail";
30
+ export { correlateActionTrail, isQuietStep, isFailedRequest, requestPath, formatDuration } from "./capture/stepCorrelation";
31
+ export type { StepActivity } from "./capture/stepCorrelation";
32
+ export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./capture/redact";
33
+
34
+ // NOT the element class: exporting it by name is what forced `extends
35
+ // HTMLElement` to evaluate on import, and took down every server-rendered app.
36
+ // `defineReporterElement()` builds it in the browser, where it exists.
37
+ export { defineReporterElement, setErroredQueriesSource, REPORTER_TAG } from "./ui/element";
38
+ export { showToast } from "./ui/toast";
39
+
40
+ /*
41
+ * The annotator ENGINE, shared by every framework package.
42
+ *
43
+ * A canvas is imperative — there is no declarative way to draw on one — so this
44
+ * stays here rather than being rewritten three times. What each framework wraps
45
+ * around it is the chrome: a dialog, a toolbar, two buttons.
46
+ *
47
+ * Its labels come from the reporter dialog's own string bag, because they are
48
+ * the same words: กรอบ, ลูกศร, ไฮไลต์, ข้อความ.
49
+ */
50
+ export { createAnnotator } from "./ui/annotator";
51
+ export type { Annotator, AnnotatorTool } from "./ui/annotator";
52
+ export { strings as reporterStrings } from "./ui/strings";
53
+ export type { Strings as ReporterStrings } from "./ui/strings";
54
+ export {
55
+ listMyTickets,
56
+ getMyTicket,
57
+ getMyTicketByNumber,
58
+ getMyTicketHistory,
59
+ replyToTicket,
60
+ editMyMessage,
61
+ reopenMyTicket,
62
+ uploadInlineImage,
63
+ getAttachmentUrl,
64
+ fetchRealmConfig,
65
+ TicketApiError,
66
+ NoIdentityError,
67
+ } from "./mytickets/api";
68
+ export type {
69
+ MyTicket,
70
+ MyTicketListItem,
71
+ MyTicketDetail,
72
+ TicketMessage,
73
+ TicketAttachment,
74
+ StatusEvent,
75
+ StatusColumn,
76
+ RealmConfig,
77
+ } from "./mytickets/api";
78
+ export {
79
+ ticketKey,
80
+ formatDateTime,
81
+ statusMaps,
82
+ hasSolution,
83
+ hasTeamReply,
84
+ responseRank,
85
+ compareBy,
86
+ matchesKeyword,
87
+ pageOf,
88
+ pageCount,
89
+ PAGE_SIZE,
90
+ } from "./mytickets/format";
91
+ export type { StatusMaps, Sort, SortKey } from "./mytickets/format";
92
+ export {
93
+ toPlainText,
94
+ looksLikeHtml,
95
+ renderBody,
96
+ cleanHtml,
97
+ htmlIsEmpty,
98
+ ALLOWED_TAGS,
99
+ ALLOWED_ATTR,
100
+ isAllowedHref,
101
+ isAllowedImageSrc,
102
+ } from "./mytickets/sanitize";
103
+ export { NOTE_TOOLBAR, FULL_TOOLBAR } from "./mytickets/toolbar";
104
+ export type { EditorTool } from "./mytickets/toolbar";
105
+ export { ticketStrings } from "./mytickets/strings";
106
+ export type { TicketStrings } from "./mytickets/strings";
107
+ export { statusTone, STATUS_TONES } from "./mytickets/format";
108
+
109
+ export type * from "./types";