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