| 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 |