| 1 | import type { BrowserSurfaceManager, BrowserTab } from "./surfaceManager.js"; |
| 2 | import type { BrowserGrant, GrantRegistry } from "./grants.js"; |
| 3 | import type { BrowserRequestTrace } from "./diagnosticContext.js"; |
| 4 | import { diagnosticText, diagnosticURL, type DiagnosticBuffer, type PageDiagnostic } from "./diagnostics.js"; |
| 5 | |
| 6 | interface Entry { scope: string; sequence: number; time: number; kind: string; [key: string]: unknown } |
| 7 | interface Tracked { scope: string; state: string; tab: BrowserTab; buffer?: DiagnosticBuffer; off?: () => void } |
| 8 | const LIMIT = 512, BYTE_LIMIT = 256 * 1024; |
| 9 | |
| 10 | // One bounded application-wide window, independent of tab/grant lifetime. |
| 11 | // Attribution is supplied by the trusted Go host, never by page content. |
| 12 | export class BrowserDiagnosticExport { |
| 13 | private entries: Entry[] = []; |
| 14 | private bytes = 0; |
| 15 | private sequence = 0; |
| 16 | private dropped = 0; |
| 17 | private readonly startedAt = Date.now(); |
| 18 | private readonly tracked = new Map<string, Tracked>(); |
| 19 | private readonly stop: (() => void)[]; |
| 20 | constructor(private surfaces: BrowserSurfaceManager, private grants: GrantRegistry, private metadata: { build: string; version: string; platform: string }) { |
| 21 | this.stop = [surfaces.subscribe(() => this.sync()), grants.onRevoke(grant => { |
| 22 | this.add(grant.diagnosticScope ?? "", "lifecycle", { event: "grant_revoked", taskId: grant.taskId }); |
| 23 | })]; |
| 24 | } |
| 25 | bind(grant: BrowserGrant): void { |
| 26 | if (!grant.diagnosticScope || !/^[a-f0-9]{64}$/.test(grant.diagnosticScope)) return; |
| 27 | this.add(grant.diagnosticScope, "lifecycle", { event: "grant_installed", taskId: grant.taskId, runtimeGeneration: grant.generation }); |
| 28 | this.sync(); |
| 29 | } |
| 30 | trace(scope: string, event: BrowserRequestTrace): void { |
| 31 | const identifier = (value?: string) => value && /^[\w./-]{1,160}$/.test(value) ? value : undefined; |
| 32 | this.add(scope, "host", { requestId: identifier(event.requestId), operationId: identifier(event.operationId), tabId: identifier(event.tabId), method: identifier(event.method), phase: identifier(event.phase), elapsedMs: event.elapsedMs, errorKind: identifier(event.errorKind), command: identifier(event.command), target: identifier(event.target) }); |
| 33 | } |
| 34 | private add(scope: string, kind: string, fields: Record<string, unknown>): void { |
| 35 | if (!scope) return; |
| 36 | const item: Entry = { ...fields, scope, kind, sequence: ++this.sequence, time: Date.now() }; |
| 37 | this.entries.push(item); |
| 38 | this.bytes += Buffer.byteLength(JSON.stringify(item)); |
| 39 | while (this.entries.length > LIMIT || this.bytes > BYTE_LIMIT) { |
| 40 | this.bytes -= Buffer.byteLength(JSON.stringify(this.entries.shift()!)); |
| 41 | this.dropped++; |
| 42 | } |
| 43 | } |
| 44 | private page(scope: string, tabId: string, entry: PageDiagnostic): void { |
| 45 | this.add(scope, "page", { tabId, pageSequence: entry.sequence, pageTime: entry.time, category: entry.kind, message: diagnosticText(entry.message), url: entry.url ? diagnosticURL(entry.url) : undefined, status: entry.status }); |
| 46 | } |
| 47 | private sync(): void { |
| 48 | const alive = new Set<string>(); |
| 49 | for (const tab of this.surfaces.all()) { |
| 50 | alive.add(tab.id); |
| 51 | let record = this.tracked.get(tab.id); |
| 52 | const scope = record?.scope ?? this.grants.diagnosticScopeForTab(tab.taskId, tab.sessionId); |
| 53 | if (!scope) continue; |
| 54 | if (!record) { record = { scope, state: "", tab }; this.tracked.set(tab.id, record); } |
| 55 | const state = { tabId: tab.id, taskId: tab.taskId, documentEpoch: tab.epoch, lifecycleEpoch: tab.lifecycleEpoch, viewportRevision: tab.viewportRevision, viewport: tab.viewport, mode: tab.mode, loading: tab.loading, crashes: tab.crashes, errorCode: tab.error?.code, active: this.surfaces.activeTabId === tab.id, webContentsId: tab.view.page.id }; |
| 56 | const serialized = JSON.stringify(state); |
| 57 | if (serialized !== record.state) { |
| 58 | this.add(scope, "lifecycle", { event: record.state ? "tab_changed" : "tab_opened", ...state }); |
| 59 | record.state = serialized; |
| 60 | } |
| 61 | const buffer = tab.view.diagnostics; |
| 62 | if (buffer !== record.buffer) { |
| 63 | record.off?.(); record.buffer = buffer; |
| 64 | if (buffer) { |
| 65 | const snapshot = buffer.read(); |
| 66 | this.add(scope, "lifecycle", { event: "page_diagnostics_attached", tabId: tab.id, truncated: snapshot.truncated }); |
| 67 | for (const item of snapshot.entries) this.page(scope, tab.id, item); |
| 68 | record.off = buffer.subscribe(item => this.page(scope, tab.id, item)); |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | for (const [id, record] of this.tracked) { |
| 73 | if (!alive.has(id)) { |
| 74 | record.off?.(); this.tracked.delete(id); |
| 75 | this.add(record.scope, "lifecycle", { event: "tab_closed", tabId: id }); |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | read(scope: string) { |
| 80 | if (!/^[a-f0-9]{64}$/.test(scope)) throw new Error("invalid diagnostic scope"); |
| 81 | this.sync(); |
| 82 | const entries = this.entries.filter(entry => entry.scope === scope).map(({ scope: _scope, ...entry }) => ({ ...entry })); |
| 83 | return { |
| 84 | available: true, scope, schemaVersion: 1, ...this.metadata, startedAt: this.startedAt, capturedAt: Date.now(), |
| 85 | sessionEvidenceAvailable: entries.length > 0, |
| 86 | retention: "In-memory application window; cleared on shell restart. Truncation may be caused by other sessions. Events without explicit attribution are omitted.", |
| 87 | truncated: this.dropped > 0, limit: LIMIT, byteLimit: BYTE_LIMIT, |
| 88 | tabs: [...this.tracked.values()].filter(row => row.scope === scope).map(row => ({ tabId: row.tab.id, pageDiagnosticsAvailable: Boolean(row.buffer) })), |
| 89 | entries, |
| 90 | }; |
| 91 | } |
| 92 | dispose(): void { |
| 93 | for (const off of this.stop) off(); |
| 94 | for (const row of this.tracked.values()) row.off?.(); |
| 95 | this.tracked.clear(); this.entries = []; this.bytes = 0; |
| 96 | } |
| 97 | } |
| 98 |