@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.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 (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +8 -1
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +7 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +3 -1
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -11
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +123 -0
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,681 @@
1
+ /**
2
+ * What every platform's debug context capture shares, with no DOM and no React Native: errors folded into
3
+ * counts, failed, slow and pending requests, the console's ordinary lines, going offline or to the
4
+ * background, what the host knows, the cache and stores, and turning all of it into the file. The web page
5
+ * (`./debug-context.ts`) and native apps (`@devaccel-labs/app-comments-native`) feed it from their own hooks.
6
+ *
7
+ * The hot path (`requestStarted`, `consoleCall`) does bookkeeping only. Reading a body, reading headers and
8
+ * redacting happen for failed or slow requests, after the app already has its answer.
9
+ */
10
+ import {
11
+ fitDebugContext,
12
+ newReportId,
13
+ sanitizeDebugContext,
14
+ type DebugContextFile,
15
+ type DebugError,
16
+ type DebugErrorKind,
17
+ type DebugRequest,
18
+ type DebugSignal,
19
+ type DebugStep,
20
+ type HostBuild,
21
+ type HostContext,
22
+ type LifecycleEvent,
23
+ type ResolvedDebugContext,
24
+ } from "./debug-context-model.js";
25
+ import {
26
+ idHeaderNames,
27
+ newTally,
28
+ scrubId,
29
+ scrubText,
30
+ scrubUrl,
31
+ scrubValue,
32
+ type JsonBag,
33
+ type RedactionTally,
34
+ } from "./debug-context-redact.js";
35
+ import type { ReplayEvent } from "./replay-model.js";
36
+
37
+ const MAX_ERRORS = 30;
38
+ const MAX_FAILED = 30;
39
+ const MAX_SLOW = 20;
40
+ const MAX_PENDING = 50;
41
+ const MAX_LOGS = 50;
42
+ const MAX_LIFECYCLE = 30;
43
+ const STEPS_WINDOW_MS = 180_000;
44
+ const MAX_STEPS = 150;
45
+ export const EXCERPT_CHARS = 2048;
46
+ export const BODY_CHARS = 16_384;
47
+ export const GRAPHQL_READ_CHARS = 65_536;
48
+ const HOST_CONTEXT_TIMEOUT_MS = 300;
49
+
50
+ /** Error messages every team ignores: browser noise, not the app's. */
51
+ const NOISE = /^(?:ResizeObserver loop|Script error\.?$|Non-Error promise rejection captured)/;
52
+ /** Frames from browser extensions and from the toolbar itself. */
53
+ export const FOREIGN_FRAME = /(?:chrome|moz|safari(?:-web)?)-extension:\/\/|\/v1\/toolbar\//;
54
+ /** Response types whose text is worth an excerpt. */
55
+ export const TEXTUAL = /json|text|xml|javascript|graphql|x-www-form-urlencoded/i;
56
+
57
+ export type DebugCaptureStats = { calls: number; ms: number };
58
+
59
+ function stackLines(stack: string | undefined, message: string): string[] {
60
+ if (!stack) return [];
61
+ const out: string[] = [];
62
+ for (const raw of stack.split("\n")) {
63
+ const line = raw.trim();
64
+ if (!line || line === message || line.endsWith(`: ${message}`) || line === `Error: ${message}`)
65
+ continue;
66
+ if (FOREIGN_FRAME.test(line)) continue;
67
+ // Query strings in frame URLs can carry tokens (signed asset URLs); the path and position are enough.
68
+ out.push(scrubText(line.replace(/\?[^\s:)]*/g, "").replace(/^at\s+/, ""), 240));
69
+ if (out.length >= 12) break;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ export function errorOf(value: unknown): { message: string; stack?: string } {
75
+ if (value instanceof Error)
76
+ return {
77
+ message: `${value.name && value.name !== "Error" ? `${value.name}: ` : ""}${value.message}`,
78
+ ...(value.stack ? { stack: value.stack } : {}),
79
+ };
80
+ if (typeof value === "string") return { message: value };
81
+ if (
82
+ value &&
83
+ typeof value === "object" &&
84
+ typeof (value as { message?: unknown }).message === "string"
85
+ ) {
86
+ const shaped = value as { message: string; stack?: unknown };
87
+ return {
88
+ message: shaped.message,
89
+ ...(typeof shaped.stack === "string" ? { stack: shaped.stack } : {}),
90
+ };
91
+ }
92
+ return { message: value === undefined ? "undefined" : String(value) };
93
+ }
94
+
95
+ /** A console call's arguments as one line, with the stack of an `Error` among them. */
96
+ export function argText(args: readonly unknown[]): { message: string; stack?: string } {
97
+ let stack: string | undefined;
98
+ const parts: string[] = [];
99
+ for (const arg of args) {
100
+ if (arg instanceof Error) {
101
+ parts.push(errorOf(arg).message);
102
+ stack ??= arg.stack;
103
+ } else if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") {
104
+ parts.push(String(arg));
105
+ } else if (arg && typeof arg === "object") {
106
+ // Small and redacted: a console line's object is often the most useful part, and also the riskiest.
107
+ try {
108
+ parts.push(JSON.stringify(scrubValue(arg, { depth: 2, entries: 10, text: 100 })));
109
+ } catch {
110
+ parts.push("[object]");
111
+ }
112
+ }
113
+ if (parts.join(" ").length > 2000) break;
114
+ }
115
+ return { message: parts.join(" ") || "error", ...(stack ? { stack } : {}) };
116
+ }
117
+
118
+ function matchesAny(url: string, patterns: readonly (string | RegExp)[] | undefined) {
119
+ if (!patterns?.length) return false;
120
+ return patterns.some((pattern) =>
121
+ typeof pattern === "string" ? url.startsWith(pattern) : pattern.test(url),
122
+ );
123
+ }
124
+
125
+ /** An excerpt with JSON keys that look sensitive masked; other text scrubbed as text. */
126
+ export function excerptOf(text: string, max: number, tally: RedactionTally): string {
127
+ const trimmed = text.trim();
128
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
129
+ try {
130
+ return scrubText(
131
+ JSON.stringify(
132
+ scrubValue(JSON.parse(trimmed), { depth: 5, entries: 30, text: 300, tally }),
133
+ ),
134
+ max,
135
+ tally,
136
+ );
137
+ } catch {
138
+ // Cut mid-way, so not parseable: fall through to text.
139
+ }
140
+ }
141
+ return scrubText(trimmed, max, tally);
142
+ }
143
+
144
+ export function graphqlErrors(text: string, tally: RedactionTally): string[] {
145
+ try {
146
+ const parsed = JSON.parse(text) as
147
+ | { errors?: Array<{ message?: unknown }> }
148
+ | Array<{ errors?: Array<{ message?: unknown }> }>;
149
+ const batches = Array.isArray(parsed) ? parsed : [parsed];
150
+ const out: string[] = [];
151
+ for (const batch of batches) {
152
+ for (const error of batch?.errors ?? []) {
153
+ if (typeof error?.message === "string") out.push(scrubText(error.message, 300, tally));
154
+ if (out.length >= 5) return out;
155
+ }
156
+ }
157
+ return out;
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ function graphqlOperation(body: unknown): string | undefined {
164
+ if (typeof body !== "string" || body.length > 200_000) return undefined;
165
+ const named = /"operationName"\s*:\s*"([A-Za-z0-9_]{1,80})"/.exec(body)?.[1];
166
+ if (named) return named;
167
+ return /\b(?:query|mutation|subscription)\s+([A-Za-z0-9_]{1,80})/.exec(body)?.[1];
168
+ }
169
+
170
+ export function within<T>(promise: Promise<T> | T, ms: number): Promise<T | undefined> {
171
+ let timer: ReturnType<typeof setTimeout> | undefined;
172
+ return Promise.race([
173
+ Promise.resolve(promise).catch(() => undefined),
174
+ new Promise<undefined>((resolve) => {
175
+ timer = setTimeout(() => resolve(undefined), ms);
176
+ }),
177
+ ]).finally(() => clearTimeout(timer));
178
+ }
179
+
180
+ function stepOf(event: ReplayEvent, reportAt: number): DebugStep {
181
+ const t = event.t - reportAt;
182
+ switch (event.type) {
183
+ case "navigate":
184
+ return { t, type: "navigate", path: event.path };
185
+ case "click":
186
+ return { t, type: "click", label: event.label };
187
+ case "input":
188
+ return {
189
+ t,
190
+ type: "input",
191
+ label: event.label,
192
+ redacted: event.redacted,
193
+ ...(event.value ? { value: event.value } : {}),
194
+ };
195
+ case "console":
196
+ return { t, type: "console", level: event.level, message: event.message };
197
+ case "network":
198
+ return { t, type: "network", method: event.method, path: event.path, status: event.status };
199
+ case "say":
200
+ return { t, type: "say", text: event.text };
201
+ default: {
202
+ const never: never = event;
203
+ return never;
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Replaces `host[key]` and returns the restore. The restore puts the original back only while ours is still
210
+ * installed, so a patch someone added after ours (Sentry, another tool) is never removed.
211
+ */
212
+ export function patch<T extends object, K extends keyof T>(host: T, key: K, next: T[K]) {
213
+ const original = host[key];
214
+ host[key] = next;
215
+ return () => {
216
+ if (host[key] === next) host[key] = original;
217
+ };
218
+ }
219
+
220
+ /** How a platform reads a finished request: headers now, text only when asked (and only once). */
221
+ export type RequestReaders = {
222
+ header: (name: string) => string | null;
223
+ /** The response's text, up to `max` characters. */
224
+ text?: (max: number) => Promise<string | null>;
225
+ /** The request's body as the app passed it. Only a string is ever kept. */
226
+ requestBody?: unknown;
227
+ /** The text could be read for a successful GraphQL request too (a 200 that holds errors). */
228
+ graphqlReadable?: boolean;
229
+ };
230
+
231
+ type Pending = { at: number; method: string; url: string };
232
+ type Recorded = DebugRequest & { at: number };
233
+ type ErrorEntry = Omit<DebugError, "t"> & { at: number };
234
+
235
+ export type DebugCoreEnv = {
236
+ /** The address relative URLs are read against and same-origin URLs are shortened to. */
237
+ pageUrl: () => string | undefined;
238
+ /** Requests that are the toolbar's own (the comment service): never recorded. */
239
+ ignoreUrl?: (url: string) => boolean;
240
+ /** A high-resolution clock for `stats`. Default `Date.now`. */
241
+ clock?: () => number;
242
+ };
243
+
244
+ /** What every platform's file says the same way. */
245
+ export type CoreSnapshot = Pick<
246
+ DebugContextFile,
247
+ | "errors"
248
+ | "logs"
249
+ | "network"
250
+ | "lifecycle"
251
+ | "steps"
252
+ | "queries"
253
+ | "stores"
254
+ | "realtime"
255
+ | "identity"
256
+ | "flags"
257
+ | "extra"
258
+ | "build"
259
+ > & {
260
+ join: Pick<DebugContextFile["join"], "traceId" | "sessionId" | "requestIds">;
261
+ routePattern?: string;
262
+ };
263
+
264
+ export function createDebugCore(resolved: ResolvedDebugContext, env: DebugCoreEnv) {
265
+ const { signals, options, slowMs, audience } = resolved;
266
+ const ignoreUrl = env.ignoreUrl ?? (() => false);
267
+ const clock = env.clock ?? (() => Date.now());
268
+ const idNames = idHeaderNames(options.idHeaders ?? []);
269
+ const errors = new Map<string, ErrorEntry>();
270
+ const logs: Array<{ at: number; level: string; message: string }> = [];
271
+ const failed: Recorded[] = [];
272
+ const slow: Recorded[] = [];
273
+ const pending = new Map<number, Pending>();
274
+ const lifecycle: Array<{ at: number; event: LifecycleEvent }> = [];
275
+ const tally = newTally();
276
+ const stats: DebugCaptureStats = { calls: 0, ms: 0 };
277
+ let aborted = 0;
278
+ let nextId = 0;
279
+ let marked: number | null = null;
280
+ let markedPending: Pending[] | null = null;
281
+ let stopped = false;
282
+
283
+ /** Times our own bookkeeping, never the app's call. */
284
+ const timed = <T>(task: () => T): T => {
285
+ const started = clock();
286
+ try {
287
+ return task();
288
+ } finally {
289
+ stats.calls += 1;
290
+ stats.ms += clock() - started;
291
+ }
292
+ };
293
+
294
+ const pushError = (
295
+ kind: DebugErrorKind,
296
+ level: "error" | "warn",
297
+ rawMessage: string,
298
+ stack?: string,
299
+ source?: string,
300
+ componentStack?: string[],
301
+ ) => {
302
+ if (stopped || NOISE.test(rawMessage)) return;
303
+ const lines = stackLines(stack, rawMessage);
304
+ const message = scrubText(rawMessage, 500, tally);
305
+ const key = `${kind}|${message}|${lines[0] ?? source ?? ""}`;
306
+ const existing = errors.get(key);
307
+ const at = Date.now();
308
+ if (existing) {
309
+ existing.count += 1;
310
+ existing.at = at;
311
+ errors.delete(key);
312
+ errors.set(key, existing);
313
+ return;
314
+ }
315
+ if (errors.size >= MAX_ERRORS) {
316
+ const oldest = errors.keys().next().value;
317
+ if (oldest !== undefined) errors.delete(oldest);
318
+ tally.dropped += 1;
319
+ }
320
+ errors.set(key, {
321
+ at,
322
+ kind,
323
+ level,
324
+ message,
325
+ ...(lines.length ? { stack: lines } : {}),
326
+ ...(source ? { source: scrubText(source.replace(/\?[^\s:]*/g, ""), 240, tally) } : {}),
327
+ ...(componentStack?.length ? { componentStack } : {}),
328
+ count: 1,
329
+ });
330
+ };
331
+
332
+ const record = (list: Recorded[], max: number, entry: Recorded) => {
333
+ list.push(entry);
334
+ if (list.length > max) {
335
+ list.shift();
336
+ tally.dropped += 1;
337
+ }
338
+ };
339
+
340
+ const consoleCall = (
341
+ level: "error" | "warn" | "log" | "info" | "debug",
342
+ args: readonly unknown[],
343
+ ) => {
344
+ if (stopped) return;
345
+ if (level === "error" || level === "warn") {
346
+ if (!signals.errors) return;
347
+ const { message, stack } = argText(args);
348
+ pushError("console", level, message, stack);
349
+ } else if (signals.consoleLog) {
350
+ logs.push({ at: Date.now(), level, message: scrubText(argText(args).message, 300, tally) });
351
+ if (logs.length > MAX_LOGS) logs.shift();
352
+ }
353
+ };
354
+
355
+ const requestStarted = (method: string, url: string): number | null => {
356
+ if (stopped || !signals.network || ignoreUrl(url)) return null;
357
+ const id = ++nextId;
358
+ if (pending.size < MAX_PENDING)
359
+ pending.set(id, { at: Date.now(), method: method.toUpperCase(), url });
360
+ return id;
361
+ };
362
+
363
+ const requestAborted = (id: number) => {
364
+ pending.delete(id);
365
+ aborted += 1;
366
+ };
367
+
368
+ const requestFinished = (id: number, status: number, readers: RequestReaders) => {
369
+ const entry = pending.get(id);
370
+ pending.delete(id);
371
+ if (!entry || stopped) return;
372
+ const ms = Math.round(Date.now() - entry.at);
373
+ const isFailed = status === 0 || status >= 400;
374
+ const isSlow = !isFailed && ms >= slowMs;
375
+ const graphqlCandidate =
376
+ !isFailed && readers.graphqlReadable === true && /graphql/i.test(entry.url);
377
+ if (!isFailed && !isSlow && !graphqlCandidate) return;
378
+ const request: Recorded = {
379
+ at: entry.at,
380
+ t: 0,
381
+ method: entry.method,
382
+ url: scrubUrl(entry.url, 400, env.pageUrl(), tally),
383
+ status,
384
+ ms,
385
+ };
386
+ const contentType = readers.header("content-type");
387
+ if (contentType) request.contentType = contentType.split(";")[0]!.trim().slice(0, 80);
388
+ const length = Number(readers.header("content-length"));
389
+ if (Number.isFinite(length) && length > 0) request.bytes = length;
390
+ const ids: Record<string, string> = {};
391
+ for (const name of idNames) {
392
+ const value = readers.header(name);
393
+ if (value) ids[name] = scrubId(value);
394
+ }
395
+ if (Object.keys(ids).length) request.ids = ids;
396
+ const operation = graphqlOperation(readers.requestBody);
397
+ const bodies = signals.bodies && matchesAny(entry.url, options.bodyUrls);
398
+ if (bodies && typeof readers.requestBody === "string")
399
+ request.requestBody = excerptOf(readers.requestBody, BODY_CHARS, tally);
400
+ if (isFailed || isSlow)
401
+ record(isFailed ? failed : slow, isFailed ? MAX_FAILED : MAX_SLOW, request);
402
+ const textual = !contentType || TEXTUAL.test(contentType);
403
+ if (isFailed && readers.text && textual && (signals.responseExcerpts || bodies)) {
404
+ void readers.text(bodies ? BODY_CHARS : EXCERPT_CHARS).then((text) => {
405
+ if (!text) return;
406
+ if (signals.responseExcerpts)
407
+ request.excerpt = excerptOf(text.slice(0, EXCERPT_CHARS), EXCERPT_CHARS, tally);
408
+ if (bodies) request.responseBody = excerptOf(text, BODY_CHARS, tally);
409
+ if (operation) {
410
+ const found = graphqlErrors(text, tally);
411
+ if (found.length) request.graphql = { operation, errors: found };
412
+ }
413
+ });
414
+ }
415
+ if (graphqlCandidate && readers.text && contentType && /json/i.test(contentType)) {
416
+ void readers.text(GRAPHQL_READ_CHARS).then((text) => {
417
+ const found = text && text.length < GRAPHQL_READ_CHARS ? graphqlErrors(text, tally) : [];
418
+ if (found.length) {
419
+ request.graphql = { ...(operation ? { operation } : {}), errors: found };
420
+ record(failed, MAX_FAILED, request);
421
+ }
422
+ });
423
+ }
424
+ };
425
+
426
+ const noteLifecycle = (event: LifecycleEvent) => {
427
+ if (stopped || !signals.lifecycle) return;
428
+ lifecycle.push({ at: Date.now(), event });
429
+ if (lifecycle.length > MAX_LIFECYCLE) lifecycle.shift();
430
+ };
431
+
432
+ const reportError = (error: unknown, componentStack?: string | null) => {
433
+ if (!signals.componentStacks || stopped) return;
434
+ timed(() => {
435
+ const { message, stack } = errorOf(error);
436
+ const components = (componentStack ?? "")
437
+ .split("\n")
438
+ .map((line) =>
439
+ line
440
+ .trim()
441
+ .replace(/^at\s+/, "")
442
+ .split(" (")[0]!
443
+ .trim(),
444
+ )
445
+ .filter(Boolean)
446
+ .slice(0, 12)
447
+ .map((line) => scrubText(line, 120, tally));
448
+ pushError("boundary", "error", message, stack, undefined, components);
449
+ });
450
+ };
451
+
452
+ const hostContext = async (): Promise<HostContext | undefined> => {
453
+ if (!options.getContext) return undefined;
454
+ if (!signals.host && !signals.build && !signals.realtime && !signals.staleBuild)
455
+ return undefined;
456
+ try {
457
+ const value = await within(options.getContext(), HOST_CONTEXT_TIMEOUT_MS);
458
+ return value && typeof value === "object" ? value : undefined;
459
+ } catch {
460
+ return undefined;
461
+ }
462
+ };
463
+
464
+ /** The sections every platform writes the same way, for a report taken at `reportAt`. */
465
+ const shared = async (
466
+ reportAt: number,
467
+ steps: readonly ReplayEvent[] | undefined,
468
+ fileTally: RedactionTally,
469
+ toolbarBuild?: string,
470
+ ): Promise<CoreSnapshot> => {
471
+ const on = (signal: DebugSignal) => signals[signal];
472
+ const relative = (at: number) => Math.round(at - reportAt);
473
+ const host = await hostContext();
474
+ const out: CoreSnapshot = { join: {} };
475
+
476
+ if (on("build") || on("staleBuild")) {
477
+ const merged: HostBuild = { ...options.build, ...host?.build };
478
+ const current = merged.commit ?? merged.version;
479
+ out.build = {
480
+ ...(Object.keys(merged).length && on("build") ? { host: merged } : {}),
481
+ ...(toolbarBuild ? { toolbar: toolbarBuild } : {}),
482
+ ...(on("staleBuild") && merged.deployed && current
483
+ ? { staleHost: merged.deployed !== current }
484
+ : {}),
485
+ };
486
+ }
487
+ if (on("host") && host) {
488
+ const identity = {
489
+ ...(host.user?.id ? { userId: String(host.user.id) } : {}),
490
+ ...(host.user?.role ? { role: String(host.user.role) } : {}),
491
+ ...(host.user?.plan ? { plan: String(host.user.plan) } : {}),
492
+ ...(host.user?.locale ? { locale: String(host.user.locale) } : {}),
493
+ ...(host.org?.id ? { orgId: String(host.org.id) } : {}),
494
+ ...(host.org?.name ? { orgName: String(host.org.name) } : {}),
495
+ };
496
+ if (Object.keys(identity).length) out.identity = identity;
497
+ if (host.flags) out.flags = host.flags;
498
+ if (host.extra) out.extra = host.extra;
499
+ if (host.traceId) out.join.traceId = String(host.traceId);
500
+ if (host.sessionId) out.join.sessionId = String(host.sessionId);
501
+ if (typeof host.routePattern === "string")
502
+ out.routePattern = scrubText(host.routePattern, 200, fileTally);
503
+ }
504
+ if (on("realtime") && host?.realtime) {
505
+ const rt = host.realtime;
506
+ out.realtime = {
507
+ ...(rt.status ? { status: rt.status } : {}),
508
+ ...(typeof rt.lastMessageAt === "number"
509
+ ? { lastMessageAgoMs: Math.max(0, reportAt - rt.lastMessageAt) }
510
+ : {}),
511
+ ...(typeof rt.reconnects === "number" ? { reconnects: rt.reconnects } : {}),
512
+ ...(rt.lastClose ? { lastClose: rt.lastClose } : {}),
513
+ };
514
+ }
515
+ if (errors.size)
516
+ out.errors = [...errors.values()].map(({ at, ...rest }) => ({ ...rest, t: relative(at) }));
517
+ if (logs.length) out.logs = logs.map(({ at, ...rest }) => ({ ...rest, t: relative(at) }));
518
+ if (on("network")) {
519
+ const still = markedPending ?? [...pending.values()];
520
+ const strip = ({ at, ...rest }: Recorded): DebugRequest => ({ ...rest, t: relative(at) });
521
+ out.network = {
522
+ failed: failed.map(strip),
523
+ slow: slow.map(strip),
524
+ pending: still.slice(-20).map((entry) => ({
525
+ t: relative(entry.at),
526
+ method: entry.method,
527
+ url: scrubUrl(entry.url, 400, env.pageUrl(), fileTally),
528
+ status: 0,
529
+ })),
530
+ aborted,
531
+ };
532
+ const requestIds = [
533
+ ...new Set([...failed, ...slow].flatMap((request) => Object.values(request.ids ?? {}))),
534
+ ].slice(0, 30);
535
+ if (requestIds.length) out.join.requestIds = requestIds;
536
+ }
537
+ if (on("steps") && steps?.length) {
538
+ out.steps = steps
539
+ .filter((step) => step.t >= reportAt - STEPS_WINDOW_MS && step.t <= reportAt + 1000)
540
+ .slice(-MAX_STEPS)
541
+ .map((step) => stepOf(step, reportAt));
542
+ }
543
+ if (on("lifecycle") && lifecycle.length)
544
+ out.lifecycle = lifecycle.map(({ at, event }) => ({ t: relative(at), event }));
545
+ if (on("queries") && options.queries) {
546
+ try {
547
+ out.queries = options
548
+ .queries()
549
+ .slice(0, 50)
550
+ .map((query) => {
551
+ const error =
552
+ query.error === undefined || query.error === null
553
+ ? undefined
554
+ : errorOf(query.error).message;
555
+ return {
556
+ key: scrubText(
557
+ JSON.stringify(
558
+ scrubValue(query.key, { depth: 3, entries: 10, text: 80, tally: fileTally }),
559
+ ) ?? "",
560
+ 200,
561
+ fileTally,
562
+ ),
563
+ ...(query.status ? { status: String(query.status) } : {}),
564
+ ...(query.fetchStatus ? { fetchStatus: String(query.fetchStatus) } : {}),
565
+ ...(error ? { error: scrubText(error, 300, fileTally) } : {}),
566
+ ...(typeof query.updatedAt === "number" && query.updatedAt > 0
567
+ ? { updatedAgoMs: Math.max(0, reportAt - query.updatedAt) }
568
+ : {}),
569
+ ...(typeof query.observers === "number" ? { observers: query.observers } : {}),
570
+ };
571
+ });
572
+ } catch {
573
+ fileTally.dropped += 1;
574
+ }
575
+ }
576
+ if (on("stores") && options.stores) {
577
+ const stores: JsonBag = {};
578
+ for (const [name, read] of Object.entries(options.stores).slice(0, 10)) {
579
+ try {
580
+ stores[name] = scrubValue(read(), {
581
+ depth: on("storeValues") ? 6 : 4,
582
+ entries: 50,
583
+ text: 300,
584
+ shapeOnly: !on("storeValues"),
585
+ tally: fileTally,
586
+ });
587
+ } catch {
588
+ fileTally.dropped += 1;
589
+ }
590
+ }
591
+ if (Object.keys(stores).length) out.stores = stores;
592
+ }
593
+ return out;
594
+ };
595
+
596
+ /**
597
+ * The report: a new id, the host told it, the host's `beforeCapture` (fail closed), redacted again and
598
+ * bounded. `build` adds the platform's own sections to the shared ones.
599
+ */
600
+ const finish = (file: DebugContextFile, fileTally: RedactionTally): DebugContextFile | null => {
601
+ file.redaction.masked = tally.masked + fileTally.masked;
602
+ file.redaction.dropped = tally.dropped + fileTally.dropped;
603
+ try {
604
+ options.onReport?.(file.join.reportId);
605
+ } catch {
606
+ // The host's logger failing must not lose the report.
607
+ }
608
+ let final: DebugContextFile | null = file;
609
+ if (options.beforeCapture) {
610
+ try {
611
+ final = options.beforeCapture(file);
612
+ } catch {
613
+ // Fail closed: a sanitizer that throws sends nothing.
614
+ return null;
615
+ }
616
+ }
617
+ if (!final) return null;
618
+ const clean = sanitizeDebugContext(final);
619
+ return clean ? fitDebugContext(clean) : null;
620
+ };
621
+
622
+ /** A file with the fields every report has, for `reportAt`. */
623
+ const skeletonFile = (
624
+ reportAt: number,
625
+ platform: DebugContextFile["platform"],
626
+ ): DebugContextFile => ({
627
+ v: 1,
628
+ kind: "debug-context",
629
+ capturedAt: new Date(reportAt).toISOString(),
630
+ audience,
631
+ platform,
632
+ join: { reportId: newReportId() },
633
+ signals: (Object.keys(signals) as DebugSignal[]).filter((signal) => signals[signal]),
634
+ redaction: { rules: 1, masked: 0, dropped: 0, truncated: false },
635
+ });
636
+
637
+ return {
638
+ signals,
639
+ options,
640
+ tally,
641
+ timed,
642
+ pushError,
643
+ consoleCall,
644
+ requestStarted,
645
+ requestFinished,
646
+ requestAborted,
647
+ noteLifecycle,
648
+ reportError,
649
+ shared,
650
+ finish,
651
+ skeletonFile,
652
+ get stopped() {
653
+ return stopped;
654
+ },
655
+ mark() {
656
+ marked = Date.now();
657
+ markedPending = [...pending.values()];
658
+ },
659
+ /** The report's moment: the mark, or now. Clears the mark. */
660
+ takeMark(): number {
661
+ const at = marked ?? Date.now();
662
+ return at;
663
+ },
664
+ clearMark() {
665
+ marked = null;
666
+ markedPending = null;
667
+ },
668
+ stats: (): DebugCaptureStats => ({ ...stats }),
669
+ stop() {
670
+ stopped = true;
671
+ errors.clear();
672
+ failed.length = 0;
673
+ slow.length = 0;
674
+ pending.clear();
675
+ logs.length = 0;
676
+ lifecycle.length = 0;
677
+ },
678
+ };
679
+ }
680
+
681
+ export type DebugCore = ReturnType<typeof createDebugCore>;