From bb49aaabd6424f011d5319a7bbb8dad5ea5ee4d0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:19:07 +0800 Subject: [PATCH 01/15] feat: add renderer diagnostics recorder --- .../src/main/renderer-diagnostics.test.ts | 301 +++++++++++ .../src/main/renderer-diagnostics.ts | 498 ++++++++++++++++++ 2 files changed, 799 insertions(+) create mode 100644 packages/desktop-electron/src/main/renderer-diagnostics.test.ts create mode 100644 packages/desktop-electron/src/main/renderer-diagnostics.ts diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts new file mode 100644 index 00000000..5652b035 --- /dev/null +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -0,0 +1,301 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + createRendererDiagnosticsRecorder, + DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_MS, + exportRendererDiagnosticsLog, + sanitizeRendererDiagnosticEvent, + selectRendererDiagnosticsSlice, + type RendererDiagnosticInput, +} from "./renderer-diagnostics" + +let roots: string[] = [] + +async function tempRoot() { + const root = await mkdtemp(join(tmpdir(), "pawwork-renderer-diagnostics-")) + roots.push(root) + return root +} + +afterEach(async () => { + for (const root of roots) await rm(root, { recursive: true, force: true }) + roots = [] +}) + +describe("renderer diagnostics sanitizer", () => { + test("accepts allowlisted scroll fields and drops hostile fields", () => { + const input: RendererDiagnosticInput = { + name: "session.scroll.sample", + level: "info", + monotonic_ms: 123.5, + trace_id: "trace_1", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + scroll_top: 42, + scroll_height: 1200, + client_height: 800, + distance_from_bottom: 358, + user_scrolled: false, + jump_button_visible: true, + visible_first_message_id: "msg_first", + visible_last_message_id: "msg_last", + prompt_text: "do not write me", + raw_provider_url: "https://api.example.com/token=secret", + nested: { message_text: "do not write me" }, + }, + } + + const event = sanitizeRendererDiagnosticEvent(input, { + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + windowID: 7, + }) + + expect(event).toMatchObject({ + time: "2026-05-02T10:30:12.123Z", + monotonic_ms: 123.5, + level: "info", + "event.name": "session.scroll.sample", + app_launch_id: "launch_1", + window_id: "7", + trace_id: "trace_1", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + scroll_top: 42, + scroll_height: 1200, + client_height: 800, + distance_from_bottom: 358, + user_scrolled: false, + jump_button_visible: true, + visible_first_message_id: "msg_first", + visible_last_message_id: "msg_last", + }, + }) + expect(JSON.stringify(event)).not.toContain("prompt_text") + expect(JSON.stringify(event)).not.toContain("raw_provider_url") + expect(JSON.stringify(event)).not.toContain("do not write me") + }) + + test("ignores unknown events, malformed input, and oversized payloads", () => { + expect( + sanitizeRendererDiagnosticEvent( + { name: "unknown.event", data: { scroll_top: 1 } }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ), + ).toBeUndefined() + expect( + sanitizeRendererDiagnosticEvent(null, { + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + windowID: 1, + }), + ).toBeUndefined() + expect( + sanitizeRendererDiagnosticEvent(42, { + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + windowID: 1, + }), + ).toBeUndefined() + expect( + sanitizeRendererDiagnosticEvent( + { name: "session.action.submit", data: { prompt_length: 1n } }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ), + ).toBeUndefined() + expect( + sanitizeRendererDiagnosticEvent( + { name: "session.action.submit", data: { action: "submit_prompt", huge: "x".repeat(9000) } }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ), + ).toBeUndefined() + }) + + test("drops url-like strings even when they use allowlisted field names", () => { + const event = sanitizeRendererDiagnosticEvent( + { + name: "session.action.submit", + data: { + action: "submit_prompt", + provider: "wss://provider.example.com/v1", + model: "deepseek-v4-pro", + endpoint_kind: "api.example.com/v1", + }, + }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ) + + expect(event?.data).toEqual({ action: "submit_prompt", model: "deepseek-v4-pro" }) + }) +}) + +describe("renderer diagnostics recorder", () => { + test("records JSONL and drops high-frequency duplicate samples", async () => { + const root = await tempRoot() + const recorder = createRendererDiagnosticsRecorder({ + root, + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + highFrequencyIntervalMs: 250, + }) + + await recorder.record({ name: "session.scroll.sample", monotonic_ms: 1, data: { scroll_top: 1 } }, { windowID: 1 }) + await recorder.record({ name: "session.scroll.sample", monotonic_ms: 2, data: { scroll_top: 2 } }, { windowID: 1 }) + await recorder.record({ name: "session.action.submit", monotonic_ms: 3, data: { action: "submit_prompt" } }, { windowID: 1 }) + + const lines = (await readFile(recorder.path, "utf8")).trim().split("\n") + expect(lines).toHaveLength(2) + expect(JSON.parse(lines[0])["event.name"]).toBe("session.scroll.sample") + expect(JSON.parse(lines[1])["event.name"]).toBe("session.action.submit") + }) + + test("rate limit uses main-process time, not renderer-provided monotonic time", async () => { + const root = await tempRoot() + const recorder = createRendererDiagnosticsRecorder({ + root, + appLaunchID: "launch_1", + highFrequencyIntervalMs: 60_000, + }) + + await recorder.record({ name: "session.scroll.sample", monotonic_ms: 1, data: { scroll_top: 1 } }, { windowID: 1 }) + await recorder.record( + { name: "session.scroll.sample", monotonic_ms: 999_999, data: { scroll_top: 2 } }, + { windowID: 1 }, + ) + + const lines = (await readFile(recorder.path, "utf8")).trim().split("\n") + expect(lines).toHaveLength(1) + }) + + test("retention keeps recent entries and caps bytes", async () => { + const root = await tempRoot() + const recorder = createRendererDiagnosticsRecorder({ + root, + appLaunchID: "launch_1", + maxBytes: 260, + now: () => new Date("2026-05-02T10:30:12.123Z"), + retentionMs: DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_MS, + }) + + await recorder.record({ name: "session.action.submit", data: { action: "one" } }, { windowID: 1 }) + await recorder.record({ name: "session.action.submit", data: { action: "two" } }, { windowID: 1 }) + await recorder.record({ name: "session.action.submit", data: { action: "three" } }, { windowID: 1 }) + await recorder.flushRetention() + + const content = await readFile(recorder.path, "utf8") + expect(Buffer.byteLength(content, "utf8")).toBeLessThanOrEqual(260) + expect(content).toContain("three") + }) + + test("slice keeps matching session transitions and reports truncation", async () => { + const events = [ + { + time: "2026-05-02T10:30:10.000Z", + level: "info" as const, + "event.name": "session.identity.transition", + app_launch_id: "launch_1", + window_id: "1", + data: { from_visible_session_id: "ses_old", to_visible_session_id: "ses_target" }, + }, + { + time: "2026-05-02T10:30:11.000Z", + level: "warn" as const, + "event.name": "incident.session_timeline_remount", + app_launch_id: "launch_1", + window_id: "1", + visible_session_id: "ses_target", + data: { timeline_mount_count: 2, timeline_unmount_count: 1 }, + }, + { + time: "2026-05-02T10:30:12.000Z", + level: "info" as const, + "event.name": "session.scroll.sample", + app_launch_id: "launch_1", + window_id: "1", + visible_session_id: "ses_target", + data: { + scroll_top: 10, + scroll_height: 1200, + client_height: 800, + distance_from_bottom: 390, + payload: "x".repeat(1000), + }, + }, + { + time: "2026-05-02T10:30:12.500Z", + level: "info" as const, + "event.name": "session.scroll.sample", + app_launch_id: "launch_1", + window_id: "2", + visible_session_id: "ses_other", + data: { scroll_top: 10 }, + }, + ] + + const slice = selectRendererDiagnosticsSlice(events, { + sessionID: "ses_target", + windowID: "1", + appLaunchID: "launch_1", + maxBytes: 800, + now: new Date("2026-05-02T10:30:13.000Z"), + }) + + expect(slice.status).toBe("truncated") + expect(slice.events.map((event) => event["event.name"])).toContain("incident.session_timeline_remount") + expect(slice.events.map((event) => event["event.name"])).toContain("session.identity.transition") + expect(JSON.stringify(slice)).not.toContain("ses_other") + expect(JSON.stringify(slice)).not.toContain("x".repeat(1000)) + }) + + test("reports missing, disabled, corrupt, and expired statuses without throwing", async () => { + const root = await tempRoot() + const missing = createRendererDiagnosticsRecorder({ root, appLaunchID: "launch_1" }) + expect((await missing.slice({ sessionID: "ses_1", maxBytes: 1024 })).status).toBe("missing") + + const disabled = createRendererDiagnosticsRecorder({ root, appLaunchID: "launch_1", disabled: true }) + expect((await disabled.slice({ sessionID: "ses_1", maxBytes: 1024 })).status).toBe("disabled") + + await writeFile(missing.path, "{not json}\n", "utf8") + expect((await missing.slice({ sessionID: "ses_1", maxBytes: 1024 })).status).toBe("corrupt") + + await writeFile( + missing.path, + JSON.stringify({ + time: "2026-05-01T10:30:12.123Z", + level: "info", + "event.name": "session.action.submit", + app_launch_id: "launch_1", + window_id: "1", + visible_session_id: "ses_1", + data: { action: "submit_prompt" }, + }) + "\n", + "utf8", + ) + expect( + ( + await missing.slice({ + sessionID: "ses_1", + maxBytes: 1024, + from: new Date("2026-05-02T10:30:00.000Z"), + to: new Date("2026-05-02T10:31:00.000Z"), + }) + ).status, + ).toBe("expired") + }) + + test("global export caps old JSONL content", async () => { + const root = await tempRoot() + const source = join(root, "renderer-diagnostics.jsonl") + const destination = join(root, "exported.jsonl") + await writeFile(source, `${"a".repeat(200)}\n${"b".repeat(40)}\n`, "utf8") + await exportRendererDiagnosticsLog({ path: source, destination, maxBytes: 80 }) + const exported = await readFile(destination, "utf8") + expect(exported).toBe(`${"b".repeat(40)}\n`) + }) +}) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts new file mode 100644 index 00000000..4828041a --- /dev/null +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -0,0 +1,498 @@ +import { appendFile, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { basename, join } from "node:path" + +export const DEFAULT_RENDERER_DIAGNOSTICS_MAX_BYTES = 20 * 1024 * 1024 +export const DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_MS = 24 * 60 * 60 * 1000 +export const SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES = 1 * 1024 * 1024 +export const GLOBAL_RENDERER_DIAGNOSTICS_EXPORT_MAX_BYTES = 10 * 1024 * 1024 +export const RENDERER_DIAGNOSTIC_EVENT_MAX_BYTES = 8 * 1024 + +export type RendererDiagnosticsStatus = + | "ok" + | "missing" + | "expired" + | "truncated" + | "corrupt" + | "disabled" + | "write_failed" + +export type RendererDiagnosticInput = { + name: string + level?: "info" | "warn" + monotonic_ms?: number + trace_id?: string + route_session_id?: string + visible_session_id?: string + timeline_session_id?: string + message_id?: string + part_id?: string + data?: Record +} + +export type RendererDiagnosticEvent = { + time: string + monotonic_ms?: number + level: "info" | "warn" + "event.name": string + app_launch_id: string + window_id: string + trace_id?: string + route_session_id?: string + visible_session_id?: string + timeline_session_id?: string + message_id?: string + part_id?: string + data: Record +} + +export type RendererDiagnosticsSlice = { + status: RendererDiagnosticsStatus + source: "renderer-diagnostics" + generated_at: string + events: RendererDiagnosticEvent[] + summary: { + event_count: number + incident_count: number + statuses: RendererDiagnosticsStatus[] + omitted_event_count: number + omitted_bytes: number + } +} + +type SanitizeContext = { + appLaunchID: string + now: () => Date + windowID: number | string +} + +type RecorderOptions = { + root: string + appLaunchID: string + maxBytes?: number + retentionMs?: number + highFrequencyIntervalMs?: number + disabled?: boolean + now?: () => Date +} + +type RecordContext = { + windowID: number | string +} + +type SliceInput = { + appLaunchID?: string + windowID?: string | number + sessionID?: string | null + traceID?: string + from?: Date + to?: Date + maxBytes: number + now: Date +} + +const eventDataFields = { + "session.view.state": [ + "route_session_id", + "visible_session_id", + "timeline_session_id", + "route_ready", + "visible_ready", + "transitioning", + "message_count", + "part_count", + "history_more", + "history_loading", + ], + "session.identity.transition": [ + "from_route_session_id", + "to_route_session_id", + "from_visible_session_id", + "to_visible_session_id", + "from_timeline_session_id", + "to_timeline_session_id", + ], + "session.action.submit": [ + "action", + "provider", + "model", + "endpoint_kind", + "prompt_length", + "image_count", + "comment_count", + ], + "session.timeline.mount": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], + "session.timeline.unmount": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], + "session.timeline.visible": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], + "session.scroll.sample": [ + "scroll_top", + "scroll_height", + "client_height", + "distance_from_bottom", + "user_scrolled", + "jump_button_visible", + "visible_first_message_id", + "visible_last_message_id", + ], + "session.layout.composer_dock": ["composer_height", "previous_composer_height", "scroll_top", "distance_from_bottom"], + "session.data.refresh": ["phase", "message_count", "part_count", "duration_ms", "cache_present"], + "renderer.perf.sample": [ + "fps", + "frame_gap_ms", + "jank_count", + "long_task_max_ms", + "long_task_block_ms", + "cls", + "heap_used_mb", + ], + "renderer.visibility": ["visibility"], + "incident.session_scroll_jump_to_top": ["scroll_top", "distance_from_bottom", "client_height", "user_scrolled"], + "incident.session_timeline_remount": ["timeline_mount_count", "timeline_unmount_count"], + "incident.session_visible_messages_cleared": ["before_count", "during_count", "after_count"], + "incident.session_layout_shift": ["cls", "phase"], + "incident.session_jank_burst": ["long_task_max_ms", "frame_gap_ms", "phase"], +} as const + +const highFrequencyEvents = new Set(["session.scroll.sample", "renderer.perf.sample"]) + +function isAllowedEventName(name: string): name is keyof typeof eventDataFields { + return Object.hasOwn(eventDataFields, name) +} + +function stringField(value: unknown, limit = 160) { + if (typeof value !== "string") return undefined + const next = value.replace(/\s+/g, " ").trim() + if (!next) return undefined + if (/[a-z][a-z0-9+.-]*:\/\//i.test(next)) return undefined + if (/\b[a-z0-9.-]+\.[a-z]{2,}(?:\/|\?|:|$)/i.test(next)) return undefined + if (/token=|key=|secret=|authorization/i.test(next)) return undefined + return next.length > limit ? next.slice(0, limit) : next +} + +function numberField(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function booleanField(value: unknown) { + return typeof value === "boolean" ? value : undefined +} + +function safeJsonBytes(value: unknown) { + try { + return Buffer.byteLength(JSON.stringify(value), "utf8") + } catch { + return Number.POSITIVE_INFINITY + } +} + +function jsonBytes(value: unknown) { + const bytes = safeJsonBytes(value) + return Number.isFinite(bytes) ? bytes : 0 +} + +function safeDataValue(value: unknown) { + const string = stringField(value) + if (string !== undefined) return string + const number = numberField(value) + if (number !== undefined) return number + const boolean = booleanField(value) + if (boolean !== undefined) return boolean + if (value === null) return null + return undefined +} + +function sanitizeData(name: keyof typeof eventDataFields, data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return {} + const input = data as Record + const output: Record = {} + for (const key of eventDataFields[name]) { + if (!(key in input)) continue + const value = safeDataValue(input[key]) + if (value !== undefined) output[key] = value + } + return output +} + +export function sanitizeRendererDiagnosticEvent( + input: unknown, + context: SanitizeContext, +): RendererDiagnosticEvent | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined + const diagnostic = input as RendererDiagnosticInput + if (!isAllowedEventName(diagnostic.name)) return undefined + if (safeJsonBytes(diagnostic) > RENDERER_DIAGNOSTIC_EVENT_MAX_BYTES) return undefined + const event: RendererDiagnosticEvent = { + time: context.now().toISOString(), + level: diagnostic.level === "warn" ? "warn" : "info", + "event.name": diagnostic.name, + app_launch_id: context.appLaunchID, + window_id: String(context.windowID), + data: sanitizeData(diagnostic.name, diagnostic.data), + } + const monotonic = numberField(diagnostic.monotonic_ms) + if (monotonic !== undefined) event.monotonic_ms = monotonic + const traceID = stringField(diagnostic.trace_id, 80) + if (traceID) event.trace_id = traceID + const routeID = stringField(diagnostic.route_session_id, 120) + if (routeID) event.route_session_id = routeID + const visibleID = stringField(diagnostic.visible_session_id, 120) + if (visibleID) event.visible_session_id = visibleID + const timelineID = stringField(diagnostic.timeline_session_id, 120) + if (timelineID) event.timeline_session_id = timelineID + const messageID = stringField(diagnostic.message_id, 120) + if (messageID) event.message_id = messageID + const partID = stringField(diagnostic.part_id, 120) + if (partID) event.part_id = partID + return event +} + +function parseEventLine(line: string): RendererDiagnosticEvent | undefined { + try { + const value = JSON.parse(line) as RendererDiagnosticEvent + if (!value || typeof value !== "object") return undefined + if (typeof value.time !== "string") return undefined + if (typeof value["event.name"] !== "string") return undefined + if (typeof value.app_launch_id !== "string") return undefined + if (typeof value.window_id !== "string") return undefined + if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) return undefined + return value + } catch { + return undefined + } +} + +function eventTime(event: RendererDiagnosticEvent) { + const time = Date.parse(event.time) + return Number.isFinite(time) ? time : 0 +} + +function eventMatchesSession(event: RendererDiagnosticEvent, sessionID: string) { + if (event.route_session_id === sessionID) return true + if (event.visible_session_id === sessionID) return true + if (event.timeline_session_id === sessionID) return true + const data = event.data + return ( + data.from_route_session_id === sessionID || + data.to_route_session_id === sessionID || + data.from_visible_session_id === sessionID || + data.to_visible_session_id === sessionID || + data.from_timeline_session_id === sessionID || + data.to_timeline_session_id === sessionID + ) +} + +function isIncident(event: RendererDiagnosticEvent) { + return event["event.name"].startsWith("incident.") +} + +export function emptyRendererDiagnosticsSlice(status: RendererDiagnosticsStatus, now: Date): RendererDiagnosticsSlice { + return { + status, + source: "renderer-diagnostics", + generated_at: now.toISOString(), + events: [], + summary: { + event_count: 0, + incident_count: 0, + statuses: [status], + omitted_event_count: 0, + omitted_bytes: 0, + }, + } +} + +function isProtectedSliceContext(event: RendererDiagnosticEvent) { + return isIncident(event) || event["event.name"] === "session.identity.transition" +} + +function capEvents(events: RendererDiagnosticEvent[], maxBytes: number) { + let selected = events.slice() + let omitted = 0 + while (selected.length > 0 && jsonBytes(selected) > maxBytes) { + const removable = selected.findIndex((event) => !isProtectedSliceContext(event)) + const index = removable >= 0 ? removable : 0 + selected.splice(index, 1) + omitted++ + } + return { + events: selected, + omittedEventCount: omitted, + omittedBytes: Math.max(0, jsonBytes(events) - jsonBytes(selected)), + } +} + +export function selectRendererDiagnosticsSlice( + inputEvents: RendererDiagnosticEvent[], + input: SliceInput, +): RendererDiagnosticsSlice { + const windowID = input.windowID === undefined ? undefined : String(input.windowID) + const from = input.from?.getTime() ?? input.now.getTime() - 5 * 60 * 1000 + const to = input.to?.getTime() ?? input.now.getTime() + 60 * 1000 + const events = inputEvents + .filter((event) => { + const time = eventTime(event) + if (time < from || time > to) return false + if (input.appLaunchID && event.app_launch_id !== input.appLaunchID) return false + if (windowID && event.window_id !== windowID) return false + if (input.traceID && event.trace_id === input.traceID) return true + if (input.sessionID && eventMatchesSession(event, input.sessionID)) return true + return !input.sessionID && !input.traceID + }) + .sort((a, b) => eventTime(a) - eventTime(b)) + const capped = capEvents(events, input.maxBytes) + const incidentCount = capped.events.filter(isIncident).length + return { + status: capped.omittedEventCount > 0 ? "truncated" : "ok", + source: "renderer-diagnostics", + generated_at: input.now.toISOString(), + events: capped.events, + summary: { + event_count: capped.events.length, + incident_count: incidentCount, + statuses: capped.omittedEventCount > 0 ? ["truncated"] : ["ok"], + omitted_event_count: capped.omittedEventCount, + omitted_bytes: capped.omittedBytes, + }, + } +} + +export function rendererDiagnosticsRoot(userDataPath: string) { + return join(userDataPath, "diagnostics") +} + +export function rendererDiagnosticsPath(root: string) { + return join(root, "renderer-diagnostics.jsonl") +} + +export async function exportRendererDiagnosticsLog(input: { + path: string + destination: string + maxBytes?: number +}) { + const maxBytes = input.maxBytes ?? GLOBAL_RENDERER_DIAGNOSTICS_EXPORT_MAX_BYTES + await copyFile(input.path, input.destination) + let content = await readFile(input.destination, "utf8") + while (Buffer.byteLength(content, "utf8") > maxBytes) { + const nextLine = content.indexOf("\n") + if (nextLine < 0) { + content = "" + break + } + content = content.slice(nextLine + 1) + } + await writeFile(input.destination, content, "utf8") +} + +export function createRendererDiagnosticsRecorder(options: RecorderOptions) { + const maxBytes = options.maxBytes ?? DEFAULT_RENDERER_DIAGNOSTICS_MAX_BYTES + const retentionMs = options.retentionMs ?? DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_MS + const highFrequencyIntervalMs = options.highFrequencyIntervalMs ?? 250 + const now = options.now ?? (() => new Date()) + const path = rendererDiagnosticsPath(options.root) + const lastHighFrequency = new Map() + let writeFailed = false + + const readEventReport = async () => { + try { + const content = await readFile(path, "utf8") + const lines = content.split(/\r?\n/).filter(Boolean) + const events: RendererDiagnosticEvent[] = [] + let corruptLineCount = 0 + for (const line of lines) { + const event = parseEventLine(line) + if (event) events.push(event) + else corruptLineCount++ + } + return { status: "ok" as const, events, corruptLineCount } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { status: "missing" as const, events: [], corruptLineCount: 0 } + } + return { status: "corrupt" as const, events: [], corruptLineCount: 1 } + } + } + + const readEvents = async () => (await readEventReport()).events + + const flushRetention = async () => { + const events = await readEvents() + const cutoff = now().getTime() - retentionMs + const retained = events.filter((event) => eventTime(event) >= cutoff) + let content = retained.map((event) => JSON.stringify(event)).join("\n") + if (content) content += "\n" + while (Buffer.byteLength(content, "utf8") > maxBytes && retained.length > 0) { + retained.shift() + content = retained.map((event) => JSON.stringify(event)).join("\n") + if (content) content += "\n" + } + await mkdir(options.root, { recursive: true }) + const temp = join(options.root, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`) + await writeFile(temp, content, "utf8") + await rename(temp, path).catch(async (error) => { + await rm(temp, { force: true }).catch(() => undefined) + throw error + }) + } + + const record = async (input: unknown, context: RecordContext) => { + try { + const sanitized = sanitizeRendererDiagnosticEvent(input, { + appLaunchID: options.appLaunchID, + now, + windowID: context.windowID, + }) + if (!sanitized) return { ok: false as const, reason: "dropped" as const } + if (highFrequencyEvents.has(sanitized["event.name"])) { + const key = `${sanitized.window_id}:${sanitized["event.name"]}` + const current = Date.now() + const previous = lastHighFrequency.get(key) + if (previous !== undefined && current - previous < highFrequencyIntervalMs) { + return { ok: false as const, reason: "rate_limited" as const } + } + lastHighFrequency.set(key, current) + } + await mkdir(options.root, { recursive: true }) + await appendFile(path, `${JSON.stringify(sanitized)}\n`, "utf8") + await flushRetention() + return { ok: true as const } + } catch { + writeFailed = true + return { ok: false as const, reason: "write_failed" as const } + } + } + + const slice = async (input: Omit) => { + if (options.disabled) return emptyRendererDiagnosticsSlice("disabled", now()) + if (writeFailed) return emptyRendererDiagnosticsSlice("write_failed", now()) + const report = await readEventReport() + if (report.status === "missing") return emptyRendererDiagnosticsSlice("missing", now()) + if (report.status === "corrupt" || (report.events.length === 0 && report.corruptLineCount > 0)) { + return emptyRendererDiagnosticsSlice("corrupt", now()) + } + const events = report.events + if (events.length === 0) return emptyRendererDiagnosticsSlice("missing", now()) + const windowID = input.windowID === undefined ? undefined : String(input.windowID) + const hasMatchingIdentity = events.some((event) => { + if (event.app_launch_id !== options.appLaunchID) return false + if (windowID && event.window_id !== windowID) return false + if (input.traceID && event.trace_id === input.traceID) return true + if (input.sessionID && eventMatchesSession(event, input.sessionID)) return true + return !input.sessionID && !input.traceID + }) + const slice = selectRendererDiagnosticsSlice(events, { + ...input, + appLaunchID: options.appLaunchID, + now: now(), + }) + if (slice.events.length === 0) return emptyRendererDiagnosticsSlice(hasMatchingIdentity ? "expired" : "missing", now()) + return slice + } + + return { + path, + record, + flushRetention, + readEvents, + readEventReport, + slice, + } +} From 5b9eb62f3befbebd61dde91462cb3a48e1317660 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:20:29 +0800 Subject: [PATCH 02/15] feat: expose renderer diagnostics IPC --- packages/app/src/app.tsx | 2 ++ packages/app/src/context/platform.tsx | 21 +++++++++++++++++++ packages/app/src/desktop-api.ts | 8 ++++++- .../src/main/ipc-window-config.test.ts | 7 +++++++ packages/desktop-electron/src/main/ipc.ts | 8 +++++++ .../desktop-electron/src/preload/index.ts | 2 ++ .../desktop-electron/src/preload/types.ts | 13 ++++++++++-- 7 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b51e461b..91ccd843 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -47,6 +47,7 @@ import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context" +import type { RendererDiagnosticInput } from "@/context/platform" import { useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) @@ -87,6 +88,7 @@ declare global { } api?: { setDesktopContext?: (context: DesktopContext) => Promise + emitRendererDiagnostic?: (event: RendererDiagnosticInput) => Promise getAboutInfo?: () => Promise onAboutOpen?: (handler: () => void) => () => void setLspEnabled?: (value: boolean) => Promise diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 21e971e4..5c0caa17 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -49,6 +49,21 @@ export type ReportProblemResult = | { status: "unavailable"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "none" } } | { status: "failed"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "failed" } } +export type RendererDiagnosticInput = { + name: string + level?: "info" | "warn" + monotonic_ms?: number + trace_id?: string + route_session_id?: string + visible_session_id?: string + timeline_session_id?: string + message_id?: string + part_id?: string + data?: Record +} + +export type RendererDiagnosticsExportResult = { ok: true; path: string } | { ok: false; error: string } + export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" @@ -115,6 +130,12 @@ export type Platform = { /** Prepare a problem report and open the configured feedback form (desktop only) */ reportProblem?(input?: ReportProblemInput): Promise + /** Emit a local renderer diagnostics event. Desktop only; no-op on web. */ + emitRendererDiagnostic?(event: RendererDiagnosticInput): Promise + + /** Export the current local renderer diagnostics log. Desktop only. */ + exportDiagnosticsLog?(): Promise + /** Install updates (desktop only) */ update?(): Promise diff --git a/packages/app/src/desktop-api.ts b/packages/app/src/desktop-api.ts index b02c73b1..2338cbe1 100644 --- a/packages/app/src/desktop-api.ts +++ b/packages/app/src/desktop-api.ts @@ -1,2 +1,8 @@ export { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context" -export type { ReportProblemInput, ReportProblemResult, UpdateInfo } from "./context/platform" +export type { + RendererDiagnosticInput, + RendererDiagnosticsExportResult, + ReportProblemInput, + ReportProblemResult, + UpdateInfo, +} from "./context/platform" diff --git a/packages/desktop-electron/src/main/ipc-window-config.test.ts b/packages/desktop-electron/src/main/ipc-window-config.test.ts index e0a0900c..d9d8e530 100644 --- a/packages/desktop-electron/src/main/ipc-window-config.test.ts +++ b/packages/desktop-electron/src/main/ipc-window-config.test.ts @@ -16,4 +16,11 @@ describe("desktop startup IPC", () => { expect(source).toContain('"report-problem"') expect(source).toContain("reportProblem") }) + + test("registers renderer diagnostics channels for sandboxed renderers", () => { + expect(source).toContain('"renderer-diagnostics:record"') + expect(source).toContain('"renderer-diagnostics:export"') + expect(source).toContain("recordRendererDiagnostic") + expect(source).toContain("exportRendererDiagnostics") + }) }) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 1a1a27c7..d8059dbb 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -61,6 +61,8 @@ type Deps = { reportDeepLinkReady: (win: BrowserWindow | null) => void reportCiSmokeReady: () => Promise | void setDesktopContext: (context: DesktopContext, win: BrowserWindow) => Promise | void + recordRendererDiagnostic: (event: unknown, context: { windowID: number }) => Promise | unknown + exportRendererDiagnostics: () => Promise<{ ok: true; path: string } | { ok: false; error: string }> } export function registerIpcHandlers(deps: Deps) { @@ -139,6 +141,12 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("report-problem", (_event: IpcMainInvokeEvent, input?: ReportProblemInput) => deps.reportProblem(input), ) + ipcMain.handle("renderer-diagnostics:record", (event: IpcMainInvokeEvent, input: unknown) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + return deps.recordRendererDiagnostic(input, { windowID: win.id }) + }) + ipcMain.handle("renderer-diagnostics:export", () => deps.exportRendererDiagnostics()) ipcMain.handle("install-update", () => deps.installUpdate()) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) ipcMain.handle("report-deep-link-ready", (event: IpcMainInvokeEvent) => diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index d2c2818c..38e09a99 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -80,6 +80,8 @@ const api: ElectronAPI = { runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), checkUpdate: () => ipcRenderer.invoke("check-update"), reportProblem: (input) => ipcRenderer.invoke("report-problem", input), + emitRendererDiagnostic: (event) => ipcRenderer.invoke("renderer-diagnostics:record", event), + exportDiagnosticsLog: () => ipcRenderer.invoke("renderer-diagnostics:export"), installUpdate: () => ipcRenderer.invoke("install-update"), setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), setLspEnabled: (value: boolean) => ipcRenderer.invoke("lsp-set-enabled", value), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 2a5f456b..a8284039 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -1,7 +1,14 @@ -import type { DesktopContext, ReportProblemInput, ReportProblemResult, UpdateInfo } from "@opencode-ai/app/desktop-api" +import type { + DesktopContext, + RendererDiagnosticInput, + RendererDiagnosticsExportResult, + ReportProblemInput, + ReportProblemResult, + UpdateInfo, +} from "@opencode-ai/app/desktop-api" export type { DesktopContext } -export type { ReportProblemInput, ReportProblemResult, UpdateInfo } +export type { RendererDiagnosticInput, RendererDiagnosticsExportResult, ReportProblemInput, ReportProblemResult, UpdateInfo } export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" } @@ -105,6 +112,8 @@ export type ElectronAPI = { runUpdater: (alertOnFail: boolean) => Promise checkUpdate: () => Promise reportProblem: (input?: ReportProblemInput) => Promise + emitRendererDiagnostic: (event: RendererDiagnosticInput) => Promise + exportDiagnosticsLog: () => Promise installUpdate: () => Promise setBackgroundColor: (color: string) => Promise setLspEnabled: (value: boolean) => Promise From 0d499832adfd0460b199f6d81af6143783bedefc Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:24:00 +0800 Subject: [PATCH 03/15] feat: wire renderer diagnostics export --- packages/desktop-electron/src/main/index.ts | 35 +++++++++++++++++++ .../src/main/menu-labels.test.ts | 1 + .../desktop-electron/src/main/menu-labels.ts | 3 ++ .../src/main/menu-template.test.ts | 12 +++++++ .../src/main/menu-template.ts | 3 ++ 5 files changed, 54 insertions(+) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 3016e19f..cd51da48 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -63,6 +63,11 @@ import { createMenu } from "./menu" import { type MenuLocale } from "./menu-labels" import { readStoredMenuLocale, writeStoredMenuLocale } from "./menu-i18n" import { cleanupProblemReports, problemReportsRoot, writeProblemReportFile } from "./problem-report-files" +import { + createRendererDiagnosticsRecorder, + exportRendererDiagnosticsLog, + rendererDiagnosticsRoot, +} from "./renderer-diagnostics" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { PAWWORK_RUNTIME } from "./runtime-namespace" import { createUpdaterController } from "./updater" @@ -128,6 +133,10 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() const logger = initLogging() const problemReportRoot = problemReportsRoot(app.getPath("userData")) +const rendererDiagnostics = createRendererDiagnosticsRecorder({ + root: rendererDiagnosticsRoot(app.getPath("userData")), + appLaunchID: randomUUID(), +}) const updater = createUpdaterController({ enabled: UPDATER_ENABLED, currentVersion: () => app.getVersion(), @@ -195,6 +204,27 @@ async function sessionExport(context = currentDesktopContext(), signal?: AbortSi } } +async function exportDiagnosticsFromMenu() { + const stamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") + const result = await dialog.showSaveDialog({ + title: "Export diagnostics log", + defaultPath: `pawwork-renderer-diagnostics-${stamp}.jsonl`, + filters: [{ name: "JSONL", extensions: ["jsonl"] }], + }) + if (result.canceled || !result.filePath) return { ok: false as const, error: "cancelled" } + + try { + await exportRendererDiagnosticsLog({ + path: rendererDiagnostics.path, + destination: result.filePath, + }) + return { ok: true as const, path: result.filePath } + } catch (error) { + logger.error("renderer diagnostics export failed", error) + return { ok: false as const, error: error instanceof Error ? error.message : "export_failed" } + } +} + function currentDesktopContext() { return desktopContexts.current(BrowserWindow.getFocusedWindow()?.id) } @@ -477,6 +507,9 @@ function wireMenu() { reportProblem: () => { void reportProblem() }, + exportDiagnosticsLog: () => { + void exportDiagnosticsFromMenu() + }, triggerAbout: (win) => triggerAbout(win), }, focusedMenuLocale()) } @@ -516,6 +549,8 @@ registerIpcHandlers({ runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), checkUpdate: async () => checkUpdate(), reportProblem: (input) => reportProblem(input), + recordRendererDiagnostic: (event, context) => rendererDiagnostics.record(event, context), + exportRendererDiagnostics: exportDiagnosticsFromMenu, installUpdate: async () => installUpdate(), setBackgroundColor: (color) => setBackgroundColor(color), reportDeepLinkReady: (win) => reportDeepLinkReady(win), diff --git a/packages/desktop-electron/src/main/menu-labels.test.ts b/packages/desktop-electron/src/main/menu-labels.test.ts index 32f30d69..cfaa7a51 100644 --- a/packages/desktop-electron/src/main/menu-labels.test.ts +++ b/packages/desktop-electron/src/main/menu-labels.test.ts @@ -42,6 +42,7 @@ describe("menu labels", () => { expect(menuLabel("zh", "file")).toBe("文件") expect(menuLabel("zh", "reloadWindow")).toBe("重新加载窗口") expect(menuLabel("zh", "reportProblem")).toBe("报告问题") + expect(menuLabel("zh", "exportDiagnosticsLog")).toBe("导出诊断日志...") expect(menuLabel("zh", "pawworkOnGithub")).toBe("在 GitHub 上查看爪印") expect(menuLabel("fr" as never, "file")).toBe("File") }) diff --git a/packages/desktop-electron/src/main/menu-labels.ts b/packages/desktop-electron/src/main/menu-labels.ts index 9a48743e..d72ffc8e 100644 --- a/packages/desktop-electron/src/main/menu-labels.ts +++ b/packages/desktop-electron/src/main/menu-labels.ts @@ -24,6 +24,7 @@ export type MenuLabelKey = | "nextProject" | "pawworkOnGithub" | "reportProblem" + | "exportDiagnosticsLog" | "openGithubIssue" export type MenuRoleLabelKey = @@ -74,6 +75,7 @@ const labels: Record> = { nextProject: "Next Project", pawworkOnGithub: "PawWork on GitHub", reportProblem: "Report a Problem", + exportDiagnosticsLog: "Export Diagnostics Log...", openGithubIssue: "Open GitHub Issue", }, zh: { @@ -100,6 +102,7 @@ const labels: Record> = { nextProject: "下一个项目", pawworkOnGithub: "在 GitHub 上查看爪印", reportProblem: "报告问题", + exportDiagnosticsLog: "导出诊断日志...", openGithubIssue: "打开 GitHub Issue", }, } diff --git a/packages/desktop-electron/src/main/menu-template.test.ts b/packages/desktop-electron/src/main/menu-template.test.ts index 0b8557e6..73d8ca3a 100644 --- a/packages/desktop-electron/src/main/menu-template.test.ts +++ b/packages/desktop-electron/src/main/menu-template.test.ts @@ -7,6 +7,7 @@ const stubDeps: MenuTemplateDeps = { reload: () => {}, relaunch: () => {}, reportProblem: () => {}, + exportDiagnosticsLog: () => {}, openExternal: () => {}, newWindow: () => {}, triggerAbout: () => {}, @@ -35,6 +36,17 @@ test("Windows Help submenu contains 'Check for Updates' and 'About PawWork'", () expect(labels).toContain("About PawWork") }) +test("Help submenu exposes diagnostics export", () => { + const windows = buildWindowsMenuTemplate(baseOptions) + const macos = buildMacosMenuTemplate(baseOptions) + expect((windows.find((m) => m.label === "Help")?.submenu ?? []).map((s) => s.label)).toContain( + "Export Diagnostics Log...", + ) + expect((macos.find((m) => m.label === "Help")?.submenu ?? []).map((s) => s.label)).toContain( + "Export Diagnostics Log...", + ) +}) + test("Windows New Session accelerator matches macOS (CmdOrCtrl+Shift+S)", () => { const tpl = buildWindowsMenuTemplate(baseOptions) const file = tpl.find((m) => m.label === "File") diff --git a/packages/desktop-electron/src/main/menu-template.ts b/packages/desktop-electron/src/main/menu-template.ts index 345a6e88..842cf147 100644 --- a/packages/desktop-electron/src/main/menu-template.ts +++ b/packages/desktop-electron/src/main/menu-template.ts @@ -19,6 +19,7 @@ export type MenuTemplateDeps = { reload: () => void relaunch: () => void reportProblem: () => void + exportDiagnosticsLog: () => void openExternal: (url: string) => void newWindow: () => void triggerAbout: (browserWindow?: BrowserWindow) => void @@ -45,6 +46,7 @@ export function buildMacosMenuTemplate(options: BuildMenuOptions): MenuItemTempl helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) } + helpSubmenu.push({ label: t("exportDiagnosticsLog"), click: () => deps.exportDiagnosticsLog() }) helpSubmenu.push({ label: t("openGithubIssue"), click: () => deps.openExternal(PAWWORK_GITHUB_ISSUE_URL) }) return [ @@ -157,6 +159,7 @@ export function buildWindowsMenuTemplate(options: BuildMenuOptions): MenuItemTem if (feedbackEnabled) { helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) } + helpSubmenu.push({ label: t("exportDiagnosticsLog"), click: () => deps.exportDiagnosticsLog() }) helpSubmenu.push({ label: t("openGithubIssue"), click: () => deps.openExternal(PAWWORK_GITHUB_ISSUE_URL) }) helpSubmenu.push({ type: "separator" }) helpSubmenu.push({ label: t("checkForUpdates"), click: () => deps.checkForUpdates() }) From 1bf8744b94310ecfdcfc1309946bfef009ec7b39 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:29:12 +0800 Subject: [PATCH 04/15] feat: emit session renderer diagnostics --- .../app/src/components/prompt-input/submit.ts | 17 +++ .../src/context/renderer-diagnostics.test.ts | 46 ++++++ .../app/src/context/renderer-diagnostics.ts | 137 ++++++++++++++++++ packages/app/src/pages/session.tsx | 37 +++++ .../src/pages/session/message-timeline.tsx | 63 +++++++- .../session/use-session-scroll-dock.test.ts | 27 ++++ .../pages/session/use-session-scroll-dock.ts | 21 ++- .../use-session-timeline-interaction.ts | 15 ++ 8 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/context/renderer-diagnostics.test.ts create mode 100644 packages/app/src/context/renderer-diagnostics.ts diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index f6c45f0d..417ca89f 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -12,6 +12,7 @@ import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" +import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { promptProbe } from "@/testing/prompt" @@ -521,6 +522,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { removeCommentItems(commentItems) clearInput() + void emitRendererDiagnostic({ + name: "session.action.submit", + trace_id: messageID, + route_session_id: session.id, + visible_session_id: session.id, + timeline_session_id: session.id, + data: { + action: "submit", + provider: model.providerID, + model: model.modelID, + endpoint_kind: "prompt", + prompt_length: input.promptLength(currentPrompt), + image_count: images.length, + comment_count: input.commentCount(), + }, + }) const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) diff --git a/packages/app/src/context/renderer-diagnostics.test.ts b/packages/app/src/context/renderer-diagnostics.test.ts new file mode 100644 index 00000000..14eca186 --- /dev/null +++ b/packages/app/src/context/renderer-diagnostics.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createRendererDiagnosticsEmitter, createSessionPerformanceDiagnostics } from "./renderer-diagnostics" +import type { RendererDiagnosticInput } from "./platform" + +describe("renderer diagnostics", () => { + test("emits through the desktop API with monotonic time", async () => { + const events: RendererDiagnosticInput[] = [] + const emit = createRendererDiagnosticsEmitter({ + api: { + emitRendererDiagnostic: async (event) => { + events.push(event) + }, + }, + now: () => 42, + }) + + await emit({ name: "session.action.submit", route_session_id: "session-1" }) + + expect(events).toEqual([{ name: "session.action.submit", route_session_id: "session-1", monotonic_ms: 42 }]) + }) + + test("session performance diagnostics registers cleanup-safe observers", () => { + const events: RendererDiagnosticInput[] = [] + createRoot((dispose) => { + createSessionPerformanceDiagnostics({ + routeSessionID: () => "route-session", + visibleSessionID: () => "visible-session", + timelineSessionID: () => "timeline-session", + emit: (event) => { + events.push(event) + }, + }) + + document.dispatchEvent(new Event("visibilitychange")) + dispose() + }) + + expect(events[0]).toMatchObject({ + name: "renderer.visibility", + route_session_id: "route-session", + visible_session_id: "visible-session", + timeline_session_id: "timeline-session", + }) + }) +}) diff --git a/packages/app/src/context/renderer-diagnostics.ts b/packages/app/src/context/renderer-diagnostics.ts new file mode 100644 index 00000000..2bcd8814 --- /dev/null +++ b/packages/app/src/context/renderer-diagnostics.ts @@ -0,0 +1,137 @@ +import { onCleanup } from "solid-js" +import type { Accessor } from "solid-js" +import type { RendererDiagnosticInput } from "@/context/platform" + +type DiagnosticsApi = { + emitRendererDiagnostic?(event: RendererDiagnosticInput): Promise +} + +type PerformanceWithMemory = Performance & { + memory?: { + usedJSHeapSize?: number + } +} + +export function createRendererDiagnosticsEmitter(input: { + api?: DiagnosticsApi + now?: () => number +}) { + return async (event: RendererDiagnosticInput) => { + const emit = input.api?.emitRendererDiagnostic + if (!emit) return + await emit({ + ...event, + monotonic_ms: event.monotonic_ms ?? input.now?.() ?? performance.now(), + }) + } +} + +export async function emitRendererDiagnostic(event: RendererDiagnosticInput) { + const api = typeof window === "undefined" ? undefined : window.api + await createRendererDiagnosticsEmitter({ api })(event) +} + +export function createSessionPerformanceDiagnostics(input: { + routeSessionID: Accessor + visibleSessionID: Accessor + timelineSessionID: Accessor + emit?: (event: RendererDiagnosticInput) => Promise | void +}) { + const emit = input.emit ?? emitRendererDiagnostic + let running = true + let frame: number | undefined + let interval: number | undefined + let lastFrame = performance.now() + let frameCount = 0 + let jankCount = 0 + let maxFrameGap = 0 + let longTaskMax = 0 + let longTaskBlock = 0 + let cls = 0 + let longTaskObserver: PerformanceObserver | undefined + let layoutShiftObserver: PerformanceObserver | undefined + + const baseEvent = () => ({ + route_session_id: input.routeSessionID(), + visible_session_id: input.visibleSessionID(), + timeline_session_id: input.timelineSessionID(), + }) + + const tick = (now: number) => { + const gap = now - lastFrame + lastFrame = now + frameCount += 1 + if (gap > 50) jankCount += 1 + maxFrameGap = Math.max(maxFrameGap, gap) + if (running) frame = requestAnimationFrame(tick) + } + + const flush = () => { + const memory = performance as PerformanceWithMemory + void emit({ + name: "renderer.perf.sample", + ...baseEvent(), + data: { + fps: frameCount, + frame_gap_ms: Math.round(maxFrameGap), + jank_count: jankCount, + long_task_max_ms: Math.round(longTaskMax), + long_task_block_ms: Math.round(longTaskBlock), + cls, + heap_used_mb: memory.memory?.usedJSHeapSize + ? Math.round(memory.memory.usedJSHeapSize / 1024 / 1024) + : undefined, + }, + }) + frameCount = 0 + jankCount = 0 + maxFrameGap = 0 + longTaskMax = 0 + longTaskBlock = 0 + cls = 0 + } + + if (typeof PerformanceObserver !== "undefined") { + try { + longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + longTaskMax = Math.max(longTaskMax, entry.duration) + longTaskBlock += entry.duration + } + }) + longTaskObserver.observe({ entryTypes: ["longtask"] }) + } catch {} + + try { + layoutShiftObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries() as PerformanceEntry[]) { + const value = (entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean }).value + const hadRecentInput = (entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput + if (!hadRecentInput && typeof value === "number") cls += value + } + }) + layoutShiftObserver.observe({ entryTypes: ["layout-shift"] }) + } catch {} + } + + frame = requestAnimationFrame(tick) + interval = window.setInterval(flush, 5_000) + + const onVisibilityChange = () => { + void emit({ + name: "renderer.visibility", + ...baseEvent(), + data: { visibility: document.visibilityState }, + }) + } + document.addEventListener("visibilitychange", onVisibilityChange) + + onCleanup(() => { + running = false + if (frame !== undefined) cancelAnimationFrame(frame) + if (interval !== undefined) window.clearInterval(interval) + longTaskObserver?.disconnect() + layoutShiftObserver?.disconnect() + document.removeEventListener("visibilitychange", onVisibilityChange) + }) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6f35c58e..d22ca606 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -11,6 +11,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePrompt } from "@/context/prompt" +import { createSessionPerformanceDiagnostics, emitRendererDiagnostic } from "@/context/renderer-diagnostics" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" @@ -120,6 +121,42 @@ export default function Page() { const timelineHistoryMore = timeline.historyMore const timelineHistoryLoading = timeline.historyLoading const lastUserMessage = timeline.lastUserMessage + const countMessageParts = (message: unknown) => { + if (!message || typeof message !== "object" || !("parts" in message)) return 0 + const parts = (message as { parts?: unknown }).parts + return Array.isArray(parts) ? parts.length : 0 + } + + createEffect(() => { + const routeSessionID = params.id + const visibleSessionID = timelineSessionID() + const messages = timelineMessages() + void emitRendererDiagnostic({ + name: "session.view.state", + route_session_id: routeSessionID, + visible_session_id: visibleSessionID, + timeline_session_id: visibleSessionID, + data: { + route_session_id: routeSessionID, + visible_session_id: visibleSessionID, + timeline_session_id: visibleSessionID, + route_ready: timelineMessagesReady(), + visible_ready: timelineMessagesReady(), + transitioning: !!routeSessionID && !!visibleSessionID && routeSessionID !== visibleSessionID, + message_count: messages.length, + part_count: messages.reduce((count, message) => count + countMessageParts(message), 0), + history_more: timelineHistoryMore(), + history_loading: timelineHistoryLoading(), + }, + }) + }) + + createSessionPerformanceDiagnostics({ + routeSessionID: () => params.id, + visibleSessionID: timelineSessionID, + timelineSessionID, + emit: emitRendererDiagnostic, + }) createEffect(() => { const tab = activeFileTab() diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 4f59a4a8..0a4a36ca 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,4 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, onMount, Show, Index, type JSX, createSignal } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" @@ -24,6 +24,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { usePlatform } from "@/context/platform" +import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" import { useServer } from "@/context/server" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" @@ -252,12 +253,55 @@ export function MessageTimeline(props: { const exportAvailable = createMemo(() => !!platform.exportSession && server.current?.type === "sidecar") const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const visibleRangeData = () => { + const ids = rendered() + return { + rendered_count: ids.length, + visible_first_message_id: ids[0], + visible_last_message_id: ids.at(-1), + } + } const sessionKey = createMemo(() => props.sessionKey) const sessionID = createMemo(() => props.sessionID) const sessionMessages = createMemo(() => props.sessionMessages) const webSearchToastSurfaced = new Set() const webSearchPartCursor = new Map() const webSearchPendingParts = new Map>() + + onMount(() => { + void emitRendererDiagnostic({ + name: "session.timeline.mount", + route_session_id: params.id, + visible_session_id: props.sessionID, + timeline_session_id: props.sessionID, + data: visibleRangeData(), + }) + }) + + onCleanup(() => { + void emitRendererDiagnostic({ + name: "session.timeline.unmount", + route_session_id: params.id, + visible_session_id: props.sessionID, + timeline_session_id: props.sessionID, + data: visibleRangeData(), + }) + }) + + createEffect( + on( + () => rendered().join("\u0000"), + () => { + void emitRendererDiagnostic({ + name: "session.timeline.visible", + route_session_id: params.id, + visible_session_id: props.sessionID, + timeline_session_id: props.sessionID, + data: visibleRangeData(), + }) + }, + ), + ) let webSearchToastSessionID: string | undefined createEffect(() => { @@ -692,6 +736,23 @@ export function MessageTimeline(props: { onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() + const el = e.currentTarget + const max = el.scrollHeight - el.clientHeight + void emitRendererDiagnostic({ + name: "session.scroll.sample", + route_session_id: params.id, + visible_session_id: props.sessionID, + timeline_session_id: props.sessionID, + data: { + scroll_top: el.scrollTop, + scroll_height: el.scrollHeight, + client_height: el.clientHeight, + distance_from_bottom: max - el.scrollTop, + user_scrolled: props.hasScrollGesture(), + jump_button_visible: props.scroll.overflow && props.scroll.jump && !staging.isStaging(), + ...visibleRangeData(), + }, + }) if (!props.hasScrollGesture()) return props.onUserScroll() props.onAutoScrollHandleScroll() diff --git a/packages/app/src/pages/session/use-session-scroll-dock.test.ts b/packages/app/src/pages/session/use-session-scroll-dock.test.ts index 9af69ddb..6774c276 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.test.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.test.ts @@ -240,14 +240,27 @@ describe("session scroll dock", () => { createRoot((dispose) => { const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height") const promptDock = makeMeasuredDiv(120) + const events: Array<{ + composerHeight: number + previousComposerHeight: number + scrollTop?: number + distanceFromBottom?: number + }> = [] + const scroller = makeScroller({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 600, + }) try { const scrollDock = createSessionScrollDock({ clearMessageHash: () => undefined, clearActiveMessage: () => undefined, fill: () => undefined, + onDockHeightChange: (event) => events.push(event), }) + scrollDock.setScrollRef(scroller.el) scrollDock.setPromptDockRef(promptDock.el) expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("120px") @@ -255,6 +268,20 @@ describe("session scroll dock", () => { triggerResize(promptDock.el) expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("220px") + expect(events).toEqual([ + { + composerHeight: 120, + previousComposerHeight: 0, + scrollTop: 600, + distanceFromBottom: 0, + }, + { + composerHeight: 220, + previousComposerHeight: 120, + scrollTop: 600, + distanceFromBottom: 0, + }, + ]) } finally { dispose() if (previousDockHeight) diff --git a/packages/app/src/pages/session/use-session-scroll-dock.ts b/packages/app/src/pages/session/use-session-scroll-dock.ts index 00b19bfe..408909a2 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -80,6 +80,12 @@ export function createSessionScrollDock(input: { clearMessageHash: () => void clearActiveMessage: () => void fill: () => void + onDockHeightChange?: (event: { + composerHeight: number + previousComposerHeight: number + scrollTop?: number + distanceFromBottom?: number + }) => void }) { const autoScroll = createAutoScroll({ working: () => true, @@ -148,9 +154,14 @@ export function createSessionScrollDock(input: { } const updateDockHeight = (next: number) => { + const previousDockHeight = dockHeight + const scrollTop = scroller?.scrollTop + const distanceFromBottom = scroller + ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + : undefined dockHeight = syncComposerDockHeight({ el: scroller, - previousDockHeight: dockHeight, + previousDockHeight, nextDockHeight: next, userScrolled: autoScroll.userScrolled(), setCssHeight: (value) => document.documentElement.style.setProperty("--composer-dock-height", `${value}px`), @@ -158,6 +169,14 @@ export function createSessionScrollDock(input: { scheduleScrollState, fill: input.fill, }) + if (dockHeight !== previousDockHeight) { + input.onDockHeightChange?.({ + composerHeight: dockHeight, + previousComposerHeight: previousDockHeight, + scrollTop, + distanceFromBottom, + }) + } } const measurePromptDockHeight = () => Math.ceil(promptDock?.getBoundingClientRect().height ?? 0) diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts index 0bdfb25e..cf7b5027 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -1,4 +1,5 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" +import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" import { createSessionActiveMessage } from "@/pages/session/use-session-active-message" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { createSessionHistoryBackfill } from "@/pages/session/use-session-history-backfill" @@ -26,6 +27,20 @@ export function createSessionTimelineInteraction(input: { clearMessageHash: () => clearMessageHash(), clearActiveMessage: () => activeMessage?.clearActiveMessage(), fill: () => historyBackfill?.fill(), + onDockHeightChange: (event) => { + void emitRendererDiagnostic({ + name: "session.layout.composer_dock", + route_session_id: input.routeSessionID(), + visible_session_id: input.sessionID(), + timeline_session_id: input.sessionID(), + data: { + composer_height: event.composerHeight, + previous_composer_height: event.previousComposerHeight, + scroll_top: event.scrollTop, + distance_from_bottom: event.distanceFromBottom, + }, + }) + }, }) const autoScroll = scrollDock.autoScroll const resumeScroll = scrollDock.resumeScroll From 91e2928d94584ddd824fecc522c7b5a6c70bf834 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:32:57 +0800 Subject: [PATCH 05/15] feat: attach renderer diagnostics to exports --- .../src/main/feedback.test.ts | 28 ++++++ .../desktop-electron/src/main/feedback.ts | 13 ++- packages/desktop-electron/src/main/index.ts | 44 ++++++++- .../src/main/ipc-window-config.test.ts | 1 + packages/desktop-electron/src/main/ipc.ts | 30 +++++- .../src/main/problem-report.test.ts | 96 ++++++++++++++++++- .../src/main/problem-report.ts | 72 ++++++++++++++ .../src/main/server-client.test.ts | 28 ++++++ .../src/main/server-client.ts | 10 ++ 9 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 packages/desktop-electron/src/main/server-client.test.ts diff --git a/packages/desktop-electron/src/main/feedback.test.ts b/packages/desktop-electron/src/main/feedback.test.ts index dd423e3e..3fc966f0 100644 --- a/packages/desktop-electron/src/main/feedback.test.ts +++ b/packages/desktop-electron/src/main/feedback.test.ts @@ -63,6 +63,19 @@ function setup(overrides: Partial[0]> = diagnostics: () => diagnostics, logTail: () => "log tail\n[error] launch failed", sessionExport: async () => ({ status: "none" }), + rendererDiagnostics: async () => ({ + status: "ok", + source: "renderer-diagnostics", + generated_at: "2026-04-23T01:02:03.004Z", + events: [], + summary: { + event_count: 0, + incident_count: 0, + statuses: ["ok"], + omitted_event_count: 0, + omitted_bytes: 0, + }, + }), onHandledError: (message) => { calls.handledErrors.push(message) }, @@ -212,6 +225,21 @@ describe("feedback handler", () => { expect(subject.calls.opened).toBe("https://example.com/form") }) + test("renderer diagnostics failure still produces report artifacts", async () => { + const subject = setup({ + rendererDiagnostics: async () => { + throw new Error("diagnostics unavailable") + }, + }) + + await subject.handler() + + expect(subject.calls.handledErrors).toContain("renderer diagnostics slice failed") + expect(subject.calls.savedMarkdown).toContain('"status": "write_failed"') + expect(subject.calls.copied).toContain("Renderer diagnostics: write_failed") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + test("slow session export times out and still produces report artifacts", async () => { let aborted = false const subject = setup({ diff --git a/packages/desktop-electron/src/main/feedback.ts b/packages/desktop-electron/src/main/feedback.ts index b97e7a16..49b93be2 100644 --- a/packages/desktop-electron/src/main/feedback.ts +++ b/packages/desktop-electron/src/main/feedback.ts @@ -8,6 +8,7 @@ import { type RendererErrorDetails, type SessionExport, } from "./problem-report" +import { emptyRendererDiagnosticsSlice, type RendererDiagnosticsSlice } from "./renderer-diagnostics" import type { MenuLocale } from "./menu-labels" import { errorMessage } from "./error" @@ -39,6 +40,7 @@ type FeedbackDeps = { diagnostics: (context?: unknown) => ProblemReportDiagnostics logTail: () => string sessionExport: (context?: unknown, signal?: AbortSignal) => Promise + rendererDiagnostics: (context?: unknown) => Promise onHandledError?: (message: string, error: unknown) => void onError?: (error: unknown) => Promise | void } @@ -193,6 +195,7 @@ export function createFeedbackHandler(deps: FeedbackDeps) { let diagnostics: ProblemReportDiagnostics let logTail = "" let sessionExport: SessionExport = { status: "none" } + let rendererDiagnostics: RendererDiagnosticsSlice = emptyRendererDiagnosticsSlice("missing", new Date(generatedAt)) let savedReport: SavedReport | undefined let fullReportFailure: string | undefined @@ -215,10 +218,17 @@ export function createFeedbackHandler(deps: FeedbackDeps) { sessionExport = { status: "failed", error: errorMessage(error) } } + try { + rendererDiagnostics = await deps.rendererDiagnostics(context) + } catch (error) { + deps.onHandledError?.("renderer diagnostics slice failed", error) + rendererDiagnostics = emptyRendererDiagnosticsSlice("write_failed", new Date(generatedAt)) + } + if (!fullReportFailure) { try { const report = buildProblemReport( - { diagnostics, logTail, sessionExport, rendererError: input.rendererError }, + { diagnostics, logTail, sessionExport, rendererDiagnostics, rendererError: input.rendererError }, { reportId: id, generatedAt, maxBytes: DEFAULT_PROBLEM_REPORT_MAX_BYTES }, ) savedReport = await deps.saveReport({ reportId: id, generatedAt, markdown: report.markdown }) @@ -236,6 +246,7 @@ export function createFeedbackHandler(deps: FeedbackDeps) { fullReportStatus: savedReport ? "ready" : "failed", failureReason: fullReportFailure, recentErrors: recentKeyErrors(logTail), + rendererDiagnostics, rendererError: input.rendererError, }) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index cd51da48..07fe8508 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -67,6 +67,7 @@ import { createRendererDiagnosticsRecorder, exportRendererDiagnosticsLog, rendererDiagnosticsRoot, + SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, } from "./renderer-diagnostics" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { PAWWORK_RUNTIME } from "./runtime-namespace" @@ -229,14 +230,39 @@ function currentDesktopContext() { return desktopContexts.current(BrowserWindow.getFocusedWindow()?.id) } +type FeedbackRuntimeContext = { + desktop: DesktopContext + windowID?: number +} + +function currentFeedbackRuntimeContext(): FeedbackRuntimeContext { + const win = BrowserWindow.getFocusedWindow() + return { + desktop: desktopContexts.current(win?.id), + windowID: win?.id, + } +} + +function isFeedbackRuntimeContext(value: unknown): value is FeedbackRuntimeContext { + return Boolean(value && typeof value === "object" && "desktop" in value) +} + +function feedbackRuntimeContext(context: unknown): FeedbackRuntimeContext { + if (context === undefined) return currentFeedbackRuntimeContext() + if (isFeedbackRuntimeContext(context)) return context + return { + desktop: normalizeDesktopContextPayload(context, menuLocale), + } +} + function feedbackContext(context: unknown): DesktopContext { - return context === undefined ? currentDesktopContext() : normalizeDesktopContextPayload(context, menuLocale) + return feedbackRuntimeContext(context).desktop } const reportProblem = createFeedbackHandler({ feedbackUrl: FEEDBACK_FORM_URL, reportRoot: problemReportRoot, - context: currentDesktopContext, + context: currentFeedbackRuntimeContext, confirm: async (context) => { const labels = feedbackDialogLabels(context === undefined ? menuLocale : feedbackContext(context).locale) const response = await dialog.showMessageBox({ @@ -270,6 +296,14 @@ const reportProblem = createFeedbackHandler({ diagnostics: (context) => diagnostics(feedbackContext(context)), logTail: tail, sessionExport: (context, signal) => sessionExport(feedbackContext(context), signal), + rendererDiagnostics: (context) => { + const runtimeContext = feedbackRuntimeContext(context) + return rendererDiagnostics.slice({ + sessionID: runtimeContext.desktop.sessionID, + windowID: runtimeContext.windowID, + maxBytes: SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, + }) + }, onHandledError: (message, error) => logger.error(message, error), onError: async (error) => { logger.error("problem report failed", error) @@ -551,6 +585,12 @@ registerIpcHandlers({ reportProblem: (input) => reportProblem(input), recordRendererDiagnostic: (event, context) => rendererDiagnostics.record(event, context), exportRendererDiagnostics: exportDiagnosticsFromMenu, + rendererDiagnosticsSlice: ({ sessionID, windowID, maxBytes }) => + rendererDiagnostics.slice({ + sessionID, + windowID, + maxBytes, + }), installUpdate: async () => installUpdate(), setBackgroundColor: (color) => setBackgroundColor(color), reportDeepLinkReady: (win) => reportDeepLinkReady(win), diff --git a/packages/desktop-electron/src/main/ipc-window-config.test.ts b/packages/desktop-electron/src/main/ipc-window-config.test.ts index d9d8e530..d9fb257a 100644 --- a/packages/desktop-electron/src/main/ipc-window-config.test.ts +++ b/packages/desktop-electron/src/main/ipc-window-config.test.ts @@ -22,5 +22,6 @@ describe("desktop startup IPC", () => { expect(source).toContain('"renderer-diagnostics:export"') expect(source).toContain("recordRendererDiagnostic") expect(source).toContain("exportRendererDiagnostics") + expect(source).toContain("rendererDiagnosticsSlice") }) }) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index d8059dbb..7ed244b4 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -17,7 +17,12 @@ import type { } from "../preload/types" import { attachmentPathMime } from "./attachment-mime" import { getStore } from "./store" -import { fetchExport } from "./server-client" +import { attachRendererDiagnosticsToSessionExport, fetchExport } from "./server-client" +import { + emptyRendererDiagnosticsSlice, + SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, + type RendererDiagnosticsSlice, +} from "./renderer-diagnostics" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -63,6 +68,12 @@ type Deps = { setDesktopContext: (context: DesktopContext, win: BrowserWindow) => Promise | void recordRendererDiagnostic: (event: unknown, context: { windowID: number }) => Promise | unknown exportRendererDiagnostics: () => Promise<{ ok: true; path: string } | { ok: false; error: string }> + rendererDiagnosticsSlice: (input: { + sessionID: string + directory: string + windowID?: number + maxBytes: number + }) => Promise } export function registerIpcHandlers(deps: Deps) { @@ -333,7 +344,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle( "export-session", async ( - _event: IpcMainInvokeEvent, + event: IpcMainInvokeEvent, sessionID: string, directory: string, defaultName?: string, @@ -345,6 +356,19 @@ export function registerIpcHandlers(deps: Deps) { const server = await deps.getServerReadyData() const fetched = await fetchExport(server, directory, sessionID) if (!fetched.ok) return fetched + let rendererDiagnostics: RendererDiagnosticsSlice + try { + const win = BrowserWindow.fromWebContents(event.sender) + rendererDiagnostics = await deps.rendererDiagnosticsSlice({ + sessionID, + directory, + windowID: win?.id, + maxBytes: SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, + }) + } catch { + rendererDiagnostics = emptyRendererDiagnosticsSlice("write_failed", new Date()) + } + const exportBody = attachRendererDiagnosticsToSessionExport(fetched.body, rendererDiagnostics) const fallbackStamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") const result = await dialog.showSaveDialog({ @@ -355,7 +379,7 @@ export function registerIpcHandlers(deps: Deps) { if (result.canceled || !result.filePath) return { ok: false, error: "cancelled" } as const try { - await fs.writeFile(result.filePath, fetched.body, "utf8") + await fs.writeFile(result.filePath, exportBody, "utf8") return { ok: true, path: result.filePath } as const } catch (err) { return { ok: false, error: (err as Error).message } as const diff --git a/packages/desktop-electron/src/main/problem-report.test.ts b/packages/desktop-electron/src/main/problem-report.test.ts index fb510873..6e2fa944 100644 --- a/packages/desktop-electron/src/main/problem-report.test.ts +++ b/packages/desktop-electron/src/main/problem-report.test.ts @@ -28,6 +28,33 @@ const base = { }, ], }, + rendererDiagnostics: { + status: "ok" as const, + source: "renderer-diagnostics" as const, + generated_at: "2026-04-23T01:02:03.004Z", + events: [ + { + ts: "2026-04-23T01:02:03.004Z", + "event.name": "session.action.submit", + level: "info" as const, + app_launch_id: "launch_1", + window_id: "1", + monotonic_ms: 10, + route_session_id: "ses_1", + visible_session_id: "ses_1", + timeline_session_id: "ses_1", + trace_id: "msg_1", + data: { action: "submit", endpoint_kind: "prompt" }, + }, + ], + summary: { + event_count: 1, + incident_count: 0, + statuses: ["ok" as const], + omitted_event_count: 0, + omitted_bytes: 0, + }, + }, } describe("problem report", () => { @@ -54,6 +81,24 @@ describe("problem report", () => { expect(payload.reportId).toBe(report.reportId) expect(payload.diagnostics.sessionID).toBe("ses_1") expect(payload.sessionExport.status).toBe("ok") + expect(payload.rendererDiagnostics?.status).toBe("ok") + expect(payload.rendererDiagnostics?.summary.event_count).toBe(1) + }) + + test("summarizes renderer diagnostics without exposing event payloads", () => { + const summary = buildProblemReportSummary({ + reportId: "pwr_20260423_abc123", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: base.diagnostics, + reportFileName: "pawwork-problem-report.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report.md", + fullReportStatus: "ready", + recentErrors: [], + rendererDiagnostics: base.rendererDiagnostics, + }) + + expect(summary).toContain("Renderer diagnostics: ok, events=1, incidents=0") + expect(summary).not.toContain("session.action.submit") }) test("builds a short summary without full logs, paths, session export, tool output, or snippets", () => { @@ -315,6 +360,44 @@ describe("problem report", () => { expect(payload.rendererError?.details.length).toBeLessThan(details.length) }) + test("truncates renderer diagnostics events to honor max bytes", () => { + const report = buildProblemReport( + { + ...base, + logTail: "", + sessionExport: { status: "none" }, + rendererDiagnostics: { + ...base.rendererDiagnostics, + events: [ + ...Array.from({ length: 50 }, (_, index) => ({ + ...base.rendererDiagnostics.events[0], + trace_id: `msg_${index}`, + data: { action: "submit", endpoint_kind: "prompt", prompt_length: index }, + })), + { + ...base.rendererDiagnostics.events[0], + "event.name": "incident.session_scroll_jump_to_top", + data: { scroll_top: 0, distance_from_bottom: 500, client_height: 400, user_scrolled: false }, + }, + ], + summary: { + ...base.rendererDiagnostics.summary, + event_count: 51, + incident_count: 1, + }, + }, + }, + { maxBytes: 5_000 }, + ) + + expect(Buffer.byteLength(report.markdown, "utf8")).toBeLessThanOrEqual(5_000) + const payload = parseProblemReportPayload(report.markdown) + expect(payload.rendererDiagnostics?.status).toBe("truncated") + expect(payload.rendererDiagnostics?.truncation).toBeUndefined() + expect(payload.rendererDiagnostics?.summary.omitted_event_count).toBeGreaterThan(0) + expect(payload.truncation.omittedRendererDiagnosticsBytes).toBeGreaterThan(0) + }) + test("sanitizes non-json session export values", () => { const circular: Record = { id: "root" } circular.self = circular @@ -415,6 +498,7 @@ describe("problem report", () => { omittedLogBytes: 0, omittedSessionInfoBytes: 0, omittedFailedExportErrorBytes: 0, + omittedRendererDiagnosticsBytes: 0, omittedDiagnosticsBytes: 0, }, }), @@ -443,6 +527,7 @@ describe("problem report", () => { omittedLogBytes: 0, omittedSessionInfoBytes: 0, omittedFailedExportErrorBytes: 0, + omittedRendererDiagnosticsBytes: 0, omittedDiagnosticsBytes: 0, }, }), @@ -470,6 +555,7 @@ describe("problem report", () => { omittedLogBytes: 0, omittedSessionInfoBytes: 0, omittedFailedExportErrorBytes: 0, + omittedRendererDiagnosticsBytes: 0, omittedDiagnosticsBytes: 0, }, }), @@ -495,6 +581,7 @@ describe("problem report", () => { omittedLogBytes: 0, omittedSessionInfoBytes: 0, omittedFailedExportErrorBytes: 0, + omittedRendererDiagnosticsBytes: 0, omittedDiagnosticsBytes: 0, }, }), @@ -513,7 +600,14 @@ describe("problem report", () => { diagnostics: base.diagnostics, logTail: "", sessionExport: { status: "none" }, - truncation: { omittedMessages: 0, omittedLogBytes: 0, omittedSessionInfoBytes: 0, omittedDiagnosticsBytes: 0 }, + truncation: { + omittedMessages: 0, + omittedLogBytes: 0, + omittedSessionInfoBytes: 0, + omittedFailedExportErrorBytes: 0, + omittedRendererDiagnosticsBytes: 0, + omittedDiagnosticsBytes: 0, + }, }), "```", ].join("\n") diff --git a/packages/desktop-electron/src/main/problem-report.ts b/packages/desktop-electron/src/main/problem-report.ts index 1cbc2a6a..d61432d4 100644 --- a/packages/desktop-electron/src/main/problem-report.ts +++ b/packages/desktop-electron/src/main/problem-report.ts @@ -1,5 +1,7 @@ // Bound full report payloads while preserving recent logs and session snippets for diagnosis. // Default full report payload limit: 5 MB. +import type { RendererDiagnosticEvent, RendererDiagnosticsSlice } from "./renderer-diagnostics" + export const DEFAULT_PROBLEM_REPORT_MAX_BYTES = 5 * 1024 * 1024 const SUMMARY_ERROR_LINE_MAX_CHARS = 220 const SUMMARY_FAILURE_REASON_MAX_CHARS = 80 @@ -44,6 +46,7 @@ type Input = { diagnostics: ProblemReportDiagnostics logTail: string sessionExport: SessionExport + rendererDiagnostics?: RendererDiagnosticsSlice rendererError?: RendererErrorDetails } @@ -60,12 +63,14 @@ type Payload = { diagnostics: ProblemReportDiagnostics logTail: string rendererError?: RendererErrorDetails + rendererDiagnostics?: RendererDiagnosticsSlice sessionExport: SafeSessionExport truncation: { omittedMessages: number omittedLogBytes: number omittedSessionInfoBytes: number omittedFailedExportErrorBytes: number + omittedRendererDiagnosticsBytes: number omittedDiagnosticsBytes: number } } @@ -149,6 +154,36 @@ function sanitizeSessionExport(sessionExport: SessionExport): SafeSessionExport } } +function isProtectedRendererDiagnosticEvent(event: RendererDiagnosticEvent) { + return event["event.name"].startsWith("incident.") || event["event.name"] === "session.identity.transition" +} + +function withRendererDiagnosticsEvents( + rendererDiagnostics: RendererDiagnosticsSlice, + events: RendererDiagnosticEvent[], + omittedBytes: number, +): RendererDiagnosticsSlice { + const omittedEventCount = rendererDiagnostics.events.length - events.length + return { + ...rendererDiagnostics, + status: omittedEventCount > 0 ? "truncated" : rendererDiagnostics.status, + events, + summary: { + ...rendererDiagnostics.summary, + event_count: events.length, + incident_count: events.filter((event) => event["event.name"].startsWith("incident.")).length, + omitted_event_count: rendererDiagnostics.summary.omitted_event_count + omittedEventCount, + omitted_bytes: rendererDiagnostics.summary.omitted_bytes + omittedBytes, + statuses: Array.from( + new Set([ + ...rendererDiagnostics.summary.statuses, + ...(omittedEventCount > 0 ? (["truncated"] as const) : []), + ]), + ), + }, + } +} + function truncateString(value: string, limit: number) { return value.length > limit ? value.slice(0, limit) : value } @@ -183,11 +218,13 @@ export function buildProblemReport(input: Input, options: Options = {}) { let messages = sessionMessages(sessionExport) let sessionInfo = sessionExport.status === "ok" ? sessionExport.info : undefined let failedExportError = sessionExport.status === "failed" ? sessionExport.error : undefined + let rendererDiagnostics = input.rendererDiagnostics let rendererError = input.rendererError let omittedMessages = 0 let omittedLogBytes = 0 let omittedSessionInfoBytes = 0 let omittedFailedExportErrorBytes = 0 + let omittedRendererDiagnosticsBytes = 0 let omittedDiagnosticsBytes = 0 const makePayload = (): Payload => ({ @@ -197,6 +234,7 @@ export function buildProblemReport(input: Input, options: Options = {}) { diagnostics, logTail, ...(rendererError ? { rendererError } : {}), + ...(rendererDiagnostics ? { rendererDiagnostics } : {}), sessionExport: withFailedExportError( withMessages(withSessionInfo(sessionExport, sessionInfo ?? null), messages), failedExportError, @@ -206,6 +244,7 @@ export function buildProblemReport(input: Input, options: Options = {}) { omittedLogBytes, omittedSessionInfoBytes, omittedFailedExportErrorBytes, + omittedRendererDiagnosticsBytes, omittedDiagnosticsBytes, }, }) @@ -264,6 +303,18 @@ export function buildProblemReport(input: Input, options: Options = {}) { output = markdown(makePayload()) } + if (bytes(output) > maxBytes && rendererDiagnostics) { + const original = rendererDiagnostics + let events = [...original.events] + while (bytes(output) > maxBytes && events.length > 0) { + const removeIndex = events.findIndex((event) => !isProtectedRendererDiagnosticEvent(event)) + events.splice(removeIndex >= 0 ? removeIndex : 0, 1) + omittedRendererDiagnosticsBytes = Math.max(0, jsonBytes(original.events) - jsonBytes(events)) + rendererDiagnostics = withRendererDiagnosticsEvents(original, events, omittedRendererDiagnosticsBytes) + output = markdown(makePayload()) + } + } + let diagnosticStringLimit = 512 while (bytes(output) > maxBytes && diagnosticStringLimit >= 0) { diagnostics = truncateDiagnostics(input.diagnostics, diagnosticStringLimit) @@ -290,6 +341,7 @@ type ProblemReportSummaryInput = { failureReason?: string recentErrors: string[] rendererError?: RendererErrorDetails + rendererDiagnostics?: RendererDiagnosticsSlice } function oneLine(value: string) { @@ -365,6 +417,11 @@ export function buildProblemReportSummary(input: ProblemReportSummaryInput) { `Electron: ${input.diagnostics.electronVersion}`, `Route: ${safeSummaryRoute(input.diagnostics.route)}`, `Session: ${safeSummarySession(input.diagnostics.sessionID)}`, + ...(input.rendererDiagnostics + ? [ + `Renderer diagnostics: ${input.rendererDiagnostics.status}, events=${input.rendererDiagnostics.summary.event_count}, incidents=${input.rendererDiagnostics.summary.incident_count}`, + ] + : []), ...(rendererError ? [`Renderer error: ${rendererError}`] : []), ...fullReportLines, "", @@ -417,6 +474,20 @@ function isRendererErrorDetails(value: unknown): value is RendererErrorDetails { return isRecord(value) && typeof value.summary === "string" && typeof value.details === "string" } +function isRendererDiagnosticsSlice(value: unknown): value is RendererDiagnosticsSlice { + if (!isRecord(value)) return false + if (typeof value.status !== "string" || value.source !== "renderer-diagnostics") return false + if (typeof value.generated_at !== "string" || !Array.isArray(value.events)) return false + if (!isRecord(value.summary)) return false + return ( + isFiniteNumber(value.summary.event_count) && + isFiniteNumber(value.summary.incident_count) && + Array.isArray(value.summary.statuses) && + isFiniteNumber(value.summary.omitted_event_count) && + isFiniteNumber(value.summary.omitted_bytes) + ) +} + function isTruncation(value: unknown): value is Payload["truncation"] { if (!isRecord(value)) return false return ( @@ -439,6 +510,7 @@ function isProblemReportPayload(value: unknown): value is Payload { isDiagnostics(value.diagnostics) && typeof value.logTail === "string" && (value.rendererError === undefined || isRendererErrorDetails(value.rendererError)) && + (value.rendererDiagnostics === undefined || isRendererDiagnosticsSlice(value.rendererDiagnostics)) && isSessionExport(value.sessionExport) && isTruncation(value.truncation) ) diff --git a/packages/desktop-electron/src/main/server-client.test.ts b/packages/desktop-electron/src/main/server-client.test.ts new file mode 100644 index 00000000..b351dfdc --- /dev/null +++ b/packages/desktop-electron/src/main/server-client.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { attachRendererDiagnosticsToSessionExport } from "./server-client" + +describe("server client", () => { + test("attaches renderer diagnostics to object session exports", () => { + const body = JSON.stringify({ session: { id: "ses_1" }, messages: [] }) + const next = attachRendererDiagnosticsToSessionExport(body, { + status: "ok", + source: "renderer-diagnostics", + events: [], + }) + + expect(JSON.parse(next)).toEqual({ + session: { id: "ses_1" }, + messages: [], + renderer_diagnostics: { + status: "ok", + source: "renderer-diagnostics", + events: [], + }, + }) + }) + + test("leaves invalid or non-object session exports unchanged", () => { + expect(attachRendererDiagnosticsToSessionExport("{", { status: "ok" })).toBe("{") + expect(attachRendererDiagnosticsToSessionExport("[]", { status: "ok" })).toBe("[]") + }) +}) diff --git a/packages/desktop-electron/src/main/server-client.ts b/packages/desktop-electron/src/main/server-client.ts index 4edb7720..a1387577 100644 --- a/packages/desktop-electron/src/main/server-client.ts +++ b/packages/desktop-electron/src/main/server-client.ts @@ -50,3 +50,13 @@ export async function fetchExport( clearTimeout(timer) } } + +export function attachRendererDiagnosticsToSessionExport(body: string, rendererDiagnostics: unknown) { + try { + const parsed = JSON.parse(body) as unknown + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return body + return JSON.stringify({ ...parsed, renderer_diagnostics: rendererDiagnostics }, null, 2) + } catch { + return body + } +} From 521740a6b0731bb5b215725e163c01b5ed39a6d7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:35:52 +0800 Subject: [PATCH 06/15] test: add renderer diagnostics e2e coverage --- .../session-renderer-diagnostics.spec.ts | 176 ++++++++++++++++++ .../src/context/renderer-diagnostics.test.ts | 69 ++++++- .../app/src/context/renderer-diagnostics.ts | 110 ++++++++++- 3 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 packages/app/e2e/session/session-renderer-diagnostics.spec.ts diff --git a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts new file mode 100644 index 00000000..2845ba38 --- /dev/null +++ b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts @@ -0,0 +1,176 @@ +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { withSession } from "../actions" +import { + promptSelector, + scrollViewportSelector, + sessionMessageItemSelector, + sessionTurnListSelector, +} from "../selectors" +import { createSdk } from "../utils" + +type Sdk = ReturnType + +type CapturedDiagnosticEvent = { + name: string + route_session_id?: string + visible_session_id?: string + timeline_session_id?: string + trace_id?: string + data?: Record +} + +type TimelineMetrics = { + top: number + height: number + client: number + distanceFromBottom: number +} + +async function installRendererDiagnosticsCapture(page: Page) { + await page.addInitScript(() => { + const win = window as typeof window & { + __pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[] + api?: { + emitRendererDiagnostic?: (event: CapturedDiagnosticEvent) => Promise + } + } + win.__pawwork_renderer_diagnostics = [] + win.api = { + ...(win.api ?? {}), + emitRendererDiagnostic: async (event) => { + win.__pawwork_renderer_diagnostics?.push(JSON.parse(JSON.stringify(event))) + }, + } + }) +} + +async function readRendererDiagnostics(page: Page) { + return page.evaluate(() => { + const win = window as typeof window & { + __pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[] + } + return win.__pawwork_renderer_diagnostics ?? [] + }) as Promise +} + +async function seedSessionTurns(input: { sdk: Sdk; sessionID: string; count: number }) { + for (let i = 0; i < input.count; i++) { + await input.sdk.session.promptAsync({ + sessionID: input.sessionID, + noReply: true, + parts: [ + { + type: "text", + text: `diagnostics seed ${i}\n${Array.from({ length: 16 }, (_, line) => `line ${line} ${"content ".repeat(8)}`).join("\n")}`, + }, + ], + }) + } +} + +function timelineMetrics(page: Page) { + return page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return null + return { + top: viewport.scrollTop, + height: viewport.scrollHeight, + client: viewport.clientHeight, + distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, + } + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) as Promise +} + +async function expectTimelineMetrics(page: Page) { + const metrics = await timelineMetrics(page) + expect(metrics, "session timeline viewport should exist").not.toBeNull() + return metrics! +} + +async function scrollTimelineToBottom(page: Page) { + const found = await page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return false + viewport.scrollTop = viewport.scrollHeight + viewport.dispatchEvent(new Event("scroll", { bubbles: true })) + return true + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) + expect(found, "session timeline viewport should exist").toBe(true) +} + +async function sendVisiblePrompt(input: { page: Page; text: string }) { + const prompt = input.page.locator(promptSelector) + await expect(prompt).toBeVisible() + await prompt.click() + await input.page.keyboard.insertText(input.text) + await expect.poll(async () => (await prompt.textContent())?.replace(/\u200B/g, "").trim()).toBe(input.text) + await input.page.keyboard.press("Enter") +} + +function numberData(event: CapturedDiagnosticEvent, key: string) { + const value = event.data?.[key] + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +test("captures renderer diagnostics while guarding send scroll position", async ({ page, project }) => { + test.setTimeout(120_000) + + await installRendererDiagnosticsCapture(page) + await project.open() + const sdk = project.sdk + + await withSession(sdk, `e2e renderer diagnostics ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await seedSessionTurns({ sdk, sessionID: session.id, count: 18 }) + + await project.gotoSession(session.id) + await expect(page.locator(sessionMessageItemSelector)).toHaveCount(10, { timeout: 30_000 }) + await scrollTimelineToBottom(page) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(40) + + const scrollAnchorBefore = await page + .locator(sessionMessageItemSelector) + .last() + .evaluate((item) => (item instanceof HTMLElement ? item.dataset.messageId : null)) + const metricsBefore = await expectTimelineMetrics(page) + const beforeCount = await page.locator(sessionMessageItemSelector).count() + + await sendVisiblePrompt({ page, text: `diagnostics guard ${Date.now()}` }) + await expect(page.locator(sessionMessageItemSelector)).toHaveCount(beforeCount + 1, { timeout: 30_000 }) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80) + + const metricsAfter = await expectTimelineMetrics(page) + const scrollAnchorAfter = await page + .locator(sessionMessageItemSelector) + .nth(beforeCount - 1) + .evaluate((item) => (item instanceof HTMLElement ? item.dataset.messageId : null)) + expect(scrollAnchorBefore).not.toBeNull() + expect(scrollAnchorAfter).toBe(scrollAnchorBefore) + expect(Math.abs(metricsAfter.top - metricsBefore.top)).toBeLessThan(200) + + const events = await readRendererDiagnostics(page) + expect(events.some((event) => event.name === "session.action.submit")).toBe(true) + expect(events.some((event) => event.name === "session.timeline.mount")).toBe(true) + expect(events.some((event) => event.name === "session.timeline.visible")).toBe(true) + expect(events.filter((event) => event.name === "session.timeline.mount")).toHaveLength(1) + expect(events.filter((event) => event.name === "session.timeline.unmount")).toHaveLength(0) + expect(events.filter((event) => event.name.startsWith("incident."))).toEqual([]) + + const visibleCounts = events + .filter((event) => event.name === "session.timeline.visible") + .map((event) => numberData(event, "rendered_count") ?? 0) + expect(Math.min(...visibleCounts)).toBeGreaterThan(0) + expect( + events.some((event) => event.name === "session.scroll.sample" && event.data?.user_scrolled === false), + ).toBe(true) + }) +}) diff --git a/packages/app/src/context/renderer-diagnostics.test.ts b/packages/app/src/context/renderer-diagnostics.test.ts index 14eca186..4f83ce8c 100644 --- a/packages/app/src/context/renderer-diagnostics.test.ts +++ b/packages/app/src/context/renderer-diagnostics.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" -import { createRendererDiagnosticsEmitter, createSessionPerformanceDiagnostics } from "./renderer-diagnostics" +import { + createRendererDiagnosticsEmitter, + createRendererIncidentDetector, + createSessionPerformanceDiagnostics, + detectSessionScrollJumpToTop, +} from "./renderer-diagnostics" import type { RendererDiagnosticInput } from "./platform" describe("renderer diagnostics", () => { @@ -43,4 +48,66 @@ describe("renderer diagnostics", () => { timeline_session_id: "timeline-session", }) }) + + test("detects automatic scroll jumps to top", () => { + const incident = detectSessionScrollJumpToTop({ + name: "session.scroll.sample", + route_session_id: "session-1", + visible_session_id: "session-1", + timeline_session_id: "session-1", + data: { + scroll_top: 0, + distance_from_bottom: 800, + client_height: 500, + user_scrolled: false, + }, + }) + + expect(incident).toMatchObject({ + name: "incident.session_scroll_jump_to_top", + level: "warn", + route_session_id: "session-1", + data: { + scroll_top: 0, + distance_from_bottom: 800, + client_height: 500, + user_scrolled: false, + }, + }) + }) + + test("does not flag user-driven scroll to top", () => { + expect( + detectSessionScrollJumpToTop({ + name: "session.scroll.sample", + data: { + scroll_top: 0, + distance_from_bottom: 800, + client_height: 500, + user_scrolled: true, + }, + }), + ).toBeUndefined() + }) + + test("detects timeline remounts and cleared visible messages", () => { + const detect = createRendererIncidentDetector() + + expect(detect({ name: "session.timeline.mount", timeline_session_id: "session-1", data: {} })).toEqual([]) + expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 5 } })).toEqual( + [], + ) + expect(detect({ name: "session.timeline.unmount", timeline_session_id: "session-1", data: {} })).toEqual([ + expect.objectContaining({ + name: "incident.session_timeline_remount", + data: { timeline_mount_count: 1, timeline_unmount_count: 1 }, + }), + ]) + expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 0 } })).toEqual([ + expect.objectContaining({ + name: "incident.session_visible_messages_cleared", + data: { before_count: 5, during_count: 0, after_count: 0 }, + }), + ]) + }) }) diff --git a/packages/app/src/context/renderer-diagnostics.ts b/packages/app/src/context/renderer-diagnostics.ts index 2bcd8814..ed0ae5b0 100644 --- a/packages/app/src/context/renderer-diagnostics.ts +++ b/packages/app/src/context/renderer-diagnostics.ts @@ -19,16 +19,116 @@ export function createRendererDiagnosticsEmitter(input: { return async (event: RendererDiagnosticInput) => { const emit = input.api?.emitRendererDiagnostic if (!emit) return - await emit({ - ...event, - monotonic_ms: event.monotonic_ms ?? input.now?.() ?? performance.now(), - }) + try { + await emit({ + ...event, + monotonic_ms: event.monotonic_ms ?? input.now?.() ?? performance.now(), + }) + } catch {} + } +} + +function numericData(event: RendererDiagnosticInput, key: string) { + const value = event.data?.[key] + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function booleanData(event: RendererDiagnosticInput, key: string) { + const value = event.data?.[key] + return typeof value === "boolean" ? value : undefined +} + +function renderedCount(event: RendererDiagnosticInput) { + return numericData(event, "rendered_count") ?? 0 +} + +export function detectSessionScrollJumpToTop(event: RendererDiagnosticInput): RendererDiagnosticInput | undefined { + if (event.name !== "session.scroll.sample") return + const scrollTop = numericData(event, "scroll_top") + const distanceFromBottom = numericData(event, "distance_from_bottom") + const clientHeight = numericData(event, "client_height") + const userScrolled = booleanData(event, "user_scrolled") + if (scrollTop === undefined || distanceFromBottom === undefined || clientHeight === undefined) return + if (scrollTop > 4 || distanceFromBottom < Math.max(100, clientHeight / 2) || userScrolled) return + return { + name: "incident.session_scroll_jump_to_top", + level: "warn", + route_session_id: event.route_session_id, + visible_session_id: event.visible_session_id, + timeline_session_id: event.timeline_session_id, + trace_id: event.trace_id, + data: { + scroll_top: scrollTop, + distance_from_bottom: distanceFromBottom, + client_height: clientHeight, + user_scrolled: userScrolled ?? false, + }, } } +export function createRendererIncidentDetector() { + const timelineMounts = new Map() + const visibleCounts = new Map() + + return (event: RendererDiagnosticInput) => { + const incidents: RendererDiagnosticInput[] = [] + const scrollIncident = detectSessionScrollJumpToTop(event) + if (scrollIncident) incidents.push(scrollIncident) + + const sessionKey = event.timeline_session_id ?? event.visible_session_id ?? event.route_session_id + if (sessionKey && (event.name === "session.timeline.mount" || event.name === "session.timeline.unmount")) { + const counts = timelineMounts.get(sessionKey) ?? { mounts: 0, unmounts: 0 } + if (event.name === "session.timeline.mount") counts.mounts += 1 + else counts.unmounts += 1 + timelineMounts.set(sessionKey, counts) + if (counts.mounts > 1 || counts.unmounts > 0) { + incidents.push({ + name: "incident.session_timeline_remount", + level: "warn", + route_session_id: event.route_session_id, + visible_session_id: event.visible_session_id, + timeline_session_id: event.timeline_session_id, + data: { + timeline_mount_count: counts.mounts, + timeline_unmount_count: counts.unmounts, + }, + }) + } + } + + if (sessionKey && event.name === "session.timeline.visible") { + const before = visibleCounts.get(sessionKey) ?? 0 + const during = renderedCount(event) + visibleCounts.set(sessionKey, during) + if (before > 0 && during === 0) { + incidents.push({ + name: "incident.session_visible_messages_cleared", + level: "warn", + route_session_id: event.route_session_id, + visible_session_id: event.visible_session_id, + timeline_session_id: event.timeline_session_id, + data: { + before_count: before, + during_count: during, + after_count: during, + }, + }) + } + } + + return incidents + } +} + +const globalIncidentDetector = createRendererIncidentDetector() + export async function emitRendererDiagnostic(event: RendererDiagnosticInput) { const api = typeof window === "undefined" ? undefined : window.api - await createRendererDiagnosticsEmitter({ api })(event) + const emit = createRendererDiagnosticsEmitter({ api }) + await emit(event) + for (const incident of globalIncidentDetector(event)) { + await emit(incident) + } } export function createSessionPerformanceDiagnostics(input: { From dc1a25b1e0511be55208c84218f1f39d44741089 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:44:23 +0800 Subject: [PATCH 07/15] fix: harden renderer diagnostics export --- packages/desktop-electron/src/main/index.ts | 4 +-- .../src/main/menu-labels.test.ts | 1 + .../desktop-electron/src/main/menu-labels.ts | 3 +++ .../src/main/renderer-diagnostics.test.ts | 26 +++++++++++++++++++ .../src/main/renderer-diagnostics.ts | 18 ++++++++++--- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 07fe8508..1543dbb1 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -60,7 +60,7 @@ import { registerAboutIpc, triggerAbout } from "./ipc/about" import { filePath, initLogging, tail } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { type MenuLocale } from "./menu-labels" +import { menuLabel, type MenuLocale } from "./menu-labels" import { readStoredMenuLocale, writeStoredMenuLocale } from "./menu-i18n" import { cleanupProblemReports, problemReportsRoot, writeProblemReportFile } from "./problem-report-files" import { @@ -208,7 +208,7 @@ async function sessionExport(context = currentDesktopContext(), signal?: AbortSi async function exportDiagnosticsFromMenu() { const stamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") const result = await dialog.showSaveDialog({ - title: "Export diagnostics log", + title: menuLabel(focusedMenuLocale(), "exportDiagnosticsLogTitle"), defaultPath: `pawwork-renderer-diagnostics-${stamp}.jsonl`, filters: [{ name: "JSONL", extensions: ["jsonl"] }], }) diff --git a/packages/desktop-electron/src/main/menu-labels.test.ts b/packages/desktop-electron/src/main/menu-labels.test.ts index cfaa7a51..ab2b9854 100644 --- a/packages/desktop-electron/src/main/menu-labels.test.ts +++ b/packages/desktop-electron/src/main/menu-labels.test.ts @@ -43,6 +43,7 @@ describe("menu labels", () => { expect(menuLabel("zh", "reloadWindow")).toBe("重新加载窗口") expect(menuLabel("zh", "reportProblem")).toBe("报告问题") expect(menuLabel("zh", "exportDiagnosticsLog")).toBe("导出诊断日志...") + expect(menuLabel("zh", "exportDiagnosticsLogTitle")).toBe("导出诊断日志") expect(menuLabel("zh", "pawworkOnGithub")).toBe("在 GitHub 上查看爪印") expect(menuLabel("fr" as never, "file")).toBe("File") }) diff --git a/packages/desktop-electron/src/main/menu-labels.ts b/packages/desktop-electron/src/main/menu-labels.ts index d72ffc8e..803d83d5 100644 --- a/packages/desktop-electron/src/main/menu-labels.ts +++ b/packages/desktop-electron/src/main/menu-labels.ts @@ -25,6 +25,7 @@ export type MenuLabelKey = | "pawworkOnGithub" | "reportProblem" | "exportDiagnosticsLog" + | "exportDiagnosticsLogTitle" | "openGithubIssue" export type MenuRoleLabelKey = @@ -76,6 +77,7 @@ const labels: Record> = { pawworkOnGithub: "PawWork on GitHub", reportProblem: "Report a Problem", exportDiagnosticsLog: "Export Diagnostics Log...", + exportDiagnosticsLogTitle: "Export Diagnostics Log", openGithubIssue: "Open GitHub Issue", }, zh: { @@ -103,6 +105,7 @@ const labels: Record> = { pawworkOnGithub: "在 GitHub 上查看爪印", reportProblem: "报告问题", exportDiagnosticsLog: "导出诊断日志...", + exportDiagnosticsLogTitle: "导出诊断日志", openGithubIssue: "打开 GitHub Issue", }, } diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index 5652b035..a122e04b 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -173,6 +173,32 @@ describe("renderer diagnostics recorder", () => { expect(lines).toHaveLength(1) }) + test("serializes concurrent writes so retention does not lose accepted events", async () => { + const root = await tempRoot() + const recorder = createRendererDiagnosticsRecorder({ + root, + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + }) + + await Promise.all( + Array.from({ length: 20 }, (_, index) => + recorder.record( + { + name: "session.action.submit", + trace_id: `msg_${index}`, + data: { action: "submit_prompt", prompt_length: index }, + }, + { windowID: 1 }, + ), + ), + ) + + const lines = (await readFile(recorder.path, "utf8")).trim().split("\n") + expect(lines).toHaveLength(20) + expect(new Set(lines.map((line) => JSON.parse(line).trace_id)).size).toBe(20) + }) + test("retention keeps recent entries and caps bytes", async () => { const root = await tempRoot() const recorder = createRendererDiagnosticsRecorder({ diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index 4828041a..ff994d07 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -390,6 +390,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { const path = rendererDiagnosticsPath(options.root) const lastHighFrequency = new Map() let writeFailed = false + let writeQueue = Promise.resolve() const readEventReport = async () => { try { @@ -433,6 +434,15 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { }) } + const enqueueWrite = async (operation: () => Promise) => { + const next = writeQueue.then(operation, operation) + writeQueue = next.then( + () => undefined, + () => undefined, + ) + return next + } + const record = async (input: unknown, context: RecordContext) => { try { const sanitized = sanitizeRendererDiagnosticEvent(input, { @@ -450,9 +460,11 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { } lastHighFrequency.set(key, current) } - await mkdir(options.root, { recursive: true }) - await appendFile(path, `${JSON.stringify(sanitized)}\n`, "utf8") - await flushRetention() + await enqueueWrite(async () => { + await mkdir(options.root, { recursive: true }) + await appendFile(path, `${JSON.stringify(sanitized)}\n`, "utf8") + await flushRetention() + }) return { ok: true as const } } catch { writeFailed = true From f39c1f74cb802edf312cc5e558ad1d9c0076bb83 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 00:59:02 +0800 Subject: [PATCH 08/15] fix: reduce renderer diagnostics false positives --- .../src/context/renderer-diagnostics.test.ts | 36 +++++++++++++++++-- .../app/src/context/renderer-diagnostics.ts | 3 +- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/app/src/context/renderer-diagnostics.test.ts b/packages/app/src/context/renderer-diagnostics.test.ts index 4f83ce8c..6b8920ff 100644 --- a/packages/app/src/context/renderer-diagnostics.test.ts +++ b/packages/app/src/context/renderer-diagnostics.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { createRoot } from "solid-js" import { createRendererDiagnosticsEmitter, @@ -9,6 +9,14 @@ import { import type { RendererDiagnosticInput } from "./platform" describe("renderer diagnostics", () => { + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalApi = window.api + + afterEach(() => { + globalThis.requestAnimationFrame = originalRequestAnimationFrame + window.api = originalApi + }) + test("emits through the desktop API with monotonic time", async () => { const events: RendererDiagnosticInput[] = [] const emit = createRendererDiagnosticsEmitter({ @@ -49,6 +57,27 @@ describe("renderer diagnostics", () => { }) }) + test("session performance diagnostics does not start timers without a diagnostics target", () => { + let frames = 0 + window.api = undefined + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + frames += 1 + return originalRequestAnimationFrame(callback) + }) as typeof requestAnimationFrame + + createRoot((dispose) => { + createSessionPerformanceDiagnostics({ + routeSessionID: () => "route-session", + visibleSessionID: () => "visible-session", + timelineSessionID: () => "timeline-session", + }) + dispose() + }) + + expect(frames).toBe(0) + }) + + test("detects automatic scroll jumps to top", () => { const incident = detectSessionScrollJumpToTop({ name: "session.scroll.sample", @@ -97,10 +126,11 @@ describe("renderer diagnostics", () => { expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 5 } })).toEqual( [], ) - expect(detect({ name: "session.timeline.unmount", timeline_session_id: "session-1", data: {} })).toEqual([ + expect(detect({ name: "session.timeline.unmount", timeline_session_id: "session-1", data: {} })).toEqual([]) + expect(detect({ name: "session.timeline.mount", timeline_session_id: "session-1", data: {} })).toEqual([ expect.objectContaining({ name: "incident.session_timeline_remount", - data: { timeline_mount_count: 1, timeline_unmount_count: 1 }, + data: { timeline_mount_count: 2, timeline_unmount_count: 1 }, }), ]) expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 0 } })).toEqual([ diff --git a/packages/app/src/context/renderer-diagnostics.ts b/packages/app/src/context/renderer-diagnostics.ts index ed0ae5b0..494e4826 100644 --- a/packages/app/src/context/renderer-diagnostics.ts +++ b/packages/app/src/context/renderer-diagnostics.ts @@ -81,7 +81,7 @@ export function createRendererIncidentDetector() { if (event.name === "session.timeline.mount") counts.mounts += 1 else counts.unmounts += 1 timelineMounts.set(sessionKey, counts) - if (counts.mounts > 1 || counts.unmounts > 0) { + if (event.name === "session.timeline.mount" && counts.mounts > 1 && counts.unmounts > 0) { incidents.push({ name: "incident.session_timeline_remount", level: "warn", @@ -137,6 +137,7 @@ export function createSessionPerformanceDiagnostics(input: { timelineSessionID: Accessor emit?: (event: RendererDiagnosticInput) => Promise | void }) { + if (!input.emit && (typeof window === "undefined" || !window.api?.emitRendererDiagnostic)) return const emit = input.emit ?? emitRendererDiagnostic let running = true let frame: number | undefined From a7f1aecc4b8e8f0986dadfbb5bd57c8cb3563ae1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 01:06:18 +0800 Subject: [PATCH 09/15] fix: export renderer diagnostics as json --- packages/desktop-electron/src/main/index.ts | 4 +- .../src/main/renderer-diagnostics.test.ts | 65 +++++++++++++++++-- .../src/main/renderer-diagnostics.ts | 62 ++++++++++++++---- 3 files changed, 112 insertions(+), 19 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 1543dbb1..9d3cc09e 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -209,8 +209,8 @@ async function exportDiagnosticsFromMenu() { const stamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") const result = await dialog.showSaveDialog({ title: menuLabel(focusedMenuLocale(), "exportDiagnosticsLogTitle"), - defaultPath: `pawwork-renderer-diagnostics-${stamp}.jsonl`, - filters: [{ name: "JSONL", extensions: ["jsonl"] }], + defaultPath: `pawwork-renderer-diagnostics-${stamp}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], }) if (result.canceled || !result.filePath) return { ok: false as const, error: "cancelled" } diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index a122e04b..3671a573 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -315,13 +315,66 @@ describe("renderer diagnostics recorder", () => { ).toBe("expired") }) - test("global export caps old JSONL content", async () => { + test("global export wraps diagnostics as JSON and caps old events", async () => { const root = await tempRoot() const source = join(root, "renderer-diagnostics.jsonl") - const destination = join(root, "exported.jsonl") - await writeFile(source, `${"a".repeat(200)}\n${"b".repeat(40)}\n`, "utf8") - await exportRendererDiagnosticsLog({ path: source, destination, maxBytes: 80 }) - const exported = await readFile(destination, "utf8") - expect(exported).toBe(`${"b".repeat(40)}\n`) + const destination = join(root, "exported.json") + const first = sanitizeRendererDiagnosticEvent( + { + name: "session.scroll.sample", + data: { scroll_top: 1, scroll_height: 100, client_height: 50 }, + }, + { appLaunchID: "launch_1", windowID: 1, now: () => new Date("2026-05-02T10:00:00.000Z") }, + ) + const second = sanitizeRendererDiagnosticEvent( + { + name: "incident.session_timeline_remount", + data: { mounts: 2, unmounts: 1 }, + }, + { appLaunchID: "launch_1", windowID: 1, now: () => new Date("2026-05-02T10:01:00.000Z") }, + ) + await writeFile(source, `${JSON.stringify(first)}\nnot-json\n${JSON.stringify(second)}\n`, "utf8") + await exportRendererDiagnosticsLog({ + path: source, + destination, + maxBytes: JSON.stringify([second]).length + 4, + now: new Date("2026-05-02T10:02:00.000Z"), + }) + const exported = JSON.parse(await readFile(destination, "utf8")) + expect(exported).toMatchObject({ + schema_version: 1, + format: "pawwork-renderer-diagnostics", + source: "renderer-diagnostics", + generated_at: "2026-05-02T10:02:00.000Z", + diagnostics: { + status: "truncated", + event_count: 1, + incident_count: 1, + corrupt_line_count: 1, + omitted_event_count: 1, + }, + }) + expect(exported.events.map((event: { "event.name": string }) => event["event.name"])).toEqual([ + "incident.session_timeline_remount", + ]) + }) + + test("global export writes a JSON report when the diagnostics log is missing", async () => { + const root = await tempRoot() + const destination = join(root, "exported.json") + await exportRendererDiagnosticsLog({ + path: join(root, "missing.jsonl"), + destination, + now: new Date("2026-05-02T10:02:00.000Z"), + }) + const exported = JSON.parse(await readFile(destination, "utf8")) + expect(exported).toMatchObject({ + format: "pawwork-renderer-diagnostics", + diagnostics: { + status: "missing", + event_count: 0, + }, + events: [], + }) }) }) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index ff994d07..967c17d8 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -1,4 +1,4 @@ -import { appendFile, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { basename, join } from "node:path" export const DEFAULT_RENDERER_DIAGNOSTICS_MAX_BYTES = 20 * 1024 * 1024 @@ -59,6 +59,22 @@ export type RendererDiagnosticsSlice = { } } +export type RendererDiagnosticsExport = { + schema_version: 1 + format: "pawwork-renderer-diagnostics" + source: "renderer-diagnostics" + generated_at: string + diagnostics: { + status: RendererDiagnosticsStatus + event_count: number + incident_count: number + corrupt_line_count: number + omitted_event_count: number + omitted_bytes: number + } + events: RendererDiagnosticEvent[] +} + type SanitizeContext = { appLaunchID: string now: () => Date @@ -367,19 +383,43 @@ export async function exportRendererDiagnosticsLog(input: { path: string destination: string maxBytes?: number + now?: Date }) { const maxBytes = input.maxBytes ?? GLOBAL_RENDERER_DIAGNOSTICS_EXPORT_MAX_BYTES - await copyFile(input.path, input.destination) - let content = await readFile(input.destination, "utf8") - while (Buffer.byteLength(content, "utf8") > maxBytes) { - const nextLine = content.indexOf("\n") - if (nextLine < 0) { - content = "" - break - } - content = content.slice(nextLine + 1) + let content = "" + let status: RendererDiagnosticsStatus = "ok" + try { + content = await readFile(input.path, "utf8") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + status = "missing" + } + const lines = content.split(/\r?\n/).filter(Boolean) + const events: RendererDiagnosticEvent[] = [] + let corruptLineCount = 0 + for (const line of lines) { + const event = parseEventLine(line) + if (event) events.push(event) + else corruptLineCount++ + } + const capped = capEvents(events, maxBytes) + if (status === "ok" && capped.omittedEventCount > 0) status = "truncated" + const output: RendererDiagnosticsExport = { + schema_version: 1, + format: "pawwork-renderer-diagnostics", + source: "renderer-diagnostics", + generated_at: (input.now ?? new Date()).toISOString(), + diagnostics: { + status, + event_count: capped.events.length, + incident_count: capped.events.filter(isIncident).length, + corrupt_line_count: corruptLineCount, + omitted_event_count: capped.omittedEventCount, + omitted_bytes: capped.omittedBytes, + }, + events: capped.events, } - await writeFile(input.destination, content, "utf8") + await writeFile(input.destination, `${JSON.stringify(output, null, 2)}\n`, "utf8") } export function createRendererDiagnosticsRecorder(options: RecorderOptions) { From 1373c4ba2002a391d2bef34d4c5148d644b093ee Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 01:12:44 +0800 Subject: [PATCH 10/15] fix: harden renderer diagnostics review issues --- .../session-renderer-diagnostics.spec.ts | 2 + packages/app/src/app.tsx | 3 +- .../app/src/components/prompt-input/submit.ts | 11 ++-- .../app/src/context/renderer-diagnostics.ts | 7 ++- .../src/pages/session/message-timeline.tsx | 54 +++++++++++++------ .../pages/session/use-session-scroll-dock.ts | 16 +++--- .../src/main/feedback.test.ts | 14 +++++ .../desktop-electron/src/main/feedback.ts | 18 ++++++- .../src/main/problem-report.ts | 1 + .../src/main/renderer-diagnostics.test.ts | 23 ++++++++ .../src/main/renderer-diagnostics.ts | 34 ++++++++---- 11 files changed, 145 insertions(+), 38 deletions(-) diff --git a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts index 2845ba38..707c41fe 100644 --- a/packages/app/e2e/session/session-renderer-diagnostics.spec.ts +++ b/packages/app/e2e/session/session-renderer-diagnostics.spec.ts @@ -36,10 +36,12 @@ async function installRendererDiagnosticsCapture(page: Page) { } } win.__pawwork_renderer_diagnostics = [] + const originalEmit = win.api?.emitRendererDiagnostic?.bind(win.api) win.api = { ...(win.api ?? {}), emitRendererDiagnostic: async (event) => { win.__pawwork_renderer_diagnostics?.push(JSON.parse(JSON.stringify(event))) + await originalEmit?.(event) }, } }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 91ccd843..1640aede 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -47,7 +47,7 @@ import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context" -import type { RendererDiagnosticInput } from "@/context/platform" +import type { RendererDiagnosticInput, RendererDiagnosticsExportResult } from "@/context/platform" import { useCheckServerHealth } from "./utils/server-health" const HomeRoute = lazy(() => import("@/pages/home")) @@ -89,6 +89,7 @@ declare global { api?: { setDesktopContext?: (context: DesktopContext) => Promise emitRendererDiagnostic?: (event: RendererDiagnosticInput) => Promise + exportDiagnosticsLog?: () => Promise getAboutInfo?: () => Promise onAboutOpen?: (handler: () => void) => () => void setLspEnabled?: (value: boolean) => Promise diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 417ca89f..ddded92a 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -511,6 +511,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const messageID = Identifier.ascending("message") + const submittedPromptLength = input.promptLength(currentPrompt) + const submittedImageCount = images.length + const submittedCommentCount = input.commentCount() const removeOptimisticMessage = () => { sync.session.optimistic.remove({ @@ -533,11 +536,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { provider: model.providerID, model: model.modelID, endpoint_kind: "prompt", - prompt_length: input.promptLength(currentPrompt), - image_count: images.length, - comment_count: input.commentCount(), + prompt_length: submittedPromptLength, + image_count: submittedImageCount, + comment_count: submittedCommentCount, }, - }) + }).catch(() => {}) const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) diff --git a/packages/app/src/context/renderer-diagnostics.ts b/packages/app/src/context/renderer-diagnostics.ts index 494e4826..89bdb8b3 100644 --- a/packages/app/src/context/renderer-diagnostics.ts +++ b/packages/app/src/context/renderer-diagnostics.ts @@ -143,6 +143,7 @@ export function createSessionPerformanceDiagnostics(input: { let frame: number | undefined let interval: number | undefined let lastFrame = performance.now() + let sampleStartedAt = lastFrame let frameCount = 0 let jankCount = 0 let maxFrameGap = 0 @@ -168,12 +169,15 @@ export function createSessionPerformanceDiagnostics(input: { } const flush = () => { + const now = performance.now() + const elapsedMs = Math.max(1, now - sampleStartedAt) + const fps = Math.round((frameCount * 1000) / elapsedMs) const memory = performance as PerformanceWithMemory void emit({ name: "renderer.perf.sample", ...baseEvent(), data: { - fps: frameCount, + fps, frame_gap_ms: Math.round(maxFrameGap), jank_count: jankCount, long_task_max_ms: Math.round(longTaskMax), @@ -190,6 +194,7 @@ export function createSessionPerformanceDiagnostics(input: { longTaskMax = 0 longTaskBlock = 0 cls = 0 + sampleStartedAt = now } if (typeof PerformanceObserver !== "undefined") { diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0a4a36ca..5c2dbf10 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -236,6 +236,17 @@ export function MessageTimeline(props: { anchor: (id: string) => string }) { let touchGesture: number | undefined + let scrollSampleFrame: number | undefined + let pendingScrollSample: + | { + scroll_top: number + scroll_height: number + client_height: number + distance_from_bottom: number + user_scrolled: boolean + jump_button_visible: boolean + } + | undefined const navigate = useNavigate() const sdk = useSDK() @@ -247,6 +258,9 @@ export function MessageTimeline(props: { const { params } = useSessionKey() const platform = usePlatform() const server = useServer() + onCleanup(() => { + if (scrollSampleFrame !== undefined) cancelAnimationFrame(scrollSampleFrame) + }) // Export hits the embedded sidecar via main-process IPC. When the user has switched the // active server to a remote HTTP/SSH target, the sidecar holds different data than the UI; // hide the action rather than ship a misleading export. @@ -737,22 +751,30 @@ export function MessageTimeline(props: { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() const el = e.currentTarget - const max = el.scrollHeight - el.clientHeight - void emitRendererDiagnostic({ - name: "session.scroll.sample", - route_session_id: params.id, - visible_session_id: props.sessionID, - timeline_session_id: props.sessionID, - data: { - scroll_top: el.scrollTop, - scroll_height: el.scrollHeight, - client_height: el.clientHeight, - distance_from_bottom: max - el.scrollTop, - user_scrolled: props.hasScrollGesture(), - jump_button_visible: props.scroll.overflow && props.scroll.jump && !staging.isStaging(), - ...visibleRangeData(), - }, - }) + const max = Math.max(0, el.scrollHeight - el.clientHeight) + pendingScrollSample = { + scroll_top: el.scrollTop, + scroll_height: el.scrollHeight, + client_height: el.clientHeight, + distance_from_bottom: Math.max(0, max - el.scrollTop), + user_scrolled: props.hasScrollGesture(), + jump_button_visible: props.scroll.overflow && props.scroll.jump && !staging.isStaging(), + } + if (scrollSampleFrame === undefined) { + scrollSampleFrame = requestAnimationFrame(() => { + scrollSampleFrame = undefined + const sample = pendingScrollSample + pendingScrollSample = undefined + if (!sample) return + void emitRendererDiagnostic({ + name: "session.scroll.sample", + route_session_id: params.id, + visible_session_id: props.sessionID, + timeline_session_id: props.sessionID, + data: { ...sample, ...visibleRangeData() }, + }).catch(() => {}) + }) + } if (!props.hasScrollGesture()) return props.onUserScroll() props.onAutoScrollHandleScroll() diff --git a/packages/app/src/pages/session/use-session-scroll-dock.ts b/packages/app/src/pages/session/use-session-scroll-dock.ts index 408909a2..db47e23f 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -170,12 +170,16 @@ export function createSessionScrollDock(input: { fill: input.fill, }) if (dockHeight !== previousDockHeight) { - input.onDockHeightChange?.({ - composerHeight: dockHeight, - previousComposerHeight: previousDockHeight, - scrollTop, - distanceFromBottom, - }) + try { + input.onDockHeightChange?.({ + composerHeight: dockHeight, + previousComposerHeight: previousDockHeight, + scrollTop, + distanceFromBottom, + }) + } catch (error) { + if (import.meta.env.DEV) console.warn("[session-scroll-dock] onDockHeightChange failed", error) + } } } diff --git a/packages/desktop-electron/src/main/feedback.test.ts b/packages/desktop-electron/src/main/feedback.test.ts index 3fc966f0..57896ba0 100644 --- a/packages/desktop-electron/src/main/feedback.test.ts +++ b/packages/desktop-electron/src/main/feedback.test.ts @@ -240,6 +240,20 @@ describe("feedback handler", () => { expect(subject.calls.opened).toBe("https://example.com/form") }) + test("slow renderer diagnostics times out and still produces report artifacts", async () => { + const subject = setup({ + sessionExportTimeoutMs: 1, + rendererDiagnostics: async () => new Promise(() => {}), + }) + + await subject.handler() + + expect(subject.calls.handledErrors).toContain("renderer diagnostics slice failed") + expect(subject.calls.savedMarkdown).toContain('"status": "write_failed"') + expect(subject.calls.copied).toContain("Renderer diagnostics: write_failed") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + test("slow session export times out and still produces report artifacts", async () => { let aborted = false const subject = setup({ diff --git a/packages/desktop-electron/src/main/feedback.ts b/packages/desktop-electron/src/main/feedback.ts index 49b93be2..7456dc08 100644 --- a/packages/desktop-electron/src/main/feedback.ts +++ b/packages/desktop-electron/src/main/feedback.ts @@ -174,6 +174,22 @@ async function sessionExportWithTimeout(deps: FeedbackDeps, context: unknown) { } } +async function rendererDiagnosticsWithTimeout(deps: FeedbackDeps, context: unknown) { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + deps.rendererDiagnostics(context), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error("renderer diagnostics timed out")) + }, deps.sessionExportTimeoutMs) + }), + ]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + export function createFeedbackHandler(deps: FeedbackDeps) { let inFlight: Promise | undefined @@ -219,7 +235,7 @@ export function createFeedbackHandler(deps: FeedbackDeps) { } try { - rendererDiagnostics = await deps.rendererDiagnostics(context) + rendererDiagnostics = await rendererDiagnosticsWithTimeout(deps, context) } catch (error) { deps.onHandledError?.("renderer diagnostics slice failed", error) rendererDiagnostics = emptyRendererDiagnosticsSlice("write_failed", new Date(generatedAt)) diff --git a/packages/desktop-electron/src/main/problem-report.ts b/packages/desktop-electron/src/main/problem-report.ts index d61432d4..5d47c708 100644 --- a/packages/desktop-electron/src/main/problem-report.ts +++ b/packages/desktop-electron/src/main/problem-report.ts @@ -495,6 +495,7 @@ function isTruncation(value: unknown): value is Payload["truncation"] { isFiniteNumber(value.omittedLogBytes) && isFiniteNumber(value.omittedSessionInfoBytes) && isFiniteNumber(value.omittedFailedExportErrorBytes) && + isFiniteNumber(value.omittedRendererDiagnosticsBytes) && isFiniteNumber(value.omittedDiagnosticsBytes) ) } diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index 3671a573..faffd32c 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -133,6 +133,26 @@ describe("renderer diagnostics sanitizer", () => { expect(event?.data).toEqual({ action: "submit_prompt", model: "deepseek-v4-pro" }) }) + + test("keeps dotted technical identifiers that are not URLs", () => { + const event = sanitizeRendererDiagnosticEvent( + { + name: "session.action.submit", + data: { + action: "submit_prompt", + provider: "open-router.ai", + model: "deepseek.v4", + }, + }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ) + + expect(event?.data).toEqual({ + action: "submit_prompt", + provider: "open-router.ai", + model: "deepseek.v4", + }) + }) }) describe("renderer diagnostics recorder", () => { @@ -285,6 +305,9 @@ describe("renderer diagnostics recorder", () => { expect((await missing.slice({ sessionID: "ses_1", maxBytes: 1024 })).status).toBe("missing") const disabled = createRendererDiagnosticsRecorder({ root, appLaunchID: "launch_1", disabled: true }) + expect((await disabled.record({ name: "session.action.submit", data: { action: "submit_prompt" } }, { windowID: 1 })).reason).toBe( + "disabled", + ) expect((await disabled.slice({ sessionID: "ses_1", maxBytes: 1024 })).status).toBe("disabled") await writeFile(missing.path, "{not json}\n", "utf8") diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index 967c17d8..bd13b9ca 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" import { basename, join } from "node:path" export const DEFAULT_RENDERER_DIAGNOSTICS_MAX_BYTES = 20 * 1024 * 1024 @@ -6,6 +6,7 @@ export const DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_MS = 24 * 60 * 60 * 1000 export const SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES = 1 * 1024 * 1024 export const GLOBAL_RENDERER_DIAGNOSTICS_EXPORT_MAX_BYTES = 10 * 1024 * 1024 export const RENDERER_DIAGNOSTIC_EVENT_MAX_BYTES = 8 * 1024 +const DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_CHECK_MS = 60 * 1000 export type RendererDiagnosticsStatus = | "ok" @@ -86,6 +87,7 @@ type RecorderOptions = { appLaunchID: string maxBytes?: number retentionMs?: number + retentionCheckIntervalMs?: number highFrequencyIntervalMs?: number disabled?: boolean now?: () => Date @@ -179,7 +181,7 @@ function stringField(value: unknown, limit = 160) { const next = value.replace(/\s+/g, " ").trim() if (!next) return undefined if (/[a-z][a-z0-9+.-]*:\/\//i.test(next)) return undefined - if (/\b[a-z0-9.-]+\.[a-z]{2,}(?:\/|\?|:|$)/i.test(next)) return undefined + if (/\b[a-z0-9.-]+\.[a-z]{2,}(?:\/|\?|:)/i.test(next)) return undefined if (/token=|key=|secret=|authorization/i.test(next)) return undefined return next.length > limit ? next.slice(0, limit) : next } @@ -425,12 +427,14 @@ export async function exportRendererDiagnosticsLog(input: { export function createRendererDiagnosticsRecorder(options: RecorderOptions) { const maxBytes = options.maxBytes ?? DEFAULT_RENDERER_DIAGNOSTICS_MAX_BYTES const retentionMs = options.retentionMs ?? DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_MS + const retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? DEFAULT_RENDERER_DIAGNOSTICS_RETENTION_CHECK_MS const highFrequencyIntervalMs = options.highFrequencyIntervalMs ?? 250 const now = options.now ?? (() => new Date()) const path = rendererDiagnosticsPath(options.root) const lastHighFrequency = new Map() let writeFailed = false let writeQueue = Promise.resolve() + let lastRetentionCheck = 0 const readEventReport = async () => { try { @@ -458,13 +462,13 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { const events = await readEvents() const cutoff = now().getTime() - retentionMs const retained = events.filter((event) => eventTime(event) >= cutoff) - let content = retained.map((event) => JSON.stringify(event)).join("\n") - if (content) content += "\n" - while (Buffer.byteLength(content, "utf8") > maxBytes && retained.length > 0) { - retained.shift() - content = retained.map((event) => JSON.stringify(event)).join("\n") - if (content) content += "\n" + const lines = retained.map((event) => JSON.stringify(event)) + let totalBytes = lines.reduce((sum, line) => sum + Buffer.byteLength(line, "utf8") + 1, 0) + while (totalBytes > maxBytes && lines.length > 0) { + const line = lines.shift() + if (line) totalBytes -= Buffer.byteLength(line, "utf8") + 1 } + const content = lines.length > 0 ? `${lines.join("\n")}\n` : "" await mkdir(options.root, { recursive: true }) const temp = join(options.root, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`) await writeFile(temp, content, "utf8") @@ -483,7 +487,19 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { return next } + const maybeFlushRetention = async () => { + const current = now().getTime() + const size = await stat(path).then( + (stats) => stats.size, + () => 0, + ) + if (size <= maxBytes && current - lastRetentionCheck < retentionCheckIntervalMs) return + lastRetentionCheck = current + await flushRetention() + } + const record = async (input: unknown, context: RecordContext) => { + if (options.disabled) return { ok: false as const, reason: "disabled" as const } try { const sanitized = sanitizeRendererDiagnosticEvent(input, { appLaunchID: options.appLaunchID, @@ -503,7 +519,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { await enqueueWrite(async () => { await mkdir(options.root, { recursive: true }) await appendFile(path, `${JSON.stringify(sanitized)}\n`, "utf8") - await flushRetention() + await maybeFlushRetention() }) return { ok: true as const } } catch { From 39b4ec6a64346d5484ed428a653ed004a01343ca Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 08:41:10 +0800 Subject: [PATCH 11/15] fix: address renderer diagnostics review gaps --- .../src/context/renderer-diagnostics.test.ts | 46 +++++++- .../app/src/context/renderer-diagnostics.ts | 97 +++++++++++++---- packages/app/src/pages/session.tsx | 103 ++++++++++++++---- .../src/pages/session/message-timeline.tsx | 19 +++- .../session/use-session-refresh-effects.ts | 90 ++++++++++++++- .../src/main/feedback.test.ts | 14 +++ .../desktop-electron/src/main/feedback.ts | 20 +++- packages/desktop-electron/src/main/index.ts | 8 +- packages/desktop-electron/src/main/ipc.ts | 9 +- .../src/main/renderer-diagnostics.test.ts | 43 ++++++++ .../src/main/renderer-diagnostics.ts | 60 +++++++--- .../desktop-electron/src/renderer/index.tsx | 4 + 12 files changed, 434 insertions(+), 79 deletions(-) diff --git a/packages/app/src/context/renderer-diagnostics.test.ts b/packages/app/src/context/renderer-diagnostics.test.ts index 6b8920ff..e43ff280 100644 --- a/packages/app/src/context/renderer-diagnostics.test.ts +++ b/packages/app/src/context/renderer-diagnostics.test.ts @@ -119,7 +119,46 @@ describe("renderer diagnostics", () => { ).toBeUndefined() }) - test("detects timeline remounts and cleared visible messages", () => { + test("detects scroll jumps after submit from a near-bottom state", () => { + const detect = createRendererIncidentDetector() + detect({ + name: "session.action.submit", + route_session_id: "session-1", + visible_session_id: "session-1", + timeline_session_id: "session-1", + trace_id: "message-1", + monotonic_ms: 1000, + data: { action: "submit" }, + }) + expect( + detect({ + name: "session.scroll.sample", + route_session_id: "session-1", + visible_session_id: "session-1", + timeline_session_id: "session-1", + monotonic_ms: 1200, + data: { scroll_top: 500, distance_from_bottom: 20, client_height: 500, user_scrolled: false }, + }), + ).toEqual([]) + + expect( + detect({ + name: "session.scroll.sample", + route_session_id: "session-1", + visible_session_id: "session-1", + timeline_session_id: "session-1", + monotonic_ms: 1300, + data: { scroll_top: 0, distance_from_bottom: 800, client_height: 500, user_scrolled: false }, + }), + ).toEqual([ + expect.objectContaining({ + name: "incident.session_scroll_jump_to_top", + trace_id: "message-1", + }), + ]) + }) + + test("detects timeline remounts and recovered visible message clears", () => { const detect = createRendererIncidentDetector() expect(detect({ name: "session.timeline.mount", timeline_session_id: "session-1", data: {} })).toEqual([]) @@ -133,10 +172,11 @@ describe("renderer diagnostics", () => { data: { timeline_mount_count: 2, timeline_unmount_count: 1 }, }), ]) - expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 0 } })).toEqual([ + expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 0 } })).toEqual([]) + expect(detect({ name: "session.timeline.visible", timeline_session_id: "session-1", data: { rendered_count: 4 } })).toEqual([ expect.objectContaining({ name: "incident.session_visible_messages_cleared", - data: { before_count: 5, during_count: 0, after_count: 0 }, + data: { before_count: 5, during_count: 0, after_count: 4 }, }), ]) }) diff --git a/packages/app/src/context/renderer-diagnostics.ts b/packages/app/src/context/renderer-diagnostics.ts index 89bdb8b3..0e4e4845 100644 --- a/packages/app/src/context/renderer-diagnostics.ts +++ b/packages/app/src/context/renderer-diagnostics.ts @@ -69,13 +69,41 @@ export function detectSessionScrollJumpToTop(event: RendererDiagnosticInput): Re export function createRendererIncidentDetector() { const timelineMounts = new Map() const visibleCounts = new Map() + const pendingVisibleClears = new Map() + const lastScroll = new Map() + const recentSubmits = new Map() return (event: RendererDiagnosticInput) => { const incidents: RendererDiagnosticInput[] = [] - const scrollIncident = detectSessionScrollJumpToTop(event) - if (scrollIncident) incidents.push(scrollIncident) - const sessionKey = event.timeline_session_id ?? event.visible_session_id ?? event.route_session_id + + if (sessionKey && event.name === "session.action.submit") { + recentSubmits.set(sessionKey, { + traceID: event.trace_id, + monotonicMs: event.monotonic_ms ?? performance.now(), + }) + } + + if (sessionKey && event.name === "session.scroll.sample") { + const scrollIncident = detectSessionScrollJumpToTop(event) + const distanceFromBottom = numericData(event, "distance_from_bottom") + const clientHeight = numericData(event, "client_height") + const nearBottom = + distanceFromBottom !== undefined && clientHeight !== undefined + ? distanceFromBottom <= Math.max(100, clientHeight / 2) + : false + const previous = lastScroll.get(sessionKey) + const submit = recentSubmits.get(sessionKey) + const monotonic = event.monotonic_ms ?? performance.now() + if (scrollIncident && previous?.nearBottom && submit && monotonic - submit.monotonicMs <= 2_000) { + incidents.push({ + ...scrollIncident, + trace_id: scrollIncident.trace_id ?? submit.traceID, + }) + } + lastScroll.set(sessionKey, { nearBottom }) + } + if (sessionKey && (event.name === "session.timeline.mount" || event.name === "session.timeline.unmount")) { const counts = timelineMounts.get(sessionKey) ?? { mounts: 0, unmounts: 0 } if (event.name === "session.timeline.mount") counts.mounts += 1 @@ -101,18 +129,24 @@ export function createRendererIncidentDetector() { const during = renderedCount(event) visibleCounts.set(sessionKey, during) if (before > 0 && during === 0) { - incidents.push({ - name: "incident.session_visible_messages_cleared", - level: "warn", - route_session_id: event.route_session_id, - visible_session_id: event.visible_session_id, - timeline_session_id: event.timeline_session_id, - data: { - before_count: before, - during_count: during, - after_count: during, - }, - }) + pendingVisibleClears.set(sessionKey, { before }) + } else if (during > 0) { + const pending = pendingVisibleClears.get(sessionKey) + if (pending) { + pendingVisibleClears.delete(sessionKey) + incidents.push({ + name: "incident.session_visible_messages_cleared", + level: "warn", + route_session_id: event.route_session_id, + visible_session_id: event.visible_session_id, + timeline_session_id: event.timeline_session_id, + data: { + before_count: pending.before, + during_count: 0, + after_count: during, + }, + }) + } } } @@ -125,8 +159,12 @@ const globalIncidentDetector = createRendererIncidentDetector() export async function emitRendererDiagnostic(event: RendererDiagnosticInput) { const api = typeof window === "undefined" ? undefined : window.api const emit = createRendererDiagnosticsEmitter({ api }) - await emit(event) - for (const incident of globalIncidentDetector(event)) { + const timedEvent = { + ...event, + monotonic_ms: event.monotonic_ms ?? performance.now(), + } + await emit(timedEvent) + for (const incident of globalIncidentDetector(timedEvent)) { await emit(incident) } } @@ -173,14 +211,17 @@ export function createSessionPerformanceDiagnostics(input: { const elapsedMs = Math.max(1, now - sampleStartedAt) const fps = Math.round((frameCount * 1000) / elapsedMs) const memory = performance as PerformanceWithMemory + const roundedFrameGap = Math.round(maxFrameGap) + const roundedLongTaskMax = Math.round(longTaskMax) + const base = baseEvent() void emit({ name: "renderer.perf.sample", - ...baseEvent(), + ...base, data: { fps, - frame_gap_ms: Math.round(maxFrameGap), + frame_gap_ms: roundedFrameGap, jank_count: jankCount, - long_task_max_ms: Math.round(longTaskMax), + long_task_max_ms: roundedLongTaskMax, long_task_block_ms: Math.round(longTaskBlock), cls, heap_used_mb: memory.memory?.usedJSHeapSize @@ -188,6 +229,22 @@ export function createSessionPerformanceDiagnostics(input: { : undefined, }, }) + if (cls >= 0.1) { + void emit({ + name: "incident.session_layout_shift", + level: "warn", + ...base, + data: { cls, phase: "perf_sample" }, + }) + } + if (roundedLongTaskMax >= 100 || roundedFrameGap >= 250) { + void emit({ + name: "incident.session_jank_burst", + level: "warn", + ...base, + data: { long_task_max_ms: roundedLongTaskMax, frame_gap_ms: roundedFrameGap, phase: "perf_sample" }, + }) + } frameCount = 0 jankCount = 0 maxFrameGap = 0 diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d22ca606..4d654793 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -126,31 +126,89 @@ export default function Page() { const parts = (message as { parts?: unknown }).parts return Array.isArray(parts) ? parts.length : 0 } - - createEffect(() => { - const routeSessionID = params.id - const visibleSessionID = timelineSessionID() + const timelineMessageMetrics = createMemo(() => { const messages = timelineMessages() - void emitRendererDiagnostic({ - name: "session.view.state", - route_session_id: routeSessionID, - visible_session_id: visibleSessionID, - timeline_session_id: visibleSessionID, - data: { - route_session_id: routeSessionID, - visible_session_id: visibleSessionID, - timeline_session_id: visibleSessionID, - route_ready: timelineMessagesReady(), - visible_ready: timelineMessagesReady(), - transitioning: !!routeSessionID && !!visibleSessionID && routeSessionID !== visibleSessionID, - message_count: messages.length, - part_count: messages.reduce((count, message) => count + countMessageParts(message), 0), - history_more: timelineHistoryMore(), - history_loading: timelineHistoryLoading(), - }, - }) + return { + messageCount: messages.length, + partCount: messages.reduce((count, message) => count + countMessageParts(message), 0), + } }) + createEffect( + on( + () => { + const routeSessionID = params.id + const visibleSessionID = timelineSessionID() + const metrics = timelineMessageMetrics() + return { + routeSessionID, + visibleSessionID, + routeReady: timelineMessagesReady(), + visibleReady: timelineMessagesReady(), + transitioning: !!routeSessionID && !!visibleSessionID && routeSessionID !== visibleSessionID, + messageCount: metrics.messageCount, + partCount: metrics.partCount, + historyMore: timelineHistoryMore(), + historyLoading: timelineHistoryLoading(), + } + }, + (state) => { + void emitRendererDiagnostic({ + name: "session.view.state", + route_session_id: state.routeSessionID, + visible_session_id: state.visibleSessionID, + timeline_session_id: state.visibleSessionID, + data: { + route_session_id: state.routeSessionID, + visible_session_id: state.visibleSessionID, + timeline_session_id: state.visibleSessionID, + route_ready: state.routeReady, + visible_ready: state.visibleReady, + transitioning: state.transitioning, + message_count: state.messageCount, + part_count: state.partCount, + history_more: state.historyMore, + history_loading: state.historyLoading, + }, + }) + }, + ), + ) + + createEffect( + on( + () => { + const id = timelineSessionID() + return { routeSessionID: params.id, visibleSessionID: id, timelineSessionID: id } + }, + (next, previous) => { + if (!previous) return + if ( + next.routeSessionID === previous.routeSessionID && + next.visibleSessionID === previous.visibleSessionID && + next.timelineSessionID === previous.timelineSessionID + ) { + return + } + void emitRendererDiagnostic({ + name: "session.identity.transition", + route_session_id: next.routeSessionID, + visible_session_id: next.visibleSessionID, + timeline_session_id: next.timelineSessionID, + data: { + from_route_session_id: previous.routeSessionID, + to_route_session_id: next.routeSessionID, + from_visible_session_id: previous.visibleSessionID, + to_visible_session_id: next.visibleSessionID, + from_timeline_session_id: previous.timelineSessionID, + to_timeline_session_id: next.timelineSessionID, + }, + }) + }, + { defer: true }, + ), + ) + createSessionPerformanceDiagnostics({ routeSessionID: () => params.id, visibleSessionID: timelineSessionID, @@ -232,6 +290,7 @@ export default function Page() { hasTodoCache: (id) => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined, syncSession: (id, options) => sync.session.sync(id, options), syncTodo: (id, options) => sync.session.todo(id, options), + emitRendererDiagnostic, }) useSessionVcsRefresh({ diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5c2dbf10..2c7c0c46 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -267,12 +267,23 @@ export function MessageTimeline(props: { const exportAvailable = createMemo(() => !!platform.exportSession && server.current?.type === "sidecar") const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) - const visibleRangeData = () => { + const visibleRange = createMemo(() => { const ids = rendered() + const first = ids[0] + const last = ids.at(-1) return { rendered_count: ids.length, - visible_first_message_id: ids[0], - visible_last_message_id: ids.at(-1), + visible_first_message_id: first, + visible_last_message_id: last, + signature: `${ids.length}:${first ?? ""}:${last ?? ""}`, + } + }) + const visibleRangeData = () => { + const range = visibleRange() + return { + rendered_count: range.rendered_count, + visible_first_message_id: range.visible_first_message_id, + visible_last_message_id: range.visible_last_message_id, } } const sessionKey = createMemo(() => props.sessionKey) @@ -304,7 +315,7 @@ export function MessageTimeline(props: { createEffect( on( - () => rendered().join("\u0000"), + () => visibleRange().signature, () => { void emitRendererDiagnostic({ name: "session.timeline.visible", diff --git a/packages/app/src/pages/session/use-session-refresh-effects.ts b/packages/app/src/pages/session/use-session-refresh-effects.ts index 42384155..11d6220e 100644 --- a/packages/app/src/pages/session/use-session-refresh-effects.ts +++ b/packages/app/src/pages/session/use-session-refresh-effects.ts @@ -1,5 +1,6 @@ import { createEffect, on, onCleanup, untrack } from "solid-js" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" +import type { RendererDiagnosticInput } from "@/context/platform" export function useSessionRefreshEffects(input: { directory: () => string @@ -11,12 +12,95 @@ export function useSessionRefreshEffects(input: { hasTodoCache: (sessionID: string) => boolean syncSession: (sessionID: string, options?: { force?: boolean }) => void | Promise syncTodo: (sessionID: string, options?: { force?: boolean }) => void | Promise + emitRendererDiagnostic?: (event: RendererDiagnosticInput) => void | Promise }) { let refreshFrame: number | undefined let refreshTimer: number | undefined let todoFrame: number | undefined let todoTimer: number | undefined + const emitRefresh = (event: RendererDiagnosticInput) => { + void input.emitRendererDiagnostic?.(event) + } + + const syncSessionWithDiagnostics = (id: string, options: { force?: boolean } | undefined, cachePresent: boolean) => { + const startedAt = performance.now() + const phase = options?.force ? "message_force" : "message" + emitRefresh({ + name: "session.data.refresh", + route_session_id: id, + visible_session_id: input.timelineSessionID(), + timeline_session_id: input.timelineSessionID(), + data: { phase: `${phase}_start`, cache_present: cachePresent }, + }) + void Promise.resolve(input.syncSession(id, options)) + .then(() => { + emitRefresh({ + name: "session.data.refresh", + route_session_id: id, + visible_session_id: input.timelineSessionID(), + timeline_session_id: input.timelineSessionID(), + data: { + phase: `${phase}_end`, + duration_ms: Math.round(performance.now() - startedAt), + cache_present: input.hasMessageCache(id), + }, + }) + }) + .catch(() => { + emitRefresh({ + name: "session.data.refresh", + route_session_id: id, + visible_session_id: input.timelineSessionID(), + timeline_session_id: input.timelineSessionID(), + data: { + phase: `${phase}_failed`, + duration_ms: Math.round(performance.now() - startedAt), + cache_present: input.hasMessageCache(id), + }, + }) + }) + } + + const syncTodoWithDiagnostics = (id: string, options: { force?: boolean } | undefined, cachePresent: boolean) => { + const startedAt = performance.now() + const phase = options?.force ? "todo_force" : "todo" + emitRefresh({ + name: "session.data.refresh", + route_session_id: input.routeSessionID(), + visible_session_id: id, + timeline_session_id: id, + data: { phase: `${phase}_start`, cache_present: cachePresent }, + }) + void Promise.resolve(input.syncTodo(id, options)) + .then(() => { + emitRefresh({ + name: "session.data.refresh", + route_session_id: input.routeSessionID(), + visible_session_id: id, + timeline_session_id: id, + data: { + phase: `${phase}_end`, + duration_ms: Math.round(performance.now() - startedAt), + cache_present: input.hasTodoCache(id), + }, + }) + }) + .catch(() => { + emitRefresh({ + name: "session.data.refresh", + route_session_id: input.routeSessionID(), + visible_session_id: id, + timeline_session_id: id, + data: { + phase: `${phase}_failed`, + duration_ms: Math.round(performance.now() - startedAt), + cache_present: input.hasTodoCache(id), + }, + }) + }) + } + createEffect( on([input.directory, input.routeSessionID] as const, ([, id]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) @@ -34,7 +118,7 @@ export function useSessionRefreshEffects(input: { return Date.now() - info.at > SESSION_PREFETCH_TTL })() untrack(() => { - void input.syncSession(id) + syncSessionWithDiagnostics(id, undefined, cached) }) refreshFrame = requestAnimationFrame(() => { @@ -43,7 +127,7 @@ export function useSessionRefreshEffects(input: { refreshTimer = undefined if (input.routeSessionID() !== id) return untrack(() => { - if (stale) void input.syncSession(id, { force: true }) + if (stale) syncSessionWithDiagnostics(id, { force: true }, cached) }) }, 0) }) @@ -71,7 +155,7 @@ export function useSessionRefreshEffects(input: { todoTimer = undefined if (input.directory() !== dir || input.timelineSessionID() !== id) return untrack(() => { - void input.syncTodo(id, cached ? { force: true } : undefined) + syncTodoWithDiagnostics(id, cached ? { force: true } : undefined, cached) }) }, 0) }) diff --git a/packages/desktop-electron/src/main/feedback.test.ts b/packages/desktop-electron/src/main/feedback.test.ts index 57896ba0..af8e445d 100644 --- a/packages/desktop-electron/src/main/feedback.test.ts +++ b/packages/desktop-electron/src/main/feedback.test.ts @@ -212,6 +212,20 @@ describe("feedback handler", () => { expect(diagnosticsContext).toBe("active") }) + test("passes IPC sender window override to context snapshot", async () => { + let receivedOverride: unknown + const subject = setup({ + context: (override) => { + receivedOverride = override + return "active" + }, + }) + + await subject.handler(undefined, { windowID: 7 }) + + expect(receivedOverride).toEqual({ windowID: 7 }) + }) + test("session export failure downgrades report", async () => { const subject = setup({ sessionExport: async () => { diff --git a/packages/desktop-electron/src/main/feedback.ts b/packages/desktop-electron/src/main/feedback.ts index 7456dc08..4d574dbb 100644 --- a/packages/desktop-electron/src/main/feedback.ts +++ b/packages/desktop-electron/src/main/feedback.ts @@ -24,10 +24,14 @@ type SaveReportInput = { markdown: string } +type FeedbackContextOverride = { + windowID?: number +} + type FeedbackDeps = { feedbackUrl: string reportRoot: string - context?: () => unknown + context?: (override?: FeedbackContextOverride) => unknown confirm: (context?: unknown) => Promise copy: (value: string) => Promise | void openExternal: (url: string) => Promise | void @@ -193,11 +197,14 @@ async function rendererDiagnosticsWithTimeout(deps: FeedbackDeps, context: unkno export function createFeedbackHandler(deps: FeedbackDeps) { let inFlight: Promise | undefined - async function runReportProblem(input: FeedbackInput = {}): Promise { + async function runReportProblem( + input: FeedbackInput = {}, + contextOverride?: FeedbackContextOverride, + ): Promise { if (!deps.feedbackUrl) { return { status: "unavailable", summaryCopied: false, feedbackOpened: false, fullReport: { status: "none" } } } - const context = deps.context?.() + const context = deps.context?.(contextOverride) const needsConfirm = input.confirm ?? true if (needsConfirm) { const confirmed = await deps.confirm(context) @@ -322,9 +329,12 @@ export function createFeedbackHandler(deps: FeedbackDeps) { } } - return async function reportProblem(input?: FeedbackInput): Promise { + return async function reportProblem( + input?: FeedbackInput, + contextOverride?: FeedbackContextOverride, + ): Promise { if (inFlight) return inFlight - const next = runReportProblem(input) + const next = runReportProblem(input, contextOverride) .catch(async (error) => { try { await deps.onError?.(error) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 9d3cc09e..771c64bc 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -235,11 +235,11 @@ type FeedbackRuntimeContext = { windowID?: number } -function currentFeedbackRuntimeContext(): FeedbackRuntimeContext { - const win = BrowserWindow.getFocusedWindow() +function currentFeedbackRuntimeContext(override?: { windowID?: number }): FeedbackRuntimeContext { + const win = override?.windowID ? BrowserWindow.fromId(override.windowID) : BrowserWindow.getFocusedWindow() return { - desktop: desktopContexts.current(win?.id), - windowID: win?.id, + desktop: desktopContexts.current(override?.windowID ?? win?.id), + windowID: override?.windowID ?? win?.id, } } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 7ed244b4..92914888 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -60,7 +60,7 @@ type Deps = { loadingWindowComplete: () => void runUpdater: (alertOnFail: boolean) => Promise | void checkUpdate: () => Promise - reportProblem: (input?: ReportProblemInput) => Promise + reportProblem: (input?: ReportProblemInput, context?: { windowID?: number }) => Promise installUpdate: () => Promise | boolean setBackgroundColor: (color: string) => void reportDeepLinkReady: (win: BrowserWindow | null) => void @@ -149,9 +149,10 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete()) ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail)) ipcMain.handle("check-update", () => deps.checkUpdate()) - ipcMain.handle("report-problem", (_event: IpcMainInvokeEvent, input?: ReportProblemInput) => - deps.reportProblem(input), - ) + ipcMain.handle("report-problem", (event: IpcMainInvokeEvent, input?: ReportProblemInput) => { + const win = BrowserWindow.fromWebContents(event.sender) + return deps.reportProblem(input, { windowID: win?.id }) + }) ipcMain.handle("renderer-diagnostics:record", (event: IpcMainInvokeEvent, input: unknown) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index faffd32c..61b4525b 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -299,6 +299,49 @@ describe("renderer diagnostics recorder", () => { expect(JSON.stringify(slice)).not.toContain("x".repeat(1000)) }) + test("slice re-sanitizes stored JSONL before exporting diagnostics", async () => { + const root = await tempRoot() + const recorder = createRendererDiagnosticsRecorder({ root, appLaunchID: "launch_1" }) + await writeFile( + recorder.path, + JSON.stringify({ + time: "2026-05-02T10:30:12.123Z", + level: "warn", + "event.name": "session.action.submit", + app_launch_id: "launch_1", + window_id: "1", + route_session_id: "ses_1", + trace_id: "https://example.com/token=secret", + data: { + action: "submit_prompt", + provider: "https://provider.example.com/v1?token=secret", + model: "deepseek.v4", + prompt_text: "do not export", + }, + }) + "\n", + "utf8", + ) + + const slice = await recorder.slice({ + sessionID: "ses_1", + maxBytes: 1024, + from: new Date("2026-05-02T10:30:00.000Z"), + to: new Date("2026-05-02T10:31:00.000Z"), + }) + + expect(slice.events).toHaveLength(1) + expect(slice.events[0]).toMatchObject({ + "event.name": "session.action.submit", + data: { + action: "submit_prompt", + model: "deepseek.v4", + }, + }) + expect(slice.events[0]?.trace_id).toBeUndefined() + expect(JSON.stringify(slice)).not.toContain("token=secret") + expect(JSON.stringify(slice)).not.toContain("do not export") + }) + test("reports missing, disabled, corrupt, and expired statuses without throwing", async () => { const root = await tempRoot() const missing = createRendererDiagnosticsRecorder({ root, appLaunchID: "launch_1" }) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index bd13b9ca..da7ad580 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -265,14 +265,38 @@ export function sanitizeRendererDiagnosticEvent( function parseEventLine(line: string): RendererDiagnosticEvent | undefined { try { - const value = JSON.parse(line) as RendererDiagnosticEvent + const value = JSON.parse(line) as Record if (!value || typeof value !== "object") return undefined - if (typeof value.time !== "string") return undefined - if (typeof value["event.name"] !== "string") return undefined - if (typeof value.app_launch_id !== "string") return undefined - if (typeof value.window_id !== "string") return undefined - if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) return undefined - return value + const time = stringField(value.time, 80) + if (!time || !Number.isFinite(Date.parse(time))) return undefined + const name = stringField(value["event.name"], 120) + if (!name || !isAllowedEventName(name)) return undefined + const appLaunchID = stringField(value.app_launch_id, 120) + const windowID = stringField(value.window_id, 80) + if (!appLaunchID || !windowID) return undefined + const event: RendererDiagnosticEvent = { + time, + level: value.level === "warn" ? "warn" : "info", + "event.name": name, + app_launch_id: appLaunchID, + window_id: windowID, + data: sanitizeData(name, value.data), + } + const monotonic = numberField(value.monotonic_ms) + if (monotonic !== undefined) event.monotonic_ms = monotonic + const traceID = stringField(value.trace_id, 80) + if (traceID) event.trace_id = traceID + const routeID = stringField(value.route_session_id, 120) + if (routeID) event.route_session_id = routeID + const visibleID = stringField(value.visible_session_id, 120) + if (visibleID) event.visible_session_id = visibleID + const timelineID = stringField(value.timeline_session_id, 120) + if (timelineID) event.timeline_session_id = timelineID + const messageID = stringField(value.message_id, 120) + if (messageID) event.message_id = messageID + const partID = stringField(value.part_id, 120) + if (partID) event.part_id = partID + return event } catch { return undefined } @@ -323,18 +347,26 @@ function isProtectedSliceContext(event: RendererDiagnosticEvent) { } function capEvents(events: RendererDiagnosticEvent[], maxBytes: number) { - let selected = events.slice() + const selected = events.map((event) => ({ + event, + bytes: jsonBytes(event), + })) + let totalBytes = + selected.length === 0 + ? Buffer.byteLength("[]", "utf8") + : 2 + selected.reduce((sum, item) => sum + item.bytes, 0) + selected.length - 1 let omitted = 0 - while (selected.length > 0 && jsonBytes(selected) > maxBytes) { - const removable = selected.findIndex((event) => !isProtectedSliceContext(event)) + while (selected.length > 0 && totalBytes > maxBytes) { + const removable = selected.findIndex((item) => !isProtectedSliceContext(item.event)) const index = removable >= 0 ? removable : 0 - selected.splice(index, 1) + const [removed] = selected.splice(index, 1) + if (removed) totalBytes -= removed.bytes + (selected.length > 0 ? 1 : 0) omitted++ } return { - events: selected, + events: selected.map((item) => item.event), omittedEventCount: omitted, - omittedBytes: Math.max(0, jsonBytes(events) - jsonBytes(selected)), + omittedBytes: Math.max(0, jsonBytes(events) - totalBytes), } } @@ -509,7 +541,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { if (!sanitized) return { ok: false as const, reason: "dropped" as const } if (highFrequencyEvents.has(sanitized["event.name"])) { const key = `${sanitized.window_id}:${sanitized["event.name"]}` - const current = Date.now() + const current = now().getTime() const previous = lastHighFrequency.get(key) if (previous !== undefined && current - previous < highFrequencyIntervalMs) { return { ok: false as const, reason: "rate_limited" as const } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 2e35dc1a..54438a86 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -235,6 +235,10 @@ const createPlatform = (): Platform => { reportProblem: (input) => window.api.reportProblem(input), + emitRendererDiagnostic: (event) => window.api.emitRendererDiagnostic(event), + + exportDiagnosticsLog: () => window.api.exportDiagnosticsLog(), + update: async () => { if (!UPDATER_ENABLED()) return await window.api.installUpdate() From ec54e5032519432d99f9e5fe27ff3f589b220453 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 08:47:08 +0800 Subject: [PATCH 12/15] fix: address diagnostics review followups --- .../app/src/context/renderer-diagnostics.ts | 41 ++++++++++++++++--- packages/app/src/pages/session.tsx | 2 +- .../src/pages/session/message-timeline.tsx | 3 ++ .../session/use-session-timeline-data.ts | 1 + packages/desktop-electron/src/main/index.ts | 2 +- packages/desktop-electron/src/main/ipc.ts | 2 - .../src/main/problem-report.test.ts | 2 +- .../src/main/problem-report.ts | 3 +- .../src/main/renderer-diagnostics.ts | 25 +++++++---- 9 files changed, 61 insertions(+), 20 deletions(-) diff --git a/packages/app/src/context/renderer-diagnostics.ts b/packages/app/src/context/renderer-diagnostics.ts index 0e4e4845..5bc39213 100644 --- a/packages/app/src/context/renderer-diagnostics.ts +++ b/packages/app/src/context/renderer-diagnostics.ts @@ -12,19 +12,32 @@ type PerformanceWithMemory = Performance & { } } +let warnedRendererDiagnosticsEmitFailure = false + +function warnRendererDiagnosticsEmitFailure(reason: string, error?: unknown) { + if (!import.meta.env.DEV || warnedRendererDiagnosticsEmitFailure) return + warnedRendererDiagnosticsEmitFailure = true + console.warn(`[renderer-diagnostics] ${reason}`, error) +} + export function createRendererDiagnosticsEmitter(input: { api?: DiagnosticsApi now?: () => number }) { return async (event: RendererDiagnosticInput) => { const emit = input.api?.emitRendererDiagnostic - if (!emit) return + if (!emit) { + warnRendererDiagnosticsEmitFailure("desktop diagnostics API is unavailable") + return + } try { await emit({ ...event, monotonic_ms: event.monotonic_ms ?? input.now?.() ?? performance.now(), }) - } catch {} + } catch (error) { + warnRendererDiagnosticsEmitFailure("failed to emit renderer diagnostic", error) + } } } @@ -42,6 +55,10 @@ function renderedCount(event: RendererDiagnosticInput) { return numericData(event, "rendered_count") ?? 0 } +function nearBottomThreshold(clientHeight: number) { + return Math.min(200, Math.max(80, clientHeight * 0.3)) +} + export function detectSessionScrollJumpToTop(event: RendererDiagnosticInput): RendererDiagnosticInput | undefined { if (event.name !== "session.scroll.sample") return const scrollTop = numericData(event, "scroll_top") @@ -49,7 +66,7 @@ export function detectSessionScrollJumpToTop(event: RendererDiagnosticInput): Re const clientHeight = numericData(event, "client_height") const userScrolled = booleanData(event, "user_scrolled") if (scrollTop === undefined || distanceFromBottom === undefined || clientHeight === undefined) return - if (scrollTop > 4 || distanceFromBottom < Math.max(100, clientHeight / 2) || userScrolled) return + if (scrollTop > 4 || distanceFromBottom < nearBottomThreshold(clientHeight) || userScrolled) return return { name: "incident.session_scroll_jump_to_top", level: "warn", @@ -90,7 +107,7 @@ export function createRendererIncidentDetector() { const clientHeight = numericData(event, "client_height") const nearBottom = distanceFromBottom !== undefined && clientHeight !== undefined - ? distanceFromBottom <= Math.max(100, clientHeight / 2) + ? distanceFromBottom <= nearBottomThreshold(clientHeight) : false const previous = lastScroll.get(sessionKey) const submit = recentSubmits.get(sessionKey) @@ -208,6 +225,17 @@ export function createSessionPerformanceDiagnostics(input: { const flush = () => { const now = performance.now() + if (document.visibilityState === "hidden") { + frameCount = 0 + jankCount = 0 + maxFrameGap = 0 + longTaskMax = 0 + longTaskBlock = 0 + cls = 0 + sampleStartedAt = now + lastFrame = now + return + } const elapsedMs = Math.max(1, now - sampleStartedAt) const fps = Math.round((frameCount * 1000) / elapsedMs) const memory = performance as PerformanceWithMemory @@ -224,6 +252,7 @@ export function createSessionPerformanceDiagnostics(input: { long_task_max_ms: roundedLongTaskMax, long_task_block_ms: Math.round(longTaskBlock), cls, + // Chrome exposes usedJSHeapSize in bytes. heap_used_mb: memory.memory?.usedJSHeapSize ? Math.round(memory.memory.usedJSHeapSize / 1024 / 1024) : undefined, @@ -262,7 +291,7 @@ export function createSessionPerformanceDiagnostics(input: { longTaskBlock += entry.duration } }) - longTaskObserver.observe({ entryTypes: ["longtask"] }) + longTaskObserver.observe({ type: "longtask", buffered: true }) } catch {} try { @@ -273,7 +302,7 @@ export function createSessionPerformanceDiagnostics(input: { if (!hadRecentInput && typeof value === "number") cls += value } }) - layoutShiftObserver.observe({ entryTypes: ["layout-shift"] }) + layoutShiftObserver.observe({ type: "layout-shift", buffered: true }) } catch {} } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4d654793..3068a940 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -143,7 +143,7 @@ export default function Page() { return { routeSessionID, visibleSessionID, - routeReady: timelineMessagesReady(), + routeReady: timeline.routeMessagesReady(), visibleReady: timelineMessagesReady(), transitioning: !!routeSessionID && !!visibleSessionID && routeSessionID !== visibleSessionID, messageCount: metrics.messageCount, diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 2c7c0c46..3e313063 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -237,6 +237,7 @@ export function MessageTimeline(props: { }) { let touchGesture: number | undefined let scrollSampleFrame: number | undefined + let mounted = true let pendingScrollSample: | { scroll_top: number @@ -259,6 +260,7 @@ export function MessageTimeline(props: { const platform = usePlatform() const server = useServer() onCleanup(() => { + mounted = false if (scrollSampleFrame !== undefined) cancelAnimationFrame(scrollSampleFrame) }) // Export hits the embedded sidecar via main-process IPC. When the user has switched the @@ -774,6 +776,7 @@ export function MessageTimeline(props: { if (scrollSampleFrame === undefined) { scrollSampleFrame = requestAnimationFrame(() => { scrollSampleFrame = undefined + if (!mounted) return const sample = pendingScrollSample pendingScrollSample = undefined if (!sample) return diff --git a/packages/app/src/pages/session/use-session-timeline-data.ts b/packages/app/src/pages/session/use-session-timeline-data.ts index 5b5e373f..867dac9c 100644 --- a/packages/app/src/pages/session/use-session-timeline-data.ts +++ b/packages/app/src/pages/session/use-session-timeline-data.ts @@ -128,6 +128,7 @@ export function createSessionTimelineData(input: { routeDiffs, routeSessionCount, routeHasSessionReview, + routeMessagesReady, sessionID, sessionKey, sessionInfo, diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 771c64bc..f82dd533 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -208,7 +208,7 @@ async function sessionExport(context = currentDesktopContext(), signal?: AbortSi async function exportDiagnosticsFromMenu() { const stamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") const result = await dialog.showSaveDialog({ - title: menuLabel(focusedMenuLocale(), "exportDiagnosticsLogTitle"), + title: menuLabel(focusedMenuLocale() ?? "en", "exportDiagnosticsLogTitle"), defaultPath: `pawwork-renderer-diagnostics-${stamp}.json`, filters: [{ name: "JSON", extensions: ["json"] }], }) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 92914888..cd6653f0 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -70,7 +70,6 @@ type Deps = { exportRendererDiagnostics: () => Promise<{ ok: true; path: string } | { ok: false; error: string }> rendererDiagnosticsSlice: (input: { sessionID: string - directory: string windowID?: number maxBytes: number }) => Promise @@ -362,7 +361,6 @@ export function registerIpcHandlers(deps: Deps) { const win = BrowserWindow.fromWebContents(event.sender) rendererDiagnostics = await deps.rendererDiagnosticsSlice({ sessionID, - directory, windowID: win?.id, maxBytes: SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, }) diff --git a/packages/desktop-electron/src/main/problem-report.test.ts b/packages/desktop-electron/src/main/problem-report.test.ts index 6e2fa944..4ac3de17 100644 --- a/packages/desktop-electron/src/main/problem-report.test.ts +++ b/packages/desktop-electron/src/main/problem-report.test.ts @@ -34,7 +34,7 @@ const base = { generated_at: "2026-04-23T01:02:03.004Z", events: [ { - ts: "2026-04-23T01:02:03.004Z", + time: "2026-04-23T01:02:03.004Z", "event.name": "session.action.submit", level: "info" as const, app_launch_id: "launch_1", diff --git a/packages/desktop-electron/src/main/problem-report.ts b/packages/desktop-electron/src/main/problem-report.ts index 5d47c708..ecd66bfd 100644 --- a/packages/desktop-electron/src/main/problem-report.ts +++ b/packages/desktop-electron/src/main/problem-report.ts @@ -308,7 +308,8 @@ export function buildProblemReport(input: Input, options: Options = {}) { let events = [...original.events] while (bytes(output) > maxBytes && events.length > 0) { const removeIndex = events.findIndex((event) => !isProtectedRendererDiagnosticEvent(event)) - events.splice(removeIndex >= 0 ? removeIndex : 0, 1) + if (removeIndex < 0) break + events.splice(removeIndex, 1) omittedRendererDiagnosticsBytes = Math.max(0, jsonBytes(original.events) - jsonBytes(events)) rendererDiagnostics = withRendererDiagnosticsEvents(original, events, omittedRendererDiagnosticsBytes) output = markdown(makePayload()) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index da7ad580..16aeec3d 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -98,13 +98,16 @@ type RecordContext = { } type SliceInput = { - appLaunchID?: string - windowID?: string | number sessionID?: string | null traceID?: string from?: Date to?: Date maxBytes: number +} + +type InternalSliceInput = SliceInput & { + appLaunchID?: string + windowID?: string | number now: Date } @@ -187,7 +190,7 @@ function stringField(value: unknown, limit = 160) { } function numberField(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : undefined + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value < 1e15 ? value : undefined } function booleanField(value: unknown) { @@ -224,6 +227,11 @@ function sanitizeData(name: keyof typeof eventDataFields, data: unknown) { const output: Record = {} for (const key of eventDataFields[name]) { if (!(key in input)) continue + if (key === "endpoint_kind") { + const value = stringField(input[key], 40) + if (value === "prompt" || value === "continue" || value === "edit") output[key] = value + continue + } const value = safeDataValue(input[key]) if (value !== undefined) output[key] = value } @@ -311,6 +319,7 @@ function eventMatchesSession(event: RendererDiagnosticEvent, sessionID: string) if (event.route_session_id === sessionID) return true if (event.visible_session_id === sessionID) return true if (event.timeline_session_id === sessionID) return true + if (event["event.name"] !== "session.identity.transition") return false const data = event.data return ( data.from_route_session_id === sessionID || @@ -372,7 +381,7 @@ function capEvents(events: RendererDiagnosticEvent[], maxBytes: number) { export function selectRendererDiagnosticsSlice( inputEvents: RendererDiagnosticEvent[], - input: SliceInput, + input: InternalSliceInput, ): RendererDiagnosticsSlice { const windowID = input.windowID === undefined ? undefined : String(input.windowID) const from = input.from?.getTime() ?? input.now.getTime() - 5 * 60 * 1000 @@ -490,7 +499,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { const readEvents = async () => (await readEventReport()).events - const flushRetention = async () => { + const flushRetentionNow = async () => { const events = await readEvents() const cutoff = now().getTime() - retentionMs const retained = events.filter((event) => eventTime(event) >= cutoff) @@ -527,7 +536,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { ) if (size <= maxBytes && current - lastRetentionCheck < retentionCheckIntervalMs) return lastRetentionCheck = current - await flushRetention() + await flushRetentionNow() } const record = async (input: unknown, context: RecordContext) => { @@ -560,7 +569,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { } } - const slice = async (input: Omit) => { + const slice = async (input: SliceInput & { windowID?: string | number }) => { if (options.disabled) return emptyRendererDiagnosticsSlice("disabled", now()) if (writeFailed) return emptyRendererDiagnosticsSlice("write_failed", now()) const report = await readEventReport() @@ -590,7 +599,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { return { path, record, - flushRetention, + flushRetention: () => enqueueWrite(flushRetentionNow), readEvents, readEventReport, slice, From a392526f178adaf8e6520a3cfd3c43607c372e56 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 08:51:06 +0800 Subject: [PATCH 13/15] fix: make diagnostics instrumentation fail open --- packages/app/src/pages/session.tsx | 7 ++++-- .../session/use-session-refresh-effects.ts | 25 +++++++++++-------- packages/desktop-electron/src/main/ipc.ts | 19 +++++++++++--- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3068a940..78fa2926 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -133,6 +133,9 @@ export default function Page() { partCount: messages.reduce((count, message) => count + countMessageParts(message), 0), } }) + const emitDiagnostics = (event: Parameters[0]) => { + void emitRendererDiagnostic(event).catch(() => undefined) + } createEffect( on( @@ -153,7 +156,7 @@ export default function Page() { } }, (state) => { - void emitRendererDiagnostic({ + emitDiagnostics({ name: "session.view.state", route_session_id: state.routeSessionID, visible_session_id: state.visibleSessionID, @@ -190,7 +193,7 @@ export default function Page() { ) { return } - void emitRendererDiagnostic({ + emitDiagnostics({ name: "session.identity.transition", route_session_id: next.routeSessionID, visible_session_id: next.visibleSessionID, diff --git a/packages/app/src/pages/session/use-session-refresh-effects.ts b/packages/app/src/pages/session/use-session-refresh-effects.ts index 11d6220e..7385108c 100644 --- a/packages/app/src/pages/session/use-session-refresh-effects.ts +++ b/packages/app/src/pages/session/use-session-refresh-effects.ts @@ -20,17 +20,21 @@ export function useSessionRefreshEffects(input: { let todoTimer: number | undefined const emitRefresh = (event: RendererDiagnosticInput) => { - void input.emitRendererDiagnostic?.(event) + try { + const pending = input.emitRendererDiagnostic?.(event) + void Promise.resolve(pending).catch(() => undefined) + } catch {} } const syncSessionWithDiagnostics = (id: string, options: { force?: boolean } | undefined, cachePresent: boolean) => { const startedAt = performance.now() + const visibleSessionID = input.timelineSessionID() const phase = options?.force ? "message_force" : "message" emitRefresh({ name: "session.data.refresh", route_session_id: id, - visible_session_id: input.timelineSessionID(), - timeline_session_id: input.timelineSessionID(), + visible_session_id: visibleSessionID, + timeline_session_id: visibleSessionID, data: { phase: `${phase}_start`, cache_present: cachePresent }, }) void Promise.resolve(input.syncSession(id, options)) @@ -38,8 +42,8 @@ export function useSessionRefreshEffects(input: { emitRefresh({ name: "session.data.refresh", route_session_id: id, - visible_session_id: input.timelineSessionID(), - timeline_session_id: input.timelineSessionID(), + visible_session_id: visibleSessionID, + timeline_session_id: visibleSessionID, data: { phase: `${phase}_end`, duration_ms: Math.round(performance.now() - startedAt), @@ -51,8 +55,8 @@ export function useSessionRefreshEffects(input: { emitRefresh({ name: "session.data.refresh", route_session_id: id, - visible_session_id: input.timelineSessionID(), - timeline_session_id: input.timelineSessionID(), + visible_session_id: visibleSessionID, + timeline_session_id: visibleSessionID, data: { phase: `${phase}_failed`, duration_ms: Math.round(performance.now() - startedAt), @@ -64,10 +68,11 @@ export function useSessionRefreshEffects(input: { const syncTodoWithDiagnostics = (id: string, options: { force?: boolean } | undefined, cachePresent: boolean) => { const startedAt = performance.now() + const routeSessionID = input.routeSessionID() const phase = options?.force ? "todo_force" : "todo" emitRefresh({ name: "session.data.refresh", - route_session_id: input.routeSessionID(), + route_session_id: routeSessionID, visible_session_id: id, timeline_session_id: id, data: { phase: `${phase}_start`, cache_present: cachePresent }, @@ -76,7 +81,7 @@ export function useSessionRefreshEffects(input: { .then(() => { emitRefresh({ name: "session.data.refresh", - route_session_id: input.routeSessionID(), + route_session_id: routeSessionID, visible_session_id: id, timeline_session_id: id, data: { @@ -89,7 +94,7 @@ export function useSessionRefreshEffects(input: { .catch(() => { emitRefresh({ name: "session.data.refresh", - route_session_id: input.routeSessionID(), + route_session_id: routeSessionID, visible_session_id: id, timeline_session_id: id, data: { diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index cd6653f0..38757ef7 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -33,6 +33,7 @@ const pickerFilters = (ext?: string[]) => { const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 // Picker approvals are short-lived because they authorize a renderer to request file bytes from main. const ATTACHMENT_APPROVAL_TTL_MS = 30 * 60 * 1000 +const RENDERER_DIAGNOSTICS_TIMEOUT_MS = 5_000 const MAX_APPROVED_ATTACHMENT_PATHS = 1000 function normalizeAttachmentPath(filepath: unknown) { @@ -359,10 +360,20 @@ export function registerIpcHandlers(deps: Deps) { let rendererDiagnostics: RendererDiagnosticsSlice try { const win = BrowserWindow.fromWebContents(event.sender) - rendererDiagnostics = await deps.rendererDiagnosticsSlice({ - sessionID, - windowID: win?.id, - maxBytes: SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, + let timeout: ReturnType | undefined + rendererDiagnostics = await Promise.race([ + deps.rendererDiagnosticsSlice({ + sessionID, + windowID: win?.id, + maxBytes: SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, + }), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error("renderer diagnostics timed out")) + }, RENDERER_DIAGNOSTICS_TIMEOUT_MS) + }), + ]).finally(() => { + if (timeout !== undefined) clearTimeout(timeout) }) } catch { rendererDiagnostics = emptyRendererDiagnosticsSlice("write_failed", new Date()) From 45e1c2b44ec75431c925dda8d02f3416ec0e023f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 09:23:12 +0800 Subject: [PATCH 14/15] fix: recover renderer diagnostics after transient failures --- packages/app/src/pages/session.tsx | 1 - packages/desktop-electron/src/main/index.ts | 2 +- .../src/main/renderer-diagnostics.test.ts | 40 +++++++++++++++++++ .../src/main/renderer-diagnostics.ts | 7 ++-- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 78fa2926..4bb0e9ef 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -216,7 +216,6 @@ export default function Page() { routeSessionID: () => params.id, visibleSessionID: timelineSessionID, timelineSessionID, - emit: emitRendererDiagnostic, }) createEffect(() => { diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index f82dd533..3e6c90e0 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -582,7 +582,7 @@ registerIpcHandlers({ loadingWindowComplete: () => loadingComplete.resolve(), runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), checkUpdate: async () => checkUpdate(), - reportProblem: (input) => reportProblem(input), + reportProblem: (input, context) => reportProblem(input, context), recordRendererDiagnostic: (event, context) => rendererDiagnostics.record(event, context), exportRendererDiagnostics: exportDiagnosticsFromMenu, rendererDiagnosticsSlice: ({ sessionID, windowID, maxBytes }) => diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index 61b4525b..8e929fad 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -381,6 +381,46 @@ describe("renderer diagnostics recorder", () => { ).toBe("expired") }) + test("recovers slices after a transient write failure", async () => { + const parent = await tempRoot() + const root = join(parent, "blocked") + await writeFile(root, "not a directory", "utf8") + const recorder = createRendererDiagnosticsRecorder({ + root, + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + }) + + expect( + (await recorder.record({ name: "session.action.submit", data: { action: "submit_prompt" } }, { windowID: 1 })) + .reason, + ).toBe("write_failed") + expect((await recorder.slice({ sessionID: "ses_1", maxBytes: 1024 })).status).toBe("write_failed") + + await rm(root, { force: true }) + expect( + ( + await recorder.record( + { + name: "session.action.submit", + route_session_id: "ses_1", + data: { action: "submit_prompt" }, + }, + { windowID: 1 }, + ) + ).ok, + ).toBe(true) + + const slice = await recorder.slice({ + sessionID: "ses_1", + maxBytes: 1024, + from: new Date("2026-05-02T10:30:00.000Z"), + to: new Date("2026-05-02T10:31:00.000Z"), + }) + expect(slice.status).toBe("ok") + expect(slice.events).toHaveLength(1) + }) + test("global export wraps diagnostics as JSON and caps old events", async () => { const root = await tempRoot() const source = join(root, "renderer-diagnostics.jsonl") diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index 16aeec3d..c3910e1e 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -562,6 +562,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { await appendFile(path, `${JSON.stringify(sanitized)}\n`, "utf8") await maybeFlushRetention() }) + writeFailed = false return { ok: true as const } } catch { writeFailed = true @@ -571,12 +572,12 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { const slice = async (input: SliceInput & { windowID?: string | number }) => { if (options.disabled) return emptyRendererDiagnosticsSlice("disabled", now()) - if (writeFailed) return emptyRendererDiagnosticsSlice("write_failed", now()) const report = await readEventReport() - if (report.status === "missing") return emptyRendererDiagnosticsSlice("missing", now()) + if (report.status === "missing") return emptyRendererDiagnosticsSlice(writeFailed ? "write_failed" : "missing", now()) if (report.status === "corrupt" || (report.events.length === 0 && report.corruptLineCount > 0)) { - return emptyRendererDiagnosticsSlice("corrupt", now()) + return emptyRendererDiagnosticsSlice(writeFailed && report.status === "corrupt" ? "write_failed" : "corrupt", now()) } + writeFailed = false const events = report.events if (events.length === 0) return emptyRendererDiagnosticsSlice("missing", now()) const windowID = input.windowID === undefined ? undefined : String(input.windowID) From 70f8cfc796445a5cc92cf9b894977a6122d77b41 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 3 May 2026 09:38:13 +0800 Subject: [PATCH 15/15] fix: drain renderer diagnostics before export --- packages/desktop-electron/src/main/index.ts | 1 + .../src/main/renderer-diagnostics.test.ts | 28 +++++++++++++++++++ .../src/main/renderer-diagnostics.ts | 6 ++++ 3 files changed, 35 insertions(+) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 3e6c90e0..a358f63e 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -215,6 +215,7 @@ async function exportDiagnosticsFromMenu() { if (result.canceled || !result.filePath) return { ok: false as const, error: "cancelled" } try { + await rendererDiagnostics.drain() await exportRendererDiagnosticsLog({ path: rendererDiagnostics.path, destination: result.filePath, diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts index 8e929fad..a7cf9f70 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.test.ts @@ -299,6 +299,34 @@ describe("renderer diagnostics recorder", () => { expect(JSON.stringify(slice)).not.toContain("x".repeat(1000)) }) + test("slice drains queued writes before reading", async () => { + const root = await tempRoot() + const recorder = createRendererDiagnosticsRecorder({ + root, + appLaunchID: "launch_1", + now: () => new Date("2026-05-02T10:30:12.123Z"), + }) + + const pending = recorder.record( + { + name: "session.action.submit", + route_session_id: "ses_1", + data: { action: "submit_prompt" }, + }, + { windowID: 1 }, + ) + const slice = await recorder.slice({ + sessionID: "ses_1", + maxBytes: 1024, + from: new Date("2026-05-02T10:30:00.000Z"), + to: new Date("2026-05-02T10:31:00.000Z"), + }) + + await pending + expect(slice.status).toBe("ok") + expect(slice.events).toHaveLength(1) + }) + test("slice re-sanitizes stored JSONL before exporting diagnostics", async () => { const root = await tempRoot() const recorder = createRendererDiagnosticsRecorder({ root, appLaunchID: "launch_1" }) diff --git a/packages/desktop-electron/src/main/renderer-diagnostics.ts b/packages/desktop-electron/src/main/renderer-diagnostics.ts index c3910e1e..84d31244 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics.ts @@ -528,6 +528,10 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { return next } + const drain = async () => { + await writeQueue + } + const maybeFlushRetention = async () => { const current = now().getTime() const size = await stat(path).then( @@ -572,6 +576,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { const slice = async (input: SliceInput & { windowID?: string | number }) => { if (options.disabled) return emptyRendererDiagnosticsSlice("disabled", now()) + await drain() const report = await readEventReport() if (report.status === "missing") return emptyRendererDiagnosticsSlice(writeFailed ? "write_failed" : "missing", now()) if (report.status === "corrupt" || (report.events.length === 0 && report.corruptLineCount > 0)) { @@ -601,6 +606,7 @@ export function createRendererDiagnosticsRecorder(options: RecorderOptions) { path, record, flushRetention: () => enqueueWrite(flushRetentionNow), + drain, readEvents, readEventReport, slice,