返回 DeepSeek-Reasonix
frontendDiagnostics.ts
根目录 / desktop / frontend / src / lib / frontendDiagnostics.ts
1 /**
2 * Privacy-preserving frontend interaction recorder.
3 *
4 * This is deliberately a local, opt-in recorder. It captures timing and
5 * geometry, not user text, transcript content, paths, stable ids, or input
6 * values. The recorder is inert until a test-build user starts it, so the
7 * ordinary desktop path has no listeners, timers, or allocation overhead.
8 */
9
10 import { notifyFrontendDiagnosticStart, setFrontendDiagnosticSink } from "./frontendDiagnosticBridge";
11 import type { FrontendDiagnosticFields } from "./frontendDiagnosticBridge";
12
13 export { isFrontendDiagnosticsBuild } from "./frontendDiagnosticsBuild";
14
15 export const FRONTEND_DIAGNOSTIC_SCHEMA_VERSION = 2;
16 export const MAX_FRONTEND_DIAGNOSTIC_EVENTS = 16_384;
17 export const FRONTEND_DIAGNOSTIC_DURATION_MS = 120_000;
18
19 /** Geometry replay fixtures produced by v1 remain valid inputs. New captures
20 * always write v2. */
21 export function isSupportedFrontendDiagnosticSchemaVersion(value: unknown): value is 1 | 2 {
22 return value === 1 || value === FRONTEND_DIAGNOSTIC_SCHEMA_VERSION;
23 }
24
25 export type FrontendDiagnosticStatus = "idle" | "recording" | "stopped";
26
27 export type FrontendDiagnosticEvent = {
28 t: number;
29 type: string;
30 source?: string;
31 eventSource?: string;
32 action?: string;
33 target?: string;
34 targetRole?: string;
35 targetTag?: string;
36 keyClass?: string;
37 pointerType?: string;
38 inputType?: string;
39 visibility?: string;
40 phase?: string;
41 reason?: string;
42 rejectedReason?: string;
43 result?: string;
44 status?: string;
45 mode?: string;
46 previousMode?: string;
47 owner?: string;
48 writeKind?: string;
49 rowKind?: string;
50 layoutVersion?: string;
51 layoutVariant?: string;
52 estimateSource?: string;
53 foldState?: string;
54 state?: string;
55 errorName?: string;
56 errorCode?: string;
57 width?: number;
58 height?: number;
59 x?: number;
60 y?: number;
61 deltaX?: number;
62 deltaY?: number;
63 targetTop?: number;
64 targetIndex?: number | "LAST";
65 listHeight?: number;
66 durationMs?: number;
67 itemCount?: number;
68 queueDepth?: number;
69 activeRequests?: number;
70 scrollTop?: number;
71 scrollHeight?: number;
72 clientHeight?: number;
73 bottomDistance?: number;
74 mountedRows?: number;
75 totalRows?: number;
76 firstVisibleIndex?: number;
77 firstVisibleTop?: number;
78 rowIndex?: number;
79 estimatedSize?: number;
80 previousSize?: number;
81 measuredSize?: number;
82 sizeDelta?: number;
83 relativeError?: number;
84 sequence?: number;
85 generation?: number;
86 surfaceGeneration?: number;
87 ownershipEpoch?: number;
88 geometryRevision?: number;
89 transactionId?: number;
90 footerHeight?: number;
91 viewport?: number;
92 mounted?: number;
93 total?: number;
94 reverseDisplacement?: number;
95 extentDelta?: number;
96 stableFrames?: number;
97 direction?: number;
98 disclosureCount?: number;
99 contentRevision?: number;
100 tabCount?: number;
101 patchCount?: number;
102 workspaceSessions?: number;
103 visibleSessions?: number;
104 hiddenSessions?: number;
105 hiddenByFilter?: number;
106 hiddenByCollapsed?: number;
107 hiddenByTruncation?: number;
108 runtimeSessions?: number;
109 runtimeOnlySessions?: number;
110 recoveryOnlySessions?: number;
111 recoveryCopySessions?: number;
112 recoveryCopies?: number;
113 runningSessions?: number;
114 unreadSessions?: number;
115 pinnedSessions?: number;
116 activeSessions?: number;
117 activeVisibleSessions?: number;
118 folderCount?: number;
119 expandedFolders?: number;
120 showAllFolders?: number;
121 catalogRevision?: number;
122 catalogIndexed?: number;
123 catalogTotal?: number;
124 repairPending?: number;
125 treeRevision?: number;
126 organizationRevision?: number;
127 unloadedSessions?: number;
128 deltaWorkspaceSessions?: number;
129 deltaVisibleSessions?: number;
130 deltaHiddenSessions?: number;
131 deltaRecoveryCopies?: number;
132 deltaRuntimeOnlySessions?: number;
133 hasActiveTab?: boolean;
134 ready?: boolean;
135 running?: boolean;
136 hydrating?: boolean;
137 runtimeTransitioning?: boolean;
138 atBottom?: boolean;
139 scrollable?: boolean;
140 blank?: boolean;
141 readerIntent?: boolean;
142 canClaimTail?: boolean;
143 substantial?: boolean;
144 transient?: boolean;
145 tailCommand?: boolean;
146 isTrusted?: boolean;
147 queryActive?: boolean;
148 timeFilterActive?: boolean;
149 catalogPartial?: boolean;
150 catalogRebuilding?: boolean;
151 directoryState?: string;
152 changeReason?: string;
153 outcome?: string;
154 trigger?: string;
155 scope?: string;
156 variant?: string;
157 timeFilter?: string;
158 button?: number;
159 modifiers?: number;
160 intent?: number;
161 sources?: Array<"footer-resize" | "row-measure" | "data-change" | "viewport-resize" | "fold-change" | "typography-change" | "items-rendered">;
162 };
163
164 export type FrontendDiagnosticAnomaly = {
165 code: "settle-before-paint-ready" | "viewport-older-without-user-input" | "navigation-session-count-changed" | "unknown-scroll-writer" | "target-empty-sequence";
166 intent?: number;
167 count?: number;
168 };
169
170 export type FrontendDiagnosticEnvironment = {
171 buildCommit: string;
172 buildChannel: string;
173 platform: "windows" | "macos" | "linux" | "other";
174 userAgent: string;
175 devicePixelRatio: number;
176 viewportWidth: number;
177 viewportHeight: number;
178 language: string;
179 reducedMotion: boolean;
180 };
181
182 export type FrontendDiagnosticPayload = {
183 schemaVersion: typeof FRONTEND_DIAGNOSTIC_SCHEMA_VERSION;
184 manifest: FrontendDiagnosticEnvironment & { reportId: string; createdAt: string };
185 summary: {
186 durationMs: number;
187 eventCount: number;
188 droppedEventCount: number;
189 markerCount: number;
190 anomalies: FrontendDiagnosticAnomaly[];
191 };
192 events: FrontendDiagnosticEvent[];
193 };
194
195 export type FrontendDiagnosticSnapshot = {
196 status: FrontendDiagnosticStatus;
197 durationMs: number;
198 eventCount: number;
199 droppedEventCount: number;
200 markerCount: number;
201 reportId: string;
202 };
203
204 type EventFields = FrontendDiagnosticFields;
205 type Sample = EventFields | null;
206 type Listener = () => void;
207 type EventTargetListener = { target: EventTarget; type: string; listener: EventListener; options?: AddEventListenerOptions };
208
209 const NUMBER_FIELDS = [
210 "width", "height", "x", "y", "deltaX", "deltaY", "targetTop", "listHeight", "durationMs", "itemCount", "queueDepth", "activeRequests", "scrollTop", "scrollHeight",
211 "clientHeight", "bottomDistance", "mountedRows", "totalRows", "firstVisibleIndex", "firstVisibleTop",
212 "rowIndex", "estimatedSize", "previousSize", "measuredSize", "sizeDelta", "relativeError", "disclosureCount", "contentRevision", "tabCount", "patchCount", "button", "modifiers", "intent",
213 "sequence", "generation", "surfaceGeneration", "ownershipEpoch", "geometryRevision", "transactionId", "footerHeight", "viewport", "mounted", "total", "reverseDisplacement", "extentDelta", "stableFrames", "direction",
214 "workspaceSessions", "visibleSessions", "hiddenSessions", "hiddenByFilter", "hiddenByCollapsed", "hiddenByTruncation", "runtimeSessions", "runtimeOnlySessions", "recoveryOnlySessions", "recoveryCopySessions", "recoveryCopies", "runningSessions", "unreadSessions", "pinnedSessions", "activeSessions", "activeVisibleSessions", "folderCount", "expandedFolders", "showAllFolders", "catalogRevision", "catalogIndexed", "catalogTotal", "repairPending", "treeRevision", "organizationRevision", "unloadedSessions", "deltaWorkspaceSessions", "deltaVisibleSessions", "deltaHiddenSessions", "deltaRecoveryCopies", "deltaRuntimeOnlySessions",
215 ] as const;
216 const BOOLEAN_FIELDS = [
217 "hasActiveTab", "ready", "running", "hydrating", "runtimeTransitioning", "atBottom", "scrollable", "blank", "readerIntent", "canClaimTail", "substantial", "tailCommand", "isTrusted",
218 "queryActive", "timeFilterActive", "catalogPartial", "catalogRebuilding", "transient",
219 ] as const;
220 const STRING_FIELDS = [
221 "source", "eventSource", "action", "target", "targetRole", "targetTag", "keyClass", "pointerType", "inputType", "visibility", "phase",
222 "reason", "rejectedReason", "result", "status", "mode", "previousMode", "owner", "writeKind", "rowKind", "layoutVersion", "layoutVariant", "estimateSource", "foldState", "state", "errorName", "errorCode",
223 "directoryState", "changeReason", "outcome", "trigger", "scope", "variant", "timeFilter",
224 ] as const;
225 const GEOMETRY_SOURCES = new Set([
226 "footer-resize", "row-measure", "data-change", "viewport-resize", "fold-change", "typography-change", "items-rendered",
227 ]);
228
229 export function analyzeFrontendDiagnosticAnomalies(events: readonly FrontendDiagnosticEvent[]): FrontendDiagnosticAnomaly[] {
230 const anomalies: FrontendDiagnosticAnomaly[] = [];
231 const painted = new Set<number>();
232 const navigation = new Map<number, { sessionCount?: number; counts: Set<number>; rows: number[] }>();
233 const knownScrollWriters = new Set([
234 "tail-follow", "recovery", "reader-stability", "jump", "rewind", "jump-bottom", "custom-scrollbar",
235 "selection-edge-scroll", "anchor-compensation", "block-window-prepend",
236 ]);
237 let viewportPermit = 0;
238 let unknownWriters = 0;
239 let unpermittedOlder = 0;
240 for (const event of events) {
241 if (event.type === "history.viewport-permit") viewportPermit = 1;
242 if (event.type === "navigation.begin" && event.intent !== undefined) {
243 navigation.set(event.intent, { counts: new Set(), rows: [] });
244 }
245 if (event.type === "navigation.paint-ready" && event.intent !== undefined) painted.add(event.intent);
246 if (event.type === "navigation.settle" && event.intent !== undefined && event.outcome !== "failed" &&
247 event.outcome !== "cancelled" && event.outcome !== "superseded" && !painted.has(event.intent)) {
248 anomalies.push({ code: "settle-before-paint-ready", intent: event.intent });
249 }
250 if (event.type === "history.older-request" && event.trigger === "viewport-user") {
251 if (viewportPermit > 0) viewportPermit -= 1;
252 else unpermittedOlder += 1;
253 }
254 if (event.type === "transcript.scroll-write" && event.owner && !knownScrollWriters.has(event.owner)) unknownWriters += 1;
255 for (const [intent, state] of navigation) {
256 if (painted.has(intent)) continue;
257 if (event.workspaceSessions !== undefined) state.counts.add(event.workspaceSessions);
258 if (event.type === "transcript.surface" && event.totalRows !== undefined) state.rows.push(event.totalRows);
259 }
260 }
261 for (const [intent, state] of navigation) {
262 if (state.counts.size > 1) anomalies.push({ code: "navigation-session-count-changed", intent, count: state.counts.size });
263 const sequence = state.rows;
264 let sawEmpty = false;
265 let sawNonEmptyAfterEmpty = false;
266 let exposed = false;
267 for (const rows of sequence) {
268 if (rows === 0) {
269 if (sawNonEmptyAfterEmpty) exposed = true;
270 sawEmpty = true;
271 } else if (sawEmpty) {
272 sawNonEmptyAfterEmpty = true;
273 }
274 }
275 if (exposed) anomalies.push({ code: "target-empty-sequence", intent });
276 }
277 if (unpermittedOlder > 0) anomalies.push({ code: "viewport-older-without-user-input", count: unpermittedOlder });
278 if (unknownWriters > 0) anomalies.push({ code: "unknown-scroll-writer", count: unknownWriters });
279 return anomalies;
280 }
281
282 function finiteNumber(value: unknown): number | undefined {
283 if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
284 return Math.round(value * 100) / 100;
285 }
286
287 function safeToken(value: unknown): string | undefined {
288 if (typeof value !== "string") return undefined;
289 const token = value.trim();
290 if (!/^[a-zA-Z0-9._:-]{1,64}$/.test(token)) return undefined;
291 return token;
292 }
293
294 function safeEventType(value: unknown): string {
295 return safeToken(value) ?? "other";
296 }
297
298 function normalizedPlatform(): FrontendDiagnosticEnvironment["platform"] {
299 if (typeof navigator === "undefined") return "other";
300 const marker = `${navigator.userAgent} ${navigator.platform}`.toLowerCase();
301 if (marker.includes("win")) return "windows";
302 if (marker.includes("mac")) return "macos";
303 if (marker.includes("linux")) return "linux";
304 return "other";
305 }
306
307 function defaultEnvironment(): FrontendDiagnosticEnvironment {
308 return {
309 buildCommit: typeof __BUILD_COMMIT__ === "string" ? __BUILD_COMMIT__ : "dev",
310 buildChannel: typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "development",
311 platform: normalizedPlatform(),
312 userAgent: typeof navigator === "undefined" ? "unknown" : navigator.userAgent.slice(0, 512),
313 devicePixelRatio: finiteNumber(globalThis.devicePixelRatio) ?? 1,
314 viewportWidth: typeof window === "undefined" ? 0 : Math.max(0, Math.round(window.innerWidth)),
315 viewportHeight: typeof window === "undefined" ? 0 : Math.max(0, Math.round(window.innerHeight)),
316 language: typeof navigator === "undefined" ? "unknown" : navigator.language.slice(0, 32),
317 reducedMotion: typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true,
318 };
319 }
320
321 function sanitizeEvent(t: number, type: string, fields: EventFields): FrontendDiagnosticEvent {
322 const event: FrontendDiagnosticEvent = { t: Math.max(0, Math.round(t)), type: safeEventType(type) };
323 const target = event as Record<string, unknown>;
324 for (const field of NUMBER_FIELDS) {
325 const value = finiteNumber(fields[field]);
326 if (value !== undefined) target[field] = value;
327 }
328 const targetIndex = fields.targetIndex;
329 if (typeof targetIndex === "number" && Number.isFinite(targetIndex)) target.targetIndex = Math.round(targetIndex);
330 else if (targetIndex === "LAST") target.targetIndex = targetIndex;
331 for (const field of BOOLEAN_FIELDS) {
332 if (typeof fields[field] === "boolean") target[field] = fields[field];
333 }
334 for (const field of STRING_FIELDS) {
335 const value = safeToken(fields[field]);
336 if (value !== undefined) target[field] = value;
337 }
338 if (Array.isArray(fields.sources)) {
339 const sources = [...new Set(fields.sources.filter((value): value is FrontendDiagnosticEvent["sources"] extends Array<infer Item> ? Item : never => (
340 typeof value === "string" && GEOMETRY_SOURCES.has(value)
341 )))];
342 if (sources.length > 0) event.sources = sources;
343 }
344 return event;
345 }
346
347 function targetElement(value: EventTarget | null): Element | null {
348 if (value instanceof Element) return value;
349 return null;
350 }
351
352 function classifyTarget(value: EventTarget | null): Pick<FrontendDiagnosticEvent, "target" | "targetRole" | "targetTag"> {
353 const element = targetElement(value);
354 if (!element) return { target: "window" };
355 const control = element.closest("button,input,textarea,select,[role],[contenteditable='true']") ?? element;
356 const tag = control.tagName.toLowerCase();
357 const role = control.getAttribute("role");
358 const target = tag === "button" ? "button" : tag === "input" ? "input" : tag === "textarea" ? "textarea" : tag === "select" ? "select" : role ? `role-${role}` : tag;
359 return { target: safeToken(target), targetRole: safeToken(role ?? undefined), targetTag: safeToken(tag) };
360 }
361
362 function keyClass(key: string): string {
363 if (/^Arrow(?:Up|Down|Left|Right)$/.test(key)) return "arrow";
364 if (key === "Enter" || key === "Escape" || key === "Tab" || key === "Backspace" || key === "Delete" || key === "Home" || key === "End" || key === "PageUp" || key === "PageDown") return key.toLowerCase();
365 if (key.length === 1) return "character";
366 return "other";
367 }
368
369 function transcriptGeometry(element: HTMLElement | null): EventFields {
370 if (!element) return {};
371 const bottomDistance = element.scrollHeight - element.scrollTop - element.clientHeight;
372 return {
373 scrollTop: element.scrollTop,
374 scrollHeight: element.scrollHeight,
375 clientHeight: element.clientHeight,
376 bottomDistance,
377 scrollable: element.scrollHeight - element.clientHeight > 4,
378 atBottom: bottomDistance <= 4,
379 };
380 }
381
382 function eventModifiers(event: MouseEvent | KeyboardEvent | WheelEvent): number {
383 return (event.ctrlKey ? 1 : 0) | (event.shiftKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0);
384 }
385
386 export function createFrontendDiagnostics(options: {
387 maxEvents?: number;
388 maxDurationMs?: number;
389 now?: () => number;
390 randomID?: () => string;
391 environment?: () => FrontendDiagnosticEnvironment;
392 } = {}) {
393 const maxEvents = Math.max(32, Math.min(MAX_FRONTEND_DIAGNOSTIC_EVENTS, Math.round(options.maxEvents ?? MAX_FRONTEND_DIAGNOSTIC_EVENTS)));
394 const maxDurationMs = Math.max(1_000, Math.min(FRONTEND_DIAGNOSTIC_DURATION_MS, Math.round(options.maxDurationMs ?? FRONTEND_DIAGNOSTIC_DURATION_MS)));
395 const now = options.now ?? (() => performance.now());
396 const randomID = options.randomID ?? (() => {
397 const bytes = new Uint8Array(16);
398 if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") crypto.getRandomValues(bytes);
399 else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
400 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
401 });
402 const environment = options.environment ?? defaultEnvironment;
403 const listeners = new Set<Listener>();
404 let status: FrontendDiagnosticStatus = "idle";
405 let startedAt = 0;
406 let stoppedAt = 0;
407 let reportId = "";
408 let createdAt = "";
409 let manifest: FrontendDiagnosticEnvironment | null = null;
410 let events: FrontendDiagnosticEvent[] = [];
411 let droppedEventCount = 0;
412 let markerCount = 0;
413 let stopTimer: ReturnType<typeof setTimeout> | null = null;
414 let sampleFrame: number | null = null;
415 let sampler: (() => Sample) | undefined;
416 let lastSampleAt = -Infinity;
417 let lastSampleSignature = "";
418 let eventListeners: EventTargetListener[] = [];
419
420 const emit = () => listeners.forEach((listener) => listener());
421
422 const append = (type: string, fields: EventFields = {}) => {
423 if (status !== "recording") return;
424 events.push(sanitizeEvent(now() - startedAt, type, fields));
425 if (events.length > maxEvents) {
426 const removed = events.splice(0, events.length - maxEvents);
427 markerCount -= removed.filter((event) => event.type === "marker").length;
428 droppedEventCount += removed.length;
429 }
430 };
431
432 const listen = (target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions) => {
433 target.addEventListener(type, listener, options);
434 eventListeners.push({ target, type, listener, options });
435 };
436
437 const removeListeners = () => {
438 for (const entry of eventListeners) entry.target.removeEventListener(entry.type, entry.listener, entry.options);
439 eventListeners = [];
440 };
441
442 const recordPointer = (type: string, event: PointerEvent) => {
443 const target = classifyTarget(event.target);
444 append(type, { ...target, pointerType: event.pointerType, button: event.button, modifiers: eventModifiers(event), x: event.clientX, y: event.clientY, isTrusted: event.isTrusted });
445 };
446
447 const recordWheel = (event: WheelEvent) => {
448 const target = classifyTarget(event.target);
449 const transcript = targetElement(event.target)?.closest<HTMLElement>(".transcript") ?? document.querySelector<HTMLElement>(".transcript");
450 append("wheel", { ...target, deltaX: event.deltaX, deltaY: event.deltaY, modifiers: eventModifiers(event), isTrusted: event.isTrusted, ...transcriptGeometry(transcript) });
451 };
452
453 const recordScroll = (event: Event) => {
454 const element = targetElement(event.target)?.closest<HTMLElement>(".transcript");
455 if (!element) return;
456 append("scroll", { ...classifyTarget(event.target), ...transcriptGeometry(element), isTrusted: event.isTrusted });
457 };
458
459 const attachBrowserListeners = () => {
460 if (typeof window === "undefined" || typeof document === "undefined") return;
461 listen(document, "pointerdown", (event) => recordPointer("pointerdown", event as PointerEvent), { capture: true, passive: true });
462 listen(document, "pointerup", (event) => recordPointer("pointerup", event as PointerEvent), { capture: true, passive: true });
463 listen(document, "click", (event) => recordPointer("click", event as PointerEvent), { capture: true, passive: true });
464 listen(document, "wheel", (event) => recordWheel(event as WheelEvent), { capture: true, passive: true });
465 listen(document, "scroll", recordScroll, { capture: true, passive: true });
466 listen(document, "keydown", (event) => {
467 const keyboard = event as KeyboardEvent;
468 append("keydown", { ...classifyTarget(keyboard.target), keyClass: keyClass(keyboard.key), modifiers: eventModifiers(keyboard), isTrusted: keyboard.isTrusted });
469 }, { capture: true, passive: true });
470 listen(document, "keyup", (event) => {
471 const keyboard = event as KeyboardEvent;
472 append("keyup", { ...classifyTarget(keyboard.target), keyClass: keyClass(keyboard.key), modifiers: eventModifiers(keyboard), isTrusted: keyboard.isTrusted });
473 }, { capture: true, passive: true });
474 listen(document, "input", (event) => {
475 const input = event as InputEvent;
476 append("input", { ...classifyTarget(input.target), inputType: input.inputType, isTrusted: input.isTrusted });
477 }, { capture: true, passive: true });
478 listen(document, "focusin", (event) => append("focusin", classifyTarget(event.target)), { capture: true, passive: true });
479 listen(document, "focusout", (event) => append("focusout", classifyTarget(event.target)), { capture: true, passive: true });
480 listen(document, "selectionchange", () => append("selectionchange"));
481 listen(document, "visibilitychange", () => append("visibility", { visibility: document.visibilityState }));
482 listen(window, "resize", () => append("resize", { width: window.innerWidth, height: window.innerHeight }));
483 listen(window, "online", () => append("network", { status: "online" }));
484 listen(window, "offline", () => append("network", { status: "offline" }));
485 listen(window, "error", () => append("error", { errorName: "window-error" }));
486 listen(window, "unhandledrejection", () => append("error", { errorName: "unhandled-rejection" }));
487 listen(window, "pageshow", () => append("lifecycle", { action: "pageshow" }));
488 listen(window, "pagehide", () => append("lifecycle", { action: "pagehide" }));
489 };
490
491 const cancelAsync = () => {
492 if (stopTimer !== null) clearTimeout(stopTimer);
493 stopTimer = null;
494 if (sampleFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(sampleFrame);
495 sampleFrame = null;
496 };
497
498 const scheduleSample = () => {
499 if (!sampler || typeof requestAnimationFrame !== "function") return;
500 const tick = () => {
501 sampleFrame = null;
502 if (status !== "recording" || !sampler) return;
503 const elapsed = now() - startedAt;
504 if (elapsed - lastSampleAt >= 50) {
505 const sample = sampler();
506 if (sample) {
507 const sanitized = sanitizeEvent(elapsed, "sample", sample);
508 const signature = JSON.stringify({ ...sanitized, t: 0 });
509 if (signature !== lastSampleSignature || elapsed - lastSampleAt >= 250) {
510 append("sample", sample);
511 lastSampleSignature = signature;
512 lastSampleAt = elapsed;
513 }
514 }
515 }
516 sampleFrame = requestAnimationFrame(tick);
517 };
518 sampleFrame = requestAnimationFrame(tick);
519 };
520
521 const snapshot = (): FrontendDiagnosticSnapshot => ({
522 status,
523 durationMs: Math.max(0, Math.round((status === "recording" ? now() : stoppedAt) - startedAt)),
524 eventCount: events.length,
525 droppedEventCount,
526 markerCount,
527 reportId,
528 });
529
530 const stop = (): FrontendDiagnosticPayload => {
531 if (status === "idle" || !manifest) throw new Error("frontend diagnostics are not active");
532 if (status === "recording") {
533 append("stop");
534 stoppedAt = now();
535 status = "stopped";
536 cancelAsync();
537 removeListeners();
538 emit();
539 }
540 return {
541 schemaVersion: FRONTEND_DIAGNOSTIC_SCHEMA_VERSION,
542 manifest: { ...manifest, reportId, createdAt },
543 summary: {
544 durationMs: Math.max(0, Math.round(stoppedAt - startedAt)),
545 eventCount: events.length,
546 droppedEventCount,
547 markerCount,
548 anomalies: analyzeFrontendDiagnosticAnomalies(events),
549 },
550 events: events.map((event) => ({ ...event })),
551 };
552 };
553
554 return {
555 start(nextSampler?: () => Sample) {
556 cancelAsync();
557 removeListeners();
558 status = "recording";
559 startedAt = now();
560 stoppedAt = startedAt;
561 reportId = randomID();
562 createdAt = new Date().toISOString();
563 manifest = environment();
564 events = [];
565 droppedEventCount = 0;
566 markerCount = 0;
567 sampler = nextSampler;
568 lastSampleAt = -Infinity;
569 lastSampleSignature = "";
570 append("start", { source: "recorder" });
571 attachBrowserListeners();
572 notifyFrontendDiagnosticStart();
573 stopTimer = setTimeout(() => stop(), maxDurationMs);
574 scheduleSample();
575 emit();
576 },
577 stop,
578 reset() {
579 cancelAsync();
580 removeListeners();
581 status = "idle";
582 startedAt = 0;
583 stoppedAt = 0;
584 reportId = "";
585 createdAt = "";
586 manifest = null;
587 events = [];
588 droppedEventCount = 0;
589 markerCount = 0;
590 sampler = undefined;
591 emit();
592 },
593 mark() {
594 if (status !== "recording") return;
595 markerCount += 1;
596 append("marker", { source: "user" });
597 emit();
598 },
599 record(source: string, type: string, fields: EventFields = {}) {
600 const { source: eventSource, ...rest } = fields;
601 append(type, { ...rest, source, eventSource });
602 },
603 getSnapshot: snapshot,
604 subscribe(listener: Listener) {
605 listeners.add(listener);
606 return () => { listeners.delete(listener); };
607 },
608 };
609 }
610
611 export const frontendDiagnostics = createFrontendDiagnostics();
612
613 export function frontendDiagnosticSample(element: HTMLElement | null, totalRows?: number): EventFields | null {
614 if (!element) return null;
615 const viewport = element.getBoundingClientRect();
616 const rows = element.querySelectorAll<HTMLElement>(".chat-node");
617 let firstVisibleIndex: number | undefined;
618 let firstVisibleTop: number | undefined;
619 for (const row of rows) {
620 const rect = row.getBoundingClientRect();
621 if (rect.bottom <= viewport.top || rect.top >= viewport.bottom) continue;
622 const index = Number(row.dataset.logicalIndex ?? row.dataset.index);
623 firstVisibleIndex = Number.isFinite(index) ? index : undefined;
624 firstVisibleTop = rect.top - viewport.top;
625 break;
626 }
627 return {
628 ...transcriptGeometry(element),
629 mountedRows: rows.length,
630 totalRows: totalRows ?? Number(element.dataset.transcriptRowCount ?? 0),
631 firstVisibleIndex,
632 firstVisibleTop,
633 mode: element.dataset.scrollMode,
634 };
635 }
636
637 setFrontendDiagnosticSink((source, type, fields) => frontendDiagnostics.record(source, type, fields));
638
638 lines TYPESCRIPT