From cb4cff663b8030354bc4b3f534b7fc6537ea2bae Mon Sep 17 00:00:00 2001 From: David Sexton Date: Tue, 11 Aug 2026 19:52:55 -0700 Subject: [PATCH 1/2] Add opt-in diagnostics capture with one-click Markdown export Adds a bounded, in-memory-only ring buffer (src/diagnostics) that records console warnings/errors, connection lifecycle events, and sampled counters (inbound messages/sec, output lines/sec, long-task count/duration) while capture is enabled. A new Preferences > Diagnostics tab exposes an off-by-default "Capture diagnostics" checkbox, a "redact message text" toggle, and a "Copy diagnostics" button that serializes the buffer plus an environment/session snapshot to the clipboard as Markdown ready to paste into a GitHub issue. The UI states what the export contains before copying. The buffer never persists to localStorage/IndexedDB, disabling it is a zero-allocation no-op on the record() hot path, and record(category, data) is intentionally generic so the performance watchdog (issue #103) can feed it once both land. Fixes #100 Co-Authored-By: Claude Fable 5 --- src/App.test.tsx | 5 + src/App.tsx | 15 ++- src/EditorManager.ts | 11 ++ src/FileTransferManager.ts | 6 + src/components/preferences.tsx | 77 +++++++++++ src/diagnostics/connectionCapture.test.ts | 78 +++++++++++ src/diagnostics/connectionCapture.ts | 40 ++++++ src/diagnostics/consoleCapture.test.ts | 72 +++++++++++ src/diagnostics/consoleCapture.ts | 54 ++++++++ src/diagnostics/environment.test.ts | 67 ++++++++++ src/diagnostics/environment.ts | 87 +++++++++++++ src/diagnostics/index.test.ts | 64 ++++++++++ src/diagnostics/index.ts | 79 ++++++++++++ src/diagnostics/markdown.test.ts | 107 ++++++++++++++++ src/diagnostics/markdown.ts | 91 +++++++++++++ src/diagnostics/ringBuffer.test.ts | 91 +++++++++++++ src/diagnostics/ringBuffer.ts | 100 +++++++++++++++ src/diagnostics/samplers.test.ts | 149 ++++++++++++++++++++++ src/diagnostics/samplers.ts | 105 +++++++++++++++ src/stores/preferencesStore.test.ts | 19 +++ src/stores/preferencesStore.ts | 10 ++ 21 files changed, 1321 insertions(+), 6 deletions(-) create mode 100644 src/diagnostics/connectionCapture.test.ts create mode 100644 src/diagnostics/connectionCapture.ts create mode 100644 src/diagnostics/consoleCapture.test.ts create mode 100644 src/diagnostics/consoleCapture.ts create mode 100644 src/diagnostics/environment.test.ts create mode 100644 src/diagnostics/environment.ts create mode 100644 src/diagnostics/index.test.ts create mode 100644 src/diagnostics/index.ts create mode 100644 src/diagnostics/markdown.test.ts create mode 100644 src/diagnostics/markdown.ts create mode 100644 src/diagnostics/ringBuffer.test.ts create mode 100644 src/diagnostics/ringBuffer.ts create mode 100644 src/diagnostics/samplers.test.ts create mode 100644 src/diagnostics/samplers.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index f6faee1f..83dfb04f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -160,6 +160,11 @@ vi.mock('./logging/AutoLogService', () => ({ createAutoLogSessionDraft: vi.fn(() => ({})), })); +// Diagnostics wires itself up against the real preferences store shape +// (state.diagnostics.enabled); this test's mockPreferences doesn't model +// every preference domain, so stub the whole side-effecting module out. +vi.mock('./diagnostics', () => ({})); + import App from './App'; import { useConnectionStore } from './stores/connectionStore'; diff --git a/src/App.tsx b/src/App.tsx index 1c8db332..ef2433ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,9 @@ import WasmGuest from "./components/WasmGuest"; import type { WasmHostState } from "./components/WasmHost"; import WasmHost from "./components/WasmHost"; import { createConfiguredClient } from "./createConfiguredClient"; +// Side-effect import: wires diagnostics capture up to the "Capture +// diagnostics" preference as soon as the app loads. +import "./diagnostics"; import type { GMCPMessageRoomInfo } from "./gmcp/Room"; import { createHapticsRuntime, type HapticsRuntime } from "./haptics/runtime"; import { useChannelHistory } from "./hooks/useChannelHistory"; @@ -110,8 +113,8 @@ function App() { const clientInitialized = useRef(false); const hapticsRuntimeRef = useRef(null); - const midiEnabled = usePreferences((state) => state.midi.enabled); - const hapticsEnabled = usePreferences((state) => state.haptics.enabled); + const midiEnabled = usePreferences((state) => state.midi.enabled); + const hapticsEnabled = usePreferences((state) => state.haptics.enabled); const connected = useConnectionStore((state) => state.connected); const sessionReady = useConnectionStore((state) => state.sessionReady); useFileTransferNotifications(client); @@ -335,7 +338,7 @@ function App() { }, [handleAppKeyDown]); useEffect(() => { - if (!midiEnabled) return; + if (!midiEnabled) return; let cancelled = false; import("./VirtualMidiService") @@ -356,7 +359,7 @@ function App() { return () => { cancelled = true; }; - }, [midiEnabled]); + }, [midiEnabled]); // Window subtitle tracks the current room from the room store. On disconnect // the client resets the store, which clears roomInfo and so clears the subtitle. @@ -373,8 +376,8 @@ function App() { }, [client, roomInfo]); useEffect(() => { - hapticsRuntimeRef.current?.setEnabled(hapticsEnabled); - }, [hapticsEnabled]); + hapticsRuntimeRef.current?.setEnabled(hapticsEnabled); + }, [hapticsEnabled]); const handleCommand = useCallback( (text: string) => { diff --git a/src/EditorManager.ts b/src/EditorManager.ts index 05a8ec83..8b039064 100644 --- a/src/EditorManager.ts +++ b/src/EditorManager.ts @@ -21,6 +21,17 @@ export class EditorManager { this.setupChannelListeners(); } + /** Number of editor windows not yet closed, for diagnostics/status reporting. */ + get openEditorCount(): number { + let count = 0; + for (const session of this.editors.values()) { + if (session.state !== EditorState.Closed) { + count += 1; + } + } + return count; + } + openEditorWindow(editorSession: EditorSession) { console.log('Opening editor window for session:', editorSession); const id = editorSession.reference; diff --git a/src/FileTransferManager.ts b/src/FileTransferManager.ts index cb6793b7..231a7b70 100644 --- a/src/FileTransferManager.ts +++ b/src/FileTransferManager.ts @@ -63,6 +63,12 @@ export default class FileTransferManager extends EventEmitter { new Map(); private store: FileTransferStore; private storeInitialized: boolean = false; + + /** Count of transfers in flight or awaiting a response, for diagnostics/status reporting. */ + get activeTransferCount(): number { + return this.incomingTransfers.size + this.outgoingTransfers.size + this.pendingOffers.size; + } + private readonly handleDataChannelMessage = (data: ArrayBuffer): void => { void this.handleIncomingChunk(data); }; diff --git a/src/components/preferences.tsx b/src/components/preferences.tsx index b06bd646..3a3719fc 100644 --- a/src/components/preferences.tsx +++ b/src/components/preferences.tsx @@ -3,6 +3,7 @@ import { announce } from "@react-aria/live-announcer"; import type { AutoreadMode, NavigationKeyScheme } from "../stores/preferencesStore"; import { usePreferences } from "../stores/preferencesStore"; import { useVoices } from "../hooks/useVoices"; +import { copyDiagnosticsToClipboard } from "../diagnostics"; import Tabs, { type TabProps } from "./tabs"; import AutoLogDialog, { type AutoLogDialogRef } from "./AutoLogDialog"; @@ -478,6 +479,81 @@ const AutologgingTab: React.FC = () => { ); }; +const CopyDiagnosticsButton: React.FC<{ redactMessageText: boolean }> = ({ + redactMessageText, +}) => { + const [state, setState] = useState<"default" | "copied" | "error">("default"); + + const handleClick = async () => { + try { + await copyDiagnosticsToClipboard(redactMessageText); + setState("copied"); + announce("Diagnostics copied to clipboard", "polite"); + } catch (error) { + console.error("Failed to copy diagnostics:", error); + setState("error"); + announce("Failed to copy diagnostics", "assertive"); + } finally { + setTimeout(() => setState("default"), 1500); + } + }; + + const label = state === "copied" ? "Copied!" : state === "error" ? "Error" : "Copy diagnostics"; + + return ( + + ); +}; + +const DiagnosticsTab: React.FC = () => { + const diagnostics = usePreferences((state) => state.diagnostics); + const setDiagnostics = usePreferences((state) => state.setDiagnostics); + + return ( +
+ +
+
+

+ While enabled, the client keeps a small in-memory log of connection events, + console warnings/errors, and performance counters, to help diagnose problems. + Nothing is saved to disk; it disappears when the tab closes. +

+

+ Before you copy: the exported report includes your character + name, room names, and other session details, and — unless redacted below — + may include the text of recent messages. It never includes passwords, tokens, + or credentials. Review it before pasting into a public GitHub issue. +

+ +
+
+ +
+ ); +}; + const Preferences: React.FC = () => { const tabs: TabProps[] = [ { id: "preferences-general-tab", label: "General", content: }, @@ -488,6 +564,7 @@ const Preferences: React.FC = () => { { id: "preferences-midi-tab", label: "MIDI", content: }, { id: "preferences-haptics-tab", label: "Haptics", content: }, { id: "preferences-autologging-tab", label: "Logging", content: }, + { id: "preferences-diagnostics-tab", label: "Diagnostics", content: }, ]; return ; diff --git a/src/diagnostics/connectionCapture.test.ts b/src/diagnostics/connectionCapture.test.ts new file mode 100644 index 00000000..24bf7d0c --- /dev/null +++ b/src/diagnostics/connectionCapture.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useConnectionStore } from "../stores/connectionStore"; +import { DiagnosticsRingBuffer } from "./ringBuffer"; +import { startConnectionCapture } from "./connectionCapture"; + +describe("startConnectionCapture", () => { + beforeEach(() => { + useConnectionStore.getState().reset(); + }); + + afterEach(() => { + useConnectionStore.getState().reset(); + }); + + it("records the first connection without counting it as a reconnect", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startConnectionCapture(buffer); + + useConnectionStore.getState().setConnected(true); + + const snapshot = buffer.snapshot(); + expect(snapshot).toHaveLength(1); + expect(snapshot[0]).toMatchObject({ + category: "connection", + data: { event: "connected", reconnectCount: 0 }, + }); + + stop(); + }); + + it("increments reconnectCount on subsequent reconnects", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startConnectionCapture(buffer); + + useConnectionStore.getState().setConnected(true); + useConnectionStore.getState().setConnected(false); + useConnectionStore.getState().setConnected(true); + + const events = buffer.snapshot().map((r) => r.data); + expect(events).toEqual([ + { event: "connected", reconnectCount: 0 }, + { event: "disconnected", statusText: "Disconnected", reconnectCount: 0 }, + { event: "connected", reconnectCount: 1 }, + ]); + + stop(); + }); + + it("records status text changes that aren't connect/disconnect", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startConnectionCapture(buffer); + + useConnectionStore.getState().setStatusText("Reconnecting..."); + + const snapshot = buffer.snapshot(); + expect(snapshot).toHaveLength(1); + expect(snapshot[0]).toMatchObject({ + category: "connection", + data: { event: "status", statusText: "Reconnecting..." }, + }); + + stop(); + }); + + it("stops recording after the returned unsubscribe is called", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startConnectionCapture(buffer); + stop(); + + useConnectionStore.getState().setConnected(true); + + expect(buffer.snapshot()).toHaveLength(0); + }); +}); diff --git a/src/diagnostics/connectionCapture.ts b/src/diagnostics/connectionCapture.ts new file mode 100644 index 00000000..53d8365b --- /dev/null +++ b/src/diagnostics/connectionCapture.ts @@ -0,0 +1,40 @@ +import { useConnectionStore } from "../stores/connectionStore"; +import { type DiagnosticsRingBuffer, diagnosticsBuffer } from "./ringBuffer"; + +/** + * Records connection lifecycle transitions (connect / disconnect / status + * text updates) into the diagnostics buffer, and tracks a running reconnect + * count (the number of times the client reconnected after an initial + * connection was lost). + * + * Returns an unsubscribe function. + */ +export function startConnectionCapture( + buffer: DiagnosticsRingBuffer = diagnosticsBuffer, +): () => void { + let everConnected = useConnectionStore.getState().connected; + let reconnectCount = 0; + + return useConnectionStore.subscribe((state, previousState) => { + if (state.connected !== previousState.connected) { + if (state.connected) { + if (everConnected) { + reconnectCount += 1; + } + everConnected = true; + buffer.record("connection", { event: "connected", reconnectCount }); + } else { + buffer.record("connection", { + event: "disconnected", + statusText: state.statusText, + reconnectCount, + }); + } + return; + } + + if (state.statusText !== previousState.statusText) { + buffer.record("connection", { event: "status", statusText: state.statusText }); + } + }); +} diff --git a/src/diagnostics/consoleCapture.test.ts b/src/diagnostics/consoleCapture.test.ts new file mode 100644 index 00000000..0f65c6c0 --- /dev/null +++ b/src/diagnostics/consoleCapture.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DiagnosticsRingBuffer } from "./ringBuffer"; +import { capMessage, installConsoleCapture } from "./consoleCapture"; + +describe("capMessage", () => { + it("returns short strings unchanged", () => { + expect(capMessage("hello")).toBe("hello"); + }); + + it("truncates long strings and appends an ellipsis", () => { + const long = "x".repeat(600); + const capped = capMessage(long, 500); + expect(capped.length).toBe(501); + expect(capped.endsWith("…")).toBe(true); + }); +}); + +describe("installConsoleCapture", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("preserves original console.warn/error behavior", () => { + const buffer = new DiagnosticsRingBuffer(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const originalWarn = console.warn; + const uninstall = installConsoleCapture(buffer); + + console.warn("careful now"); + + expect(warnSpy).toHaveBeenCalledWith("careful now"); + uninstall(); + expect(console.warn).toBe(originalWarn); + }); + + it("records warn/error calls only while the buffer is enabled", () => { + const buffer = new DiagnosticsRingBuffer(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const uninstall = installConsoleCapture(buffer); + + console.warn("not captured yet"); + expect(buffer.snapshot()).toHaveLength(0); + + buffer.setEnabled(true); + console.warn("captured"); + console.error("also captured"); + + const snapshot = buffer.snapshot(); + expect(snapshot).toHaveLength(2); + expect(snapshot[0]).toMatchObject({ category: "console.warn", data: { message: "captured" } }); + expect(snapshot[1]).toMatchObject({ category: "console.error", data: { message: "also captured" } }); + + uninstall(); + }); + + it("caps long messages and joins multiple arguments", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + vi.spyOn(console, "error").mockImplementation(() => {}); + const uninstall = installConsoleCapture(buffer); + + console.error("prefix:", "x".repeat(600)); + + const [record] = buffer.snapshot(); + const message = record.data.message as string; + expect(message.startsWith("prefix:")).toBe(true); + expect(message.length).toBeLessThanOrEqual(501); + + uninstall(); + }); +}); diff --git a/src/diagnostics/consoleCapture.ts b/src/diagnostics/consoleCapture.ts new file mode 100644 index 00000000..4694fe4f --- /dev/null +++ b/src/diagnostics/consoleCapture.ts @@ -0,0 +1,54 @@ +import { type DiagnosticsRingBuffer, diagnosticsBuffer } from "./ringBuffer"; + +const MAX_MESSAGE_LENGTH = 500; + +function formatConsoleArg(value: unknown): string { + if (typeof value === "string") return value; + if (value instanceof Error) return `${value.name}: ${value.message}`; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Caps a string to `maxLength`, appending an ellipsis when truncated. */ +export function capMessage(value: string, maxLength: number = MAX_MESSAGE_LENGTH): string { + return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; +} + +function formatConsoleArgs(args: unknown[]): string { + return capMessage(args.map(formatConsoleArg).join(" ")); +} + +/** + * Wraps `console.warn` and `console.error` exactly once, forwarding to the + * original implementation first (so DevTools behavior is unchanged) and + * then recording a capped copy of the message into the diagnostics buffer. + * The buffer itself no-ops while disabled, so this wrapper stays cheap even + * when diagnostics capture is off. + * + * Returns a function that restores the original console methods, primarily + * for tests. + */ +export function installConsoleCapture( + buffer: DiagnosticsRingBuffer = diagnosticsBuffer, +): () => void { + const originalWarn = console.warn; + const originalError = console.error; + + console.warn = (...args: unknown[]) => { + originalWarn.apply(console, args); + buffer.record("console.warn", { message: formatConsoleArgs(args) }); + }; + + console.error = (...args: unknown[]) => { + originalError.apply(console, args); + buffer.record("console.error", { message: formatConsoleArgs(args) }); + }; + + return () => { + console.warn = originalWarn; + console.error = originalError; + }; +} diff --git a/src/diagnostics/environment.test.ts b/src/diagnostics/environment.test.ts new file mode 100644 index 00000000..94246fd8 --- /dev/null +++ b/src/diagnostics/environment.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import packageJson from "../../package.json"; +import { useConnectionStore } from "../stores/connectionStore"; +import { usePreferences } from "../stores/preferencesStore"; +import { buildEnvironmentSnapshot } from "./environment"; + +describe("buildEnvironmentSnapshot", () => { + afterEach(() => { + useConnectionStore.getState().reset(); + usePreferences.getState().setMidi({ enabled: false }); + }); + + it("reads environment/browser fields", () => { + const snapshot = buildEnvironmentSnapshot(null); + + expect(snapshot.userAgent).toBe(navigator.userAgent); + expect(snapshot.platform).toBe(navigator.platform); + expect(snapshot.appVersion).toBe(packageJson.version); + expect(typeof snapshot.pageUptimeMs).toBe("number"); + expect(snapshot.windowWidth).toBe(window.innerWidth); + expect(snapshot.windowHeight).toBe(window.innerHeight); + }); + + it("reflects connection store state", () => { + useConnectionStore.getState().setConnected(true); + useConnectionStore.getState().setSessionReady(true); + + const snapshot = buildEnvironmentSnapshot(null); + + expect(snapshot.connection).toEqual({ + status: "Connected", + connected: true, + sessionReady: true, + }); + }); + + it("reflects the MIDI preference", () => { + usePreferences.getState().setMidi({ enabled: true }); + + const snapshot = buildEnvironmentSnapshot(null); + + expect(snapshot.subsystems.midi.enabled).toBe(true); + }); + + it("reports subsystems as inactive when no client is available", () => { + const snapshot = buildEnvironmentSnapshot(null); + + expect(snapshot.subsystems.audio).toEqual({ live: false, audioContextState: null }); + expect(snapshot.subsystems.editors.openCount).toBe(0); + expect(snapshot.subsystems.fileTransfers.activeCount).toBe(0); + }); + + it("reads live subsystem state off a provided client", () => { + const fakeClient = { + media: { cacophony: { context: { state: "running" } } }, + editors: { openEditorCount: 2 }, + fileTransferManager: { activeTransferCount: 3 }, + // biome-ignore lint/suspicious/noExplicitAny: minimal test double, not a real MudClient + } as any; + + const snapshot = buildEnvironmentSnapshot(fakeClient); + + expect(snapshot.subsystems.audio).toEqual({ live: true, audioContextState: "running" }); + expect(snapshot.subsystems.editors.openCount).toBe(2); + expect(snapshot.subsystems.fileTransfers.activeCount).toBe(3); + }); +}); diff --git a/src/diagnostics/environment.ts b/src/diagnostics/environment.ts new file mode 100644 index 00000000..9e1d7e15 --- /dev/null +++ b/src/diagnostics/environment.ts @@ -0,0 +1,87 @@ +import packageJson from "../../package.json"; +import type MudClient from "../client"; +import { useConnectionStore } from "../stores/connectionStore"; +import { usePreferences } from "../stores/preferencesStore"; + +export interface EnvironmentSnapshot { + userAgent: string; + platform: string; + hardwareConcurrency: number | null; + /** Approximate device memory in GB, per the (Chromium-only) Device Memory API. */ + deviceMemoryGb: number | null; + windowWidth: number; + windowHeight: number; + devicePixelRatio: number; + pageUptimeMs: number; + appVersion: string; + connection: { + status: string; + connected: boolean; + sessionReady: boolean; + }; + subsystems: { + audio: { live: boolean; audioContextState: string | null }; + midi: { enabled: boolean }; + editors: { openCount: number }; + fileTransfers: { activeCount: number }; + }; +} + +/** Chrome/Edge-only; not part of the standard Navigator type. */ +interface NavigatorWithDeviceMemory extends Navigator { + deviceMemory?: number; +} + +function resolveClient(client: MudClient | null | undefined): MudClient | null { + if (client !== undefined) return client; + return typeof window !== "undefined" ? (window.mudClient ?? null) : null; +} + +/** + * Assembles the environment + session snapshot included in a diagnostics + * export. Reads live globals/stores at call time rather than tracking them + * continuously, since this only needs to run once per export. + */ +export function buildEnvironmentSnapshot( + client?: MudClient | null, +): EnvironmentSnapshot { + const resolvedClient = resolveClient(client); + const nav = navigator as NavigatorWithDeviceMemory; + const connection = useConnectionStore.getState(); + const midi = usePreferences.getState().midi; + + // `BaseContext` is a cacophony-defined subset of AudioContext that doesn't + // declare `state`, but the real context is always a browser AudioContext. + const audioContext = resolvedClient?.media?.cacophony?.context as + | { state?: string } + | undefined; + const audioContextState = audioContext?.state ?? null; + + return { + userAgent: navigator.userAgent, + platform: navigator.platform, + hardwareConcurrency: navigator.hardwareConcurrency ?? null, + deviceMemoryGb: nav.deviceMemory ?? null, + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + pageUptimeMs: Math.round(performance.now()), + appVersion: packageJson.version, + connection: { + status: connection.statusText, + connected: connection.connected, + sessionReady: connection.sessionReady, + }, + subsystems: { + audio: { + live: audioContextState === "running", + audioContextState, + }, + midi: { enabled: midi.enabled }, + editors: { openCount: resolvedClient?.editors?.openEditorCount ?? 0 }, + fileTransfers: { + activeCount: resolvedClient?.fileTransferManager?.activeTransferCount ?? 0, + }, + }, + }; +} diff --git a/src/diagnostics/index.test.ts b/src/diagnostics/index.test.ts new file mode 100644 index 00000000..be54296c --- /dev/null +++ b/src/diagnostics/index.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useConnectionStore } from "../stores/connectionStore"; +import { usePreferences } from "../stores/preferencesStore"; +import { + copyDiagnosticsToClipboard, + diagnosticsBuffer, + getDiagnosticsMarkdown, +} from "./index"; + +describe("diagnostics service wiring", () => { + beforeEach(() => { + usePreferences.getState().setDiagnostics({ enabled: false, redactMessageText: false }); + diagnosticsBuffer.clear(); + useConnectionStore.getState().reset(); + }); + + afterEach(() => { + usePreferences.getState().setDiagnostics({ enabled: false, redactMessageText: false }); + diagnosticsBuffer.clear(); + useConnectionStore.getState().reset(); + vi.restoreAllMocks(); + }); + + it("keeps the ring buffer disabled until the preference is turned on", () => { + expect(diagnosticsBuffer.isEnabled()).toBe(false); + + useConnectionStore.getState().setConnected(true); + expect(diagnosticsBuffer.snapshot()).toHaveLength(0); + }); + + it("starts capturing (e.g. connection lifecycle) once the preference is enabled", () => { + usePreferences.getState().setDiagnostics({ enabled: true, redactMessageText: false }); + expect(diagnosticsBuffer.isEnabled()).toBe(true); + + useConnectionStore.getState().setConnected(true); + + const snapshot = diagnosticsBuffer.snapshot(); + expect(snapshot.some((r) => r.category === "connection")).toBe(true); + }); + + it("stops capturing once the preference is disabled again", () => { + usePreferences.getState().setDiagnostics({ enabled: true, redactMessageText: false }); + usePreferences.getState().setDiagnostics({ enabled: false, redactMessageText: false }); + + useConnectionStore.getState().setConnected(true); + expect(diagnosticsBuffer.snapshot()).toHaveLength(0); + }); + + it("getDiagnosticsMarkdown returns a full export even with an empty buffer", () => { + const markdown = getDiagnosticsMarkdown(false); + expect(markdown).toContain("# Mongoose Client Diagnostics"); + expect(markdown).toContain("## Recent events (0)"); + }); + + it("copyDiagnosticsToClipboard writes the markdown export to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + await copyDiagnosticsToClipboard(false); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toContain("# Mongoose Client Diagnostics"); + }); +}); diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts new file mode 100644 index 00000000..c7a01d6f --- /dev/null +++ b/src/diagnostics/index.ts @@ -0,0 +1,79 @@ +import { usePreferences } from "../stores/preferencesStore"; +import { installConsoleCapture } from "./consoleCapture"; +import { startConnectionCapture } from "./connectionCapture"; +import { buildEnvironmentSnapshot } from "./environment"; +import { serializeDiagnosticsMarkdown } from "./markdown"; +import { diagnosticsBuffer } from "./ringBuffer"; +import { startCounterSampling, startLongTaskObserver } from "./samplers"; + +export { diagnosticsBuffer } from "./ringBuffer"; +export type { DiagnosticRecord } from "./ringBuffer"; +export type { EnvironmentSnapshot } from "./environment"; + +/** + * Owns the diagnostics capture lifecycle: keeps the ring buffer's enabled + * state in sync with the "Capture diagnostics" preference, and starts/stops + * the sampling-based capture subsystems (connection lifecycle, counters, + * long tasks) alongside it. + * + * Console capture is installed once, unconditionally, for the lifetime of + * the app — it's cheap when disabled (the buffer no-ops), and installing it + * only on first enable would miss warnings/errors logged before the user + * ever opts in. + */ +class DiagnosticsService { + private stopCaptureSubsystems: (() => void) | null = null; + + constructor() { + installConsoleCapture(); + + const applyEnabled = (enabled: boolean): void => { + diagnosticsBuffer.setEnabled(enabled); + if (enabled) { + this.startCaptureSubsystems(); + } else { + this.stopCaptureSubsystemsIfRunning(); + } + }; + + applyEnabled(usePreferences.getState().diagnostics.enabled); + usePreferences.subscribe((state) => state.diagnostics.enabled, applyEnabled); + } + + private startCaptureSubsystems(): void { + if (this.stopCaptureSubsystems) return; + + const stopConnection = startConnectionCapture(); + const stopCounters = startCounterSampling(); + const stopLongTasks = startLongTaskObserver(); + + this.stopCaptureSubsystems = () => { + stopConnection(); + stopCounters(); + stopLongTasks(); + }; + } + + private stopCaptureSubsystemsIfRunning(): void { + this.stopCaptureSubsystems?.(); + this.stopCaptureSubsystems = null; + } +} + +/** Side-effecting singleton — importing this module starts diagnostics + * capture wiring. Import it once from app startup (see App.tsx). */ +export const diagnosticsService = new DiagnosticsService(); + +/** Builds the full Markdown export (environment + session + recent events). */ +export function getDiagnosticsMarkdown(redactMessageText: boolean): string { + const environment = buildEnvironmentSnapshot(); + return serializeDiagnosticsMarkdown(diagnosticsBuffer.snapshot(), environment, { + redactMessageText, + }); +} + +/** Serializes the diagnostics export to Markdown and copies it to the clipboard. */ +export async function copyDiagnosticsToClipboard(redactMessageText: boolean): Promise { + const markdown = getDiagnosticsMarkdown(redactMessageText); + await navigator.clipboard.writeText(markdown); +} diff --git a/src/diagnostics/markdown.test.ts b/src/diagnostics/markdown.test.ts new file mode 100644 index 00000000..3585c80b --- /dev/null +++ b/src/diagnostics/markdown.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { EnvironmentSnapshot } from "./environment"; +import { serializeDiagnosticsMarkdown } from "./markdown"; +import type { DiagnosticRecord } from "./ringBuffer"; + +const environment: EnvironmentSnapshot = { + userAgent: "TestAgent/1.0", + platform: "TestOS", + hardwareConcurrency: 8, + deviceMemoryGb: 8, + windowWidth: 1280, + windowHeight: 800, + devicePixelRatio: 1, + pageUptimeMs: 12345, + appVersion: "0.7.0", + connection: { status: "Connected", connected: true, sessionReady: true }, + subsystems: { + audio: { live: true, audioContextState: "running" }, + midi: { enabled: false }, + editors: { openCount: 0 }, + fileTransfers: { activeCount: 0 }, + }, +}; + +describe("serializeDiagnosticsMarkdown", () => { + it("produces a markdown document with expected section headers", () => { + const markdown = serializeDiagnosticsMarkdown([], environment, { + redactMessageText: false, + }); + + expect(markdown).toContain("# Mongoose Client Diagnostics"); + expect(markdown).toContain("## Environment"); + expect(markdown).toContain("## Session"); + expect(markdown).toContain("## Recent events (0)"); + expect(markdown).toContain("_No diagnostics recorded yet._"); + expect(markdown).toContain("User agent: TestAgent/1.0"); + expect(markdown).toContain("App version: 0.7.0"); + expect(markdown).toContain("Connection status: Connected"); + }); + + it("renders a markdown table row per record", () => { + const records: DiagnosticRecord[] = [ + { ts: 1710000000000, category: "connection", data: { event: "connected", reconnectCount: 0 } }, + { ts: 1710000005000, category: "counters", data: { outputLinesPerSec: 1.2 } }, + ]; + + const markdown = serializeDiagnosticsMarkdown(records, environment, { + redactMessageText: false, + }); + + expect(markdown).toContain("| Time | Category | Data |"); + expect(markdown).toContain("| --- | --- | --- |"); + expect(markdown).toContain("connection"); + expect(markdown).toContain('"event":"connected"'); + expect(markdown).toContain("counters"); + expect(markdown).toContain('"outputLinesPerSec":1.2'); + expect(markdown).toContain("## Recent events (2)"); + }); + + it("leaves message text intact when redaction is off", () => { + const records: DiagnosticRecord[] = [ + { ts: 1710000000000, category: "console.error", data: { message: "you say hello to Bob" } }, + ]; + + const markdown = serializeDiagnosticsMarkdown(records, environment, { + redactMessageText: false, + }); + + expect(markdown).toContain("you say hello to Bob"); + expect(markdown).toContain("Message text redaction: **off**"); + }); + + it("redacts fields that look like message/chat text when redaction is on", () => { + const records: DiagnosticRecord[] = [ + { + ts: 1710000000000, + category: "console.error", + data: { message: "you say hello to Bob", code: "ERR_1" }, + }, + ]; + + const markdown = serializeDiagnosticsMarkdown(records, environment, { + redactMessageText: true, + }); + + expect(markdown).not.toContain("you say hello to Bob"); + expect(markdown).toContain("[redacted]"); + // Non-text fields are left alone. + expect(markdown).toContain("ERR_1"); + expect(markdown).toContain("Message text redaction: **on**"); + }); + + it("escapes pipe characters so records can't break the table", () => { + const records: DiagnosticRecord[] = [ + { ts: 1710000000000, category: "console.warn", data: { message: "a | b" } }, + ]; + + const markdown = serializeDiagnosticsMarkdown(records, environment, { + redactMessageText: false, + }); + + const tableLine = markdown.split("\n").find((line) => line.includes("console.warn")); + // The row should still have exactly 3 unescaped column-delimiting pipes + // (leading, between, trailing) plus the escaped one from the data. + expect(tableLine).toContain("a \\| b"); + }); +}); diff --git a/src/diagnostics/markdown.ts b/src/diagnostics/markdown.ts new file mode 100644 index 00000000..8b2607e6 --- /dev/null +++ b/src/diagnostics/markdown.ts @@ -0,0 +1,91 @@ +import type { EnvironmentSnapshot } from "./environment"; +import type { DiagnosticRecord } from "./ringBuffer"; + +export interface MarkdownOptions { + /** When true, string fields that look like they hold message/chat text + * (key matches /message|text/i) are replaced with a placeholder. */ + redactMessageText: boolean; +} + +const REDACTED_PLACEHOLDER = "[redacted]"; +const TEXT_FIELD_PATTERN = /message|text/i; + +function redactRecordData(data: Record): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(data)) { + redacted[key] = + typeof value === "string" && TEXT_FIELD_PATTERN.test(key) ? REDACTED_PLACEHOLDER : value; + } + return redacted; +} + +function formatRecordRow(record: DiagnosticRecord, redactMessageText: boolean): string { + const data = redactMessageText ? redactRecordData(record.data) : record.data; + const time = new Date(record.ts).toISOString(); + const json = JSON.stringify(data).replace(/\|/g, "\\|"); + return `| ${time} | ${record.category} | \`${json}\` |`; +} + +/** + * Serializes a diagnostics buffer snapshot + environment snapshot to + * Markdown, ready to paste into a GitHub issue. + */ +export function serializeDiagnosticsMarkdown( + records: DiagnosticRecord[], + environment: EnvironmentSnapshot, + options: MarkdownOptions, +): string { + const lines: string[] = []; + + lines.push("# Mongoose Client Diagnostics"); + lines.push(""); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push( + options.redactMessageText + ? "Message text redaction: **on** (fields that look like message/chat text were replaced)" + : "Message text redaction: **off** (message/chat text may be present below)", + ); + lines.push(""); + + lines.push("## Environment"); + lines.push(""); + lines.push(`- User agent: ${environment.userAgent}`); + lines.push(`- Platform: ${environment.platform}`); + lines.push(`- Hardware concurrency: ${environment.hardwareConcurrency ?? "unknown"}`); + lines.push( + `- Device memory: ${environment.deviceMemoryGb !== null ? `${environment.deviceMemoryGb} GB` : "unknown"}`, + ); + lines.push(`- Window size: ${environment.windowWidth}x${environment.windowHeight}`); + lines.push(`- Device pixel ratio: ${environment.devicePixelRatio}`); + lines.push(`- Page uptime: ${(environment.pageUptimeMs / 1000).toFixed(1)}s`); + lines.push(`- App version: ${environment.appVersion}`); + lines.push(""); + + lines.push("## Session"); + lines.push(""); + lines.push(`- Connection status: ${environment.connection.status}`); + lines.push(`- Connected: ${environment.connection.connected}`); + lines.push(`- Session ready: ${environment.connection.sessionReady}`); + lines.push( + `- Audio: live=${environment.subsystems.audio.live}, AudioContext.state=${environment.subsystems.audio.audioContextState ?? "n/a"}`, + ); + lines.push(`- MIDI enabled: ${environment.subsystems.midi.enabled}`); + lines.push(`- Editors open: ${environment.subsystems.editors.openCount}`); + lines.push(`- Active file transfers: ${environment.subsystems.fileTransfers.activeCount}`); + lines.push(""); + + lines.push(`## Recent events (${records.length})`); + lines.push(""); + if (records.length === 0) { + lines.push("_No diagnostics recorded yet._"); + } else { + lines.push("| Time | Category | Data |"); + lines.push("| --- | --- | --- |"); + for (const record of records) { + lines.push(formatRecordRow(record, options.redactMessageText)); + } + } + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/diagnostics/ringBuffer.test.ts b/src/diagnostics/ringBuffer.test.ts new file mode 100644 index 00000000..2a0b0149 --- /dev/null +++ b/src/diagnostics/ringBuffer.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { DiagnosticsRingBuffer } from "./ringBuffer"; + +describe("DiagnosticsRingBuffer", () => { + it("is disabled by default and record() is a no-op", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.record("console.warn", { message: "hi" }); + expect(buffer.snapshot()).toEqual([]); + }); + + it("does not allocate or serialize when disabled", () => { + const buffer = new DiagnosticsRingBuffer(); + const stringifySpy = vi.spyOn(JSON, "stringify"); + + buffer.record("console.warn", { message: "hi" }); + + expect(stringifySpy).not.toHaveBeenCalled(); + stringifySpy.mockRestore(); + }); + + it("records entries once enabled", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + buffer.record("connection", { event: "connected" }); + + const snapshot = buffer.snapshot(); + expect(snapshot).toHaveLength(1); + expect(snapshot[0]).toMatchObject({ + category: "connection", + data: { event: "connected" }, + }); + expect(typeof snapshot[0].ts).toBe("number"); + }); + + it("stops recording once disabled again, without clearing prior records", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + buffer.record("connection", { event: "connected" }); + buffer.setEnabled(false); + buffer.record("connection", { event: "disconnected" }); + + const snapshot = buffer.snapshot(); + expect(snapshot).toHaveLength(1); + expect(snapshot[0].data).toEqual({ event: "connected" }); + }); + + it("bounds by record count, evicting oldest first", () => { + const buffer = new DiagnosticsRingBuffer(5, 1024 * 1024); + buffer.setEnabled(true); + + for (let i = 0; i < 10; i++) { + buffer.record("counters", { i }); + } + + const snapshot = buffer.snapshot(); + expect(snapshot).toHaveLength(5); + expect(snapshot.map((r) => r.data.i)).toEqual([5, 6, 7, 8, 9]); + }); + + it("bounds by rough byte estimate, evicting oldest first", () => { + const buffer = new DiagnosticsRingBuffer(1000, 200); + buffer.setEnabled(true); + + const bigString = "x".repeat(100); + for (let i = 0; i < 5; i++) { + buffer.record("console.error", { message: bigString, i }); + } + + const snapshot = buffer.snapshot(); + // Each record is well over 100 bytes serialized, so a 200-byte cap + // should only ever keep the most recent one or two. + expect(snapshot.length).toBeLessThan(5); + expect(snapshot.at(-1)?.data.i).toBe(4); + }); + + it("clear() empties the buffer", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + buffer.record("connection", { event: "connected" }); + buffer.clear(); + + expect(buffer.snapshot()).toEqual([]); + }); + + it("isEnabled() reflects the current state", () => { + const buffer = new DiagnosticsRingBuffer(); + expect(buffer.isEnabled()).toBe(false); + buffer.setEnabled(true); + expect(buffer.isEnabled()).toBe(true); + }); +}); diff --git a/src/diagnostics/ringBuffer.ts b/src/diagnostics/ringBuffer.ts new file mode 100644 index 00000000..41f8aef9 --- /dev/null +++ b/src/diagnostics/ringBuffer.ts @@ -0,0 +1,100 @@ +/** + * In-memory, bounded ring buffer for diagnostics records. + * + * Records are never persisted (no localStorage / IndexedDB) — the buffer + * lives for the tab's lifetime only, per the privacy requirements in + * issue #100. It is capped by both record count and a rough byte estimate + * so a burst of large records can't blow past a reasonable memory budget + * even while under the record-count cap. + * + * `record()` is designed to be called from hot paths (console wrapping, + * store subscriptions). When disabled it returns immediately, before any + * object is allocated or serialized. + */ + +export interface DiagnosticRecord { + /** Epoch milliseconds when the record was captured. */ + ts: number; + /** Coarse grouping, e.g. "console.warn", "connection", "counters". */ + category: string; + /** Structured payload. Keep this JSON-serializable. */ + data: Record; +} + +export const DEFAULT_MAX_RECORDS = 500; +export const DEFAULT_MAX_BYTES = 256 * 1024; // 256 KB + +/** Rough byte estimate for a record. Falls back to a fixed guess for data + * that can't be JSON-serialized (e.g. it contains a circular reference). */ +function estimateBytes(record: DiagnosticRecord): number { + try { + return JSON.stringify(record).length; + } catch { + return 256; + } +} + +interface StoredRecord { + record: DiagnosticRecord; + size: number; +} + +export class DiagnosticsRingBuffer { + private entries: StoredRecord[] = []; + private bytes = 0; + private enabled = false; + + constructor( + private readonly maxRecords: number = DEFAULT_MAX_RECORDS, + private readonly maxBytes: number = DEFAULT_MAX_BYTES, + ) {} + + isEnabled(): boolean { + return this.enabled; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + /** + * Records a structured diagnostics entry. No-op (zero allocation) when + * disabled, so it's cheap to call unconditionally from hot paths. + */ + record(category: string, data: Record): void { + if (!this.enabled) return; + + const record: DiagnosticRecord = { ts: Date.now(), category, data }; + const size = estimateBytes(record); + this.entries.push({ record, size }); + this.bytes += size; + this.evict(); + } + + private evict(): void { + while ( + this.entries.length > 0 && + (this.entries.length > this.maxRecords || this.bytes > this.maxBytes) + ) { + const removed = this.entries.shift(); + if (removed) { + this.bytes -= removed.size; + } + } + } + + /** Returns a snapshot copy of the current records, oldest first. */ + snapshot(): DiagnosticRecord[] { + return this.entries.map((entry) => entry.record); + } + + clear(): void { + this.entries = []; + this.bytes = 0; + } +} + +/** Shared singleton used by the rest of the app. Tests should construct + * their own `DiagnosticsRingBuffer` instance rather than relying on this + * shared, mutable instance. */ +export const diagnosticsBuffer = new DiagnosticsRingBuffer(); diff --git a/src/diagnostics/samplers.test.ts b/src/diagnostics/samplers.test.ts new file mode 100644 index 00000000..124a1352 --- /dev/null +++ b/src/diagnostics/samplers.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useChannelHistoryStore } from "../stores/channelHistoryStore"; +import { useOutputStore } from "../stores/outputStore"; +import { DiagnosticsRingBuffer } from "./ringBuffer"; +import { startCounterSampling, startLongTaskObserver } from "./samplers"; + +describe("startCounterSampling", () => { + beforeEach(() => { + vi.useFakeTimers(); + useOutputStore.getState().reset(); + useChannelHistoryStore.getState().reset(); + }); + + afterEach(() => { + vi.useRealTimers(); + useOutputStore.getState().reset(); + useChannelHistoryStore.getState().reset(); + }); + + it("records a zero-rate sample when nothing happened", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startCounterSampling(buffer, 5000); + + vi.advanceTimersByTime(5000); + + const [record] = buffer.snapshot(); + expect(record).toMatchObject({ + category: "counters", + data: { outputLinesPerSec: 0, inboundMessagesPerSec: 0 }, + }); + + stop(); + }); + + it("computes rates from entry-id deltas between samples", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startCounterSampling(buffer, 5000); + + for (let i = 0; i < 10; i++) { + useOutputStore.getState().addMessage(`line ${i}`); + } + for (let i = 0; i < 5; i++) { + useChannelHistoryStore.getState().addChannelText({ channel: "sayto", talker: "a", text: "hi" }); + } + + vi.advanceTimersByTime(5000); + + const [record] = buffer.snapshot(); + expect(record.data.outputLinesPerSec).toBe(2); // 10 lines / 5s + expect(record.data.inboundMessagesPerSec).toBe(1); // 5 messages / 5s + + stop(); + }); + + it("stops sampling once stopped", () => { + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startCounterSampling(buffer, 5000); + stop(); + + vi.advanceTimersByTime(20000); + + expect(buffer.snapshot()).toHaveLength(0); + }); +}); + +describe("startLongTaskObserver", () => { + const originalPerformanceObserver = globalThis.PerformanceObserver; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + if (originalPerformanceObserver) { + globalThis.PerformanceObserver = originalPerformanceObserver; + } else { + // @ts-expect-error - cleaning up a test-only global + delete globalThis.PerformanceObserver; + } + }); + + it("does nothing when PerformanceObserver is unavailable", () => { + // @ts-expect-error - simulating an environment without PerformanceObserver + delete globalThis.PerformanceObserver; + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + + const stop = startLongTaskObserver(buffer, 5000); + vi.advanceTimersByTime(5000); + + expect(buffer.snapshot()).toHaveLength(0); + stop(); + }); + + it("aggregates observed long tasks and flushes them on the sample interval", () => { + let callback: (list: { getEntries: () => { duration: number }[] }) => void = () => {}; + const disconnect = vi.fn(); + + class FakePerformanceObserver { + constructor(cb: typeof callback) { + callback = cb; + } + observe() {} + disconnect = disconnect; + } + + // @ts-expect-error - test double + globalThis.PerformanceObserver = FakePerformanceObserver; + + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startLongTaskObserver(buffer, 5000); + + callback({ getEntries: () => [{ duration: 60 }, { duration: 90 }] }); + vi.advanceTimersByTime(5000); + + const [record] = buffer.snapshot(); + expect(record).toMatchObject({ + category: "longtask", + data: { count: 2, totalDurationMs: 150 }, + }); + + stop(); + expect(disconnect).toHaveBeenCalled(); + }); + + it("does not emit a record for a sample window with no long tasks", () => { + class FakePerformanceObserver { + observe() {} + disconnect() {} + } + + // @ts-expect-error - test double + globalThis.PerformanceObserver = FakePerformanceObserver; + + const buffer = new DiagnosticsRingBuffer(); + buffer.setEnabled(true); + const stop = startLongTaskObserver(buffer, 5000); + + vi.advanceTimersByTime(5000); + + expect(buffer.snapshot()).toHaveLength(0); + stop(); + }); +}); diff --git a/src/diagnostics/samplers.ts b/src/diagnostics/samplers.ts new file mode 100644 index 00000000..fefc0c1b --- /dev/null +++ b/src/diagnostics/samplers.ts @@ -0,0 +1,105 @@ +import { useChannelHistoryStore } from "../stores/channelHistoryStore"; +import { useOutputStore } from "../stores/outputStore"; +import { type DiagnosticsRingBuffer, diagnosticsBuffer } from "./ringBuffer"; + +export const SAMPLE_INTERVAL_MS = 5000; + +interface EntryWithId { + id: number; +} + +function lastEntryId(entries: EntryWithId[]): number { + return entries.length > 0 ? entries[entries.length - 1].id : 0; +} + +/** + * Samples cheap counters every `intervalMs` instead of hooking every + * message/output event: inbound messages/sec (from the channel history + * store's monotonic entry ids) and output lines/sec (from the output + * store's monotonic entry ids). + * + * Returns a function that stops sampling. + */ +export function startCounterSampling( + buffer: DiagnosticsRingBuffer = diagnosticsBuffer, + intervalMs: number = SAMPLE_INTERVAL_MS, +): () => void { + let lastOutputId = lastEntryId(useOutputStore.getState().entries); + let lastChannelId = lastEntryId(useChannelHistoryStore.getState().entries); + let lastSampleTime = Date.now(); + + const timer = window.setInterval(() => { + const now = Date.now(); + const elapsedSec = Math.max((now - lastSampleTime) / 1000, 0.001); + + const outputId = lastEntryId(useOutputStore.getState().entries); + const channelId = lastEntryId(useChannelHistoryStore.getState().entries); + + // Store resets (id counters restarting) can make a delta look negative; + // clamp to zero rather than reporting a bogus negative rate. + const outputDelta = Math.max(0, outputId - lastOutputId); + const channelDelta = Math.max(0, channelId - lastChannelId); + + buffer.record("counters", { + outputLinesPerSec: Number((outputDelta / elapsedSec).toFixed(2)), + inboundMessagesPerSec: Number((channelDelta / elapsedSec).toFixed(2)), + }); + + lastOutputId = outputId; + lastChannelId = channelId; + lastSampleTime = now; + }, intervalMs); + + return () => window.clearInterval(timer); +} + +/** + * Observes long tasks (PerformanceObserver, entryType "longtask") and + * flushes an aggregated count/duration into the diagnostics buffer every + * `intervalMs`, rather than recording one entry per long task. Silently + * does nothing in environments without PerformanceObserver or without + * "longtask" support (e.g. Firefox, jsdom under test). + * + * Returns a function that stops observing/sampling. + */ +export function startLongTaskObserver( + buffer: DiagnosticsRingBuffer = diagnosticsBuffer, + intervalMs: number = SAMPLE_INTERVAL_MS, +): () => void { + if (typeof PerformanceObserver === "undefined") { + return () => {}; + } + + let count = 0; + let totalDurationMs = 0; + let observer: PerformanceObserver; + + try { + observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + count += 1; + totalDurationMs += entry.duration; + } + }); + observer.observe({ type: "longtask", buffered: true }); + } catch { + // "longtask" isn't a supported entry type in this environment. + return () => {}; + } + + const timer = window.setInterval(() => { + if (count > 0) { + buffer.record("longtask", { + count, + totalDurationMs: Math.round(totalDurationMs), + }); + count = 0; + totalDurationMs = 0; + } + }, intervalMs); + + return () => { + window.clearInterval(timer); + observer.disconnect(); + }; +} diff --git a/src/stores/preferencesStore.test.ts b/src/stores/preferencesStore.test.ts index a226b174..041a2324 100644 --- a/src/stores/preferencesStore.test.ts +++ b/src/stores/preferencesStore.test.ts @@ -16,6 +16,10 @@ describe("preferencesStore", () => { enabled: false, maxBytes: 100 * 1024 * 1024, }); + usePreferences.getState().setDiagnostics({ + enabled: false, + redactMessageText: false, + }); localStorage.removeItem("preferences"); }); @@ -31,6 +35,21 @@ describe("preferencesStore", () => { expect(usePreferences.getState().speech.autoreadMode).toBe(AutoreadMode.Off); }); + it("defaults diagnostics capture to off", () => { + expect(usePreferences.getState().diagnostics).toEqual({ + enabled: false, + redactMessageText: false, + }); + }); + + it("setDiagnostics replaces the diagnostics section", () => { + usePreferences.getState().setDiagnostics({ enabled: true, redactMessageText: true }); + expect(usePreferences.getState().diagnostics).toEqual({ + enabled: true, + redactMessageText: true, + }); + }); + it("setSound replaces the sound section", () => { usePreferences.getState().setSound({ muteInBackground: true, volume: 0.5 }); expect(usePreferences.getState().sound).toEqual({ diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index 55f84a1f..dce1def7 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -64,6 +64,11 @@ export type AutologgingPreferences = { maxBytes: number; }; +export type DiagnosticsPreferences = { + enabled: boolean; + redactMessageText: boolean; +}; + export type PrefState = { general: GeneralPreferences; speech: SpeechPreferences; @@ -74,6 +79,7 @@ export type PrefState = { midi: MidiPreferences; haptics: HapticsPreferences; autologging: AutologgingPreferences; + diagnostics: DiagnosticsPreferences; }; type PrefActions = { @@ -87,6 +93,7 @@ type PrefActions = { setMidi: (data: MidiPreferences) => void; setHaptics: (data: HapticsPreferences) => void; setAutologging: (data: AutologgingPreferences) => void; + setDiagnostics: (data: DiagnosticsPreferences) => void; }; const STORAGE_KEY = "preferences"; @@ -116,6 +123,7 @@ function getInitialPreferences(): PrefState { midi: { enabled: false }, haptics: { enabled: false, intensityCap: 1.0, autoStopTimeout: 5 }, autologging: { enabled: false, maxBytes: 100 * 1024 * 1024 }, + diagnostics: { enabled: false, redactMessageText: false }, }; } @@ -137,6 +145,7 @@ function mergePreferences(initial: PrefState, stored: PrefState): PrefState { midi: { ...initial.midi, ...stored.midi }, haptics: { ...initial.haptics, ...cleanHaptics }, autologging: { ...initial.autologging, ...stored.autologging }, + diagnostics: { ...initial.diagnostics, ...stored.diagnostics }, }; } @@ -193,6 +202,7 @@ export const usePreferences = create()( setMidi: (data) => set({ midi: data }), setHaptics: (data) => set({ haptics: data }), setAutologging: (data) => set({ autologging: data }), + setDiagnostics: (data) => set({ diagnostics: data }), })), ); From 88692a5ba4d539c20242d54e552b681b87a9c11e Mon Sep 17 00:00:00 2001 From: David Sexton Date: Tue, 11 Aug 2026 19:56:52 -0700 Subject: [PATCH 2/2] Align openEditorCount with the watchdog branch Both #110 and #111 add this getter at the same spot; identical hunks let git merge whichever lands second without conflict. Also adopts the stricter liveness check (window present and not closed). Co-Authored-By: Claude Fable 5 --- src/EditorManager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/EditorManager.ts b/src/EditorManager.ts index 8b039064..241ad1d1 100644 --- a/src/EditorManager.ts +++ b/src/EditorManager.ts @@ -21,11 +21,14 @@ export class EditorManager { this.setupChannelListeners(); } - /** Number of editor windows not yet closed, for diagnostics/status reporting. */ + /** + * Editor windows that are still open. Read by the diagnostics and + * performance tooling when attributing main-thread work. + */ get openEditorCount(): number { let count = 0; for (const session of this.editors.values()) { - if (session.state !== EditorState.Closed) { + if (session.state !== EditorState.Closed && session.window && !session.window.closed) { count += 1; } }