diff --git a/apps/desktop/src/main/lib/debug-channel.ts b/apps/desktop/src/main/lib/debug-channel.ts new file mode 100644 index 00000000000..4edf178400e --- /dev/null +++ b/apps/desktop/src/main/lib/debug-channel.ts @@ -0,0 +1,76 @@ +import type { + DebugChannelOptions, + DebugChannelTransport, +} from "shared/debug-channel"; +import { createDebugChannel } from "shared/debug-channel"; + +let sentryModulePromise: Promise< + typeof import("@sentry/electron/main") +> | null = null; + +function getSentry() { + if (!sentryModulePromise) { + sentryModulePromise = import("@sentry/electron/main"); + } + return sentryModulePromise; +} + +function createMainTransport(): DebugChannelTransport { + return { + addBreadcrumb(entry) { + void getSentry() + .then((Sentry) => { + Sentry.addBreadcrumb({ + category: entry.namespace, + level: entry.level, + message: entry.message, + data: entry.data, + }); + }) + .catch(() => {}); + }, + captureMessage(entry) { + void getSentry() + .then((Sentry) => { + Sentry.withScope((scope) => { + scope.setLevel(entry.level); + scope.setTag("debug_namespace", entry.namespace); + if (entry.fingerprint) { + scope.setFingerprint(entry.fingerprint); + } + if (entry.data) { + scope.setContext("debug", entry.data); + } + Sentry.captureMessage(`[${entry.namespace}] ${entry.message}`); + }); + }) + .catch(() => {}); + }, + captureException(error, entry) { + void getSentry() + .then((Sentry) => { + Sentry.withScope((scope) => { + scope.setLevel(entry.level); + scope.setTag("debug_namespace", entry.namespace); + if (entry.fingerprint) { + scope.setFingerprint(entry.fingerprint); + } + if (entry.data) { + scope.setContext("debug", entry.data); + } + Sentry.captureException(error); + }); + }) + .catch(() => {}); + }, + }; +} + +export function createMainDebugChannel( + options: Omit, +) { + return createDebugChannel({ + ...options, + transport: createMainTransport(), + }); +} diff --git a/apps/desktop/src/main/terminal-host/debug.ts b/apps/desktop/src/main/terminal-host/debug.ts new file mode 100644 index 00000000000..2cb81484504 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/debug.ts @@ -0,0 +1,14 @@ +import { createMainDebugChannel } from "../lib/debug-channel"; + +const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; + +// terminal host のログは、再起動前提の再現を避けるため +// Sentry には常時送る。 +// これにより renderer 側の停止、hidden terminal の滞留、 +// PTY/emulator の backpressure を事後に追いやすくする。 +// env フラグは同じ内容を console にも出すかだけを制御する。 +export const terminalHostDebug = createMainDebugChannel({ + namespace: "terminal.host", + enabled: true, + mirrorToConsole: DEBUG_TERMINAL, +}); diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index 08419125316..d4ec9092b97 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -36,6 +36,7 @@ import type { TerminalSnapshot, } from "../lib/terminal-host/types"; import { treeKillAsync } from "../lib/tree-kill"; +import { terminalHostDebug } from "./debug"; import { createFrameHeader, PtySubprocessFrameDecoder, @@ -1245,6 +1246,18 @@ export class Session { } this.emulatorWriteBackpressured = true; + terminalHostDebug.warn( + "emulator-backpressure-paused", + { + sessionId: this.sessionId, + queuedBytes: this.emulatorWriteQueuedBytes, + clientCount: this.attachedClients.size, + }, + { + captureMessage: true, + fingerprint: ["terminal.host", "emulator-backpressure-paused"], + }, + ); console.warn( `[Session ${this.sessionId}] Emulator backlog reached ${this.emulatorWriteQueuedBytes} bytes, pausing PTY reads`, ); @@ -1260,6 +1273,18 @@ export class Session { } this.emulatorWriteBackpressured = false; + terminalHostDebug.info( + "emulator-backpressure-resumed", + { + sessionId: this.sessionId, + queuedBytes: this.emulatorWriteQueuedBytes, + clientCount: this.attachedClients.size, + }, + { + captureMessage: true, + fingerprint: ["terminal.host", "emulator-backpressure-resumed"], + }, + ); this.updateSubprocessStdoutFlow(); } diff --git a/apps/desktop/src/renderer/lib/debug-channel.ts b/apps/desktop/src/renderer/lib/debug-channel.ts new file mode 100644 index 00000000000..e564e920d09 --- /dev/null +++ b/apps/desktop/src/renderer/lib/debug-channel.ts @@ -0,0 +1,76 @@ +import type { + DebugChannelOptions, + DebugChannelTransport, +} from "shared/debug-channel"; +import { createDebugChannel } from "shared/debug-channel"; + +let sentryModulePromise: Promise< + typeof import("@sentry/electron/renderer") +> | null = null; + +function getSentry() { + if (!sentryModulePromise) { + sentryModulePromise = import("@sentry/electron/renderer"); + } + return sentryModulePromise; +} + +function createRendererTransport(): DebugChannelTransport { + return { + addBreadcrumb(entry) { + void getSentry() + .then((Sentry) => { + Sentry.addBreadcrumb({ + category: entry.namespace, + level: entry.level, + message: entry.message, + data: entry.data, + }); + }) + .catch(() => {}); + }, + captureMessage(entry) { + void getSentry() + .then((Sentry) => { + Sentry.withScope((scope) => { + scope.setLevel(entry.level); + scope.setTag("debug_namespace", entry.namespace); + if (entry.fingerprint) { + scope.setFingerprint(entry.fingerprint); + } + if (entry.data) { + scope.setContext("debug", entry.data); + } + Sentry.captureMessage(`[${entry.namespace}] ${entry.message}`); + }); + }) + .catch(() => {}); + }, + captureException(error, entry) { + void getSentry() + .then((Sentry) => { + Sentry.withScope((scope) => { + scope.setLevel(entry.level); + scope.setTag("debug_namespace", entry.namespace); + if (entry.fingerprint) { + scope.setFingerprint(entry.fingerprint); + } + if (entry.data) { + scope.setContext("debug", entry.data); + } + Sentry.captureException(error); + }); + }) + .catch(() => {}); + }, + }; +} + +export function createRendererDebugChannel( + options: Omit, +) { + return createDebugChannel({ + ...options, + transport: createRendererTransport(), + }); +} diff --git a/apps/desktop/src/renderer/lib/terminal/debug.ts b/apps/desktop/src/renderer/lib/terminal/debug.ts new file mode 100644 index 00000000000..5f7db22136e --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/debug.ts @@ -0,0 +1,52 @@ +import { createRendererDebugChannel } from "renderer/lib/debug-channel"; +import type { DebugData } from "shared/debug-channel"; + +function isTerminalDebugEnabled(): boolean { + try { + return globalThis.localStorage?.getItem("SUPERSET_TERMINAL_DEBUG") === "1"; + } catch { + return false; + } +} + +// terminal renderer のログは v1/v2 の両経路で再利用する前提で置く。 +// 主調査対象: +// - タブ切り替えや reattach 後に Codex 系 TUI の再描画が崩れる問題 +// - 入力は通っていそうなのに画面へ描画されない問題 +// 副次仮説: +// - hidden terminal が data を受け続けて xterm を回し続ける問題 +// 生の terminal 本文は送らず、状態遷移と byte/count 集計だけを残す。 +// とくに visible terminal 問題では「入力」「受信」「xterm.write 実行」の +// 3 点を突き合わせたいので、下の helper で共通集計する。 +// こうしておくと Sentry 上で検索しやすく、payload も肥大化しにくい。 +export const terminalRendererDebug = createRendererDebugChannel({ + namespace: "terminal.renderer", + enabled: true, + mirrorToConsole: isTerminalDebugEnabled(), +}); + +export function logTerminalWrite( + source: string, + bytes: number, + data?: DebugData, +): void { + terminalRendererDebug.increment("xterm-write-events", 1, { + data: { source, ...(data ?? {}) }, + }); + terminalRendererDebug.observe("xterm-write-bytes", bytes, { + data: { source, ...(data ?? {}) }, + }); +} + +export function logTerminalInput( + source: string, + bytes: number, + data?: DebugData, +): void { + terminalRendererDebug.increment("terminal-input-events", 1, { + data: { source, ...(data ?? {}) }, + }); + terminalRendererDebug.observe("terminal-input-bytes", bytes, { + data: { source, ...(data ?? {}) }, + }); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index df890d5d50f..aef632b2cea 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -1,6 +1,7 @@ import type { ProgressAddon } from "@xterm/addon-progress"; import type { SearchAddon } from "@xterm/addon-search"; import type { TerminalAppearance } from "./appearance"; +import { terminalRendererDebug } from "./debug"; import { type LinkHoverInfo, type TerminalLinkHandlers, @@ -43,7 +44,7 @@ class TerminalRuntimeRegistryImpl { entry = { runtime: null, - transport: createTransport(), + transport: createTransport(terminalId), linkManager: null, pendingLinkHandlers: null, }; @@ -59,6 +60,18 @@ class TerminalRuntimeRegistryImpl { appearance: TerminalAppearance, ) { const entry = this.getOrCreateEntry(terminalId); + terminalRendererDebug.info( + "runtime-attach", + { + terminalId, + hasExistingRuntime: entry.runtime !== null, + connectionState: entry.transport.connectionState, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "runtime-attach"], + }, + ); if (!entry.runtime) { entry.runtime = createRuntime(terminalId, appearance); @@ -104,6 +117,17 @@ class TerminalRuntimeRegistryImpl { detach(terminalId: string) { const entry = this.entries.get(terminalId); if (!entry?.runtime) return; + terminalRendererDebug.info( + "runtime-detach", + { + terminalId, + connectionState: entry.transport.connectionState, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "runtime-detach"], + }, + ); detachFromContainer(entry.runtime); } @@ -126,6 +150,18 @@ class TerminalRuntimeRegistryImpl { dispose(terminalId: string) { const entry = this.entries.get(terminalId); if (!entry) return; + terminalRendererDebug.info( + "runtime-dispose", + { + terminalId, + hasRuntime: entry.runtime !== null, + connectionState: entry.transport.connectionState, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "runtime-dispose"], + }, + ); entry.linkManager?.dispose(); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index a5da70f68e0..f0e9296f151 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -6,6 +6,7 @@ import { Terminal as XTerm } from "@xterm/xterm"; import { resolveHotkeyFromEvent } from "renderer/hotkeys"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; import type { TerminalAppearance } from "./appearance"; +import { logTerminalWrite, terminalRendererDebug } from "./debug"; import { loadAddons } from "./terminal-addons"; const SERIALIZE_SCROLLBACK = 1000; @@ -81,7 +82,10 @@ function persistBuffer(terminalId: string, serializeAddon: SerializeAddon) { function restoreBuffer(terminalId: string, terminal: XTerm) { try { const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${terminalId}`); - if (data) terminal.write(data); + if (data) { + logTerminalWrite("runtime-restore-buffer", data.length, { terminalId }); + terminal.write(data); + } } catch {} } @@ -181,9 +185,32 @@ export function attachToContainer( ) { runtime.container = container; container.appendChild(runtime.wrapper); + terminalRendererDebug.info( + "runtime-attach-to-container", + { + terminalId: runtime.terminalId, + containerWidth: container.clientWidth, + containerHeight: container.clientHeight, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "runtime-attach-to-container"], + }, + ); measureAndResize(runtime); // Renderer may have skipped frames while the wrapper was detached. + terminalRendererDebug.info( + "runtime-refresh", + { + terminalId: runtime.terminalId, + rows: runtime.terminal.rows, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "runtime-refresh"], + }, + ); runtime.terminal.refresh(0, runtime.terminal.rows - 1); runtime.resizeObserver?.disconnect(); @@ -200,6 +227,18 @@ export function attachToContainer( export function detachFromContainer(runtime: TerminalRuntime) { persistBuffer(runtime.terminalId, runtime.serializeAddon); persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); + terminalRendererDebug.info( + "runtime-detach-from-container", + { + terminalId: runtime.terminalId, + lastCols: runtime.lastCols, + lastRows: runtime.lastRows, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "runtime-detach-from-container"], + }, + ); runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index e333cef088f..7797afea67e 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -1,4 +1,9 @@ import type { Terminal as XTerm } from "@xterm/xterm"; +import { + logTerminalInput, + logTerminalWrite, + terminalRendererDebug, +} from "./debug"; export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; @@ -9,6 +14,7 @@ type TerminalServerMessage = | { type: "replay"; data: string }; export interface TerminalTransport { + debugId: string | null; socket: WebSocket | null; connectionState: ConnectionState; /** The URL the socket is currently connected (or connecting) to. */ @@ -39,8 +45,9 @@ const MAX_RECONNECT_DELAY = 10_000; const BASE_RECONNECT_DELAY = 500; const MAX_RECONNECT_ATTEMPTS = 10; -export function createTransport(): TerminalTransport { +export function createTransport(debugId?: string): TerminalTransport { return { + debugId: debugId ?? null, socket: null, connectionState: "disconnected", currentUrl: null, @@ -64,6 +71,18 @@ function scheduleReconnect(transport: TerminalTransport) { MAX_RECONNECT_DELAY, ); transport._reconnectAttempt++; + terminalRendererDebug.info( + "ws-reconnect-scheduled", + { + terminalId: transport.debugId, + delayMs: delay, + reconnectAttempt: transport._reconnectAttempt, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-reconnect-scheduled"], + }, + ); transport._reconnectTimer = setTimeout(() => { transport._reconnectTimer = null; @@ -104,6 +123,18 @@ export function connect( transport.currentUrl = wsUrl; transport._terminal = terminal; transport._exited = false; + terminalRendererDebug.info( + "ws-connect-start", + { + terminalId: transport.debugId, + wsUrl, + reconnectAttempt: transport._reconnectAttempt, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-connect-start"], + }, + ); setConnectionState(transport, "connecting"); const socket = new WebSocket(wsUrl); transport.socket = socket; @@ -111,6 +142,14 @@ export function connect( socket.addEventListener("open", () => { if (transport.socket !== socket) return; transport._reconnectAttempt = 0; + terminalRendererDebug.info( + "ws-open", + { terminalId: transport.debugId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-open"], + }, + ); setConnectionState(transport, "open"); sendResize(transport, terminal.cols, terminal.rows); }); @@ -121,16 +160,45 @@ export function connect( try { message = JSON.parse(String(event.data)) as TerminalServerMessage; } catch { + terminalRendererDebug.error( + "ws-invalid-payload", + { terminalId: transport.debugId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-invalid-payload"], + }, + ); terminal.writeln("\r\n[terminal] invalid server payload"); return; } if (message.type === "data" || message.type === "replay") { + terminalRendererDebug.increment("ws-receive-events", 1, { + data: { terminalId: transport.debugId, type: message.type }, + }); + terminalRendererDebug.observe("ws-receive-bytes", message.data.length, { + data: { terminalId: transport.debugId, type: message.type }, + }); + logTerminalWrite("ws-message", message.data.length, { + terminalId: transport.debugId, + messageType: message.type, + }); terminal.write(message.data); return; } if (message.type === "error") { + terminalRendererDebug.warn( + "ws-server-error", + { + terminalId: transport.debugId, + errorMessage: message.message, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-server-error"], + }, + ); terminal.writeln(`\r\n[terminal] ${message.message}`); return; } @@ -138,6 +206,18 @@ export function connect( if (message.type === "exit") { transport._exited = true; cancelReconnect(transport); + terminalRendererDebug.info( + "ws-exit", + { + terminalId: transport.debugId, + exitCode: message.exitCode, + signal: message.signal, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-exit"], + }, + ); terminal.writeln( `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, ); @@ -146,6 +226,18 @@ export function connect( socket.addEventListener("close", () => { if (transport.socket !== socket) return; + terminalRendererDebug.warn( + "ws-close", + { + terminalId: transport.debugId, + exited: transport._exited, + reconnectAttempt: transport._reconnectAttempt, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-close"], + }, + ); setConnectionState(transport, "closed"); transport.socket = null; // Auto-reconnect on unexpected close (host-service restart, network blip) @@ -154,12 +246,23 @@ export function connect( socket.addEventListener("error", () => { if (transport.socket !== socket) return; + terminalRendererDebug.error( + "ws-error", + { terminalId: transport.debugId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-error"], + }, + ); terminal.writeln("\r\n[terminal] websocket error"); }); transport.onDataDisposable?.dispose(); transport.onDataDisposable = terminal.onData((data) => { if (socket.readyState !== WebSocket.OPEN) return; + logTerminalInput("ws-input", data.length, { + terminalId: transport.debugId, + }); socket.send(JSON.stringify({ type: "input", data })); }); } @@ -179,6 +282,14 @@ export function disconnect(transport: TerminalTransport) { transport.socket.close(); transport.socket = null; } + terminalRendererDebug.info( + "ws-disconnect", + { terminalId: transport.debugId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-disconnect"], + }, + ); transport.currentUrl = null; transport._terminal = null; transport._reconnectAttempt = 0; @@ -215,6 +326,14 @@ export function disposeTransport(transport: TerminalTransport) { transport.socket.close(); transport.socket = null; } + terminalRendererDebug.info( + "ws-transport-dispose", + { terminalId: transport.debugId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "ws-transport-dispose"], + }, + ); transport.currentUrl = null; transport._terminal = null; transport._reconnectAttempt = 0; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/debug.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/debug.ts new file mode 100644 index 00000000000..a70df8e4c67 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/debug.ts @@ -0,0 +1,5 @@ +export { + logTerminalInput, + logTerminalWrite, + terminalRendererDebug, +} from "renderer/lib/terminal/debug"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 3626bef575e..51e7153af80 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -28,6 +28,7 @@ import { shouldSelectAllShortcut, } from "./clipboardShortcuts"; import { TERMINAL_OPTIONS } from "./config"; +import { terminalRendererDebug } from "./debug"; import { suppressQueryResponses } from "./suppressQueryResponses"; /** @@ -145,15 +146,31 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { try { webglAddon = new WebglAddon(); + terminalRendererDebug.info( + "webgl-addon-loaded", + { suggestedRendererType: suggestedRendererType ?? "auto" }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "webgl-loaded"], + }, + ); webglAddon.onContextLoss(() => { webglAddon?.dispose(); webglAddon = null; + terminalRendererDebug.warn("webgl-context-lost", undefined, { + captureMessage: true, + fingerprint: ["terminal.renderer", "webgl-context-lost"], + }); xterm.refresh(0, xterm.rows - 1); }); xterm.loadAddon(webglAddon); } catch { suggestedRendererType = "dom"; webglAddon = null; + terminalRendererDebug.warn("webgl-addon-fallback-dom", undefined, { + captureMessage: true, + fingerprint: ["terminal.renderer", "webgl-fallback-dom"], + }); } }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts index 9fa66362515..4804725fec7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { rejectTerminalSessionReady } from "renderer/lib/terminal/session-readiness"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { isTerminalAttachCanceledMessage } from "../attach-cancel"; +import { logTerminalWrite } from "../debug"; import { coldRestoreState } from "../state"; import type { CreateOrAttachMutate, @@ -251,7 +252,10 @@ export function useTerminalColdRestore({ }); // Add visual separator - xterm.write("\r\n\x1b[90m─── Session Contents Restored ───\x1b[0m\r\n\r\n"); + const restoreBanner = + "\r\n\x1b[90m─── Session Contents Restored ───\x1b[0m\r\n\r\n"; + logTerminalWrite("cold-restore-banner", restoreBanner.length, { paneId }); + xterm.write(restoreBanner); // Reset state for new session isStreamReadyRef.current = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts index 7ab1ffa8b0e..371459a8ced 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { useCreateOrAttachWithTheme } from "renderer/hooks/useCreateOrAttachWithTheme"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { logTerminalInput, terminalRendererDebug } from "../debug"; import type { TerminalCancelCreateOrAttachMutate, TerminalClearScrollbackMutate, @@ -44,12 +45,26 @@ export function useTerminalConnection({ // Use imperative client calls for write/resize/detach/clear to avoid // mutation-observer re-renders on every keystroke. const writeRef = useRef((input, callbacks) => { + logTerminalInput("trpc-write", input.data.length, { paneId: input.paneId }); electronTrpcClient.terminal.write .mutate(input) .then(() => { callbacks?.onSuccess?.(); }) .catch((error) => { + terminalRendererDebug.error( + "terminal-write-mutate-failed", + { + paneId: input.paneId, + bytes: input.data.length, + errorMessage: + error instanceof Error ? error.message : "Write failed", + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "terminal-write-mutate-failed"], + }, + ); callbacks?.onError?.({ message: error instanceof Error ? error.message : "Write failed", }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index 8e35d3b82dc..74adb706a12 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -16,6 +16,7 @@ import { isTerminalAttachCanceledMessage } from "../attach-cancel"; import { scheduleTerminalAttach } from "../attach-scheduler"; import { isCommandEchoed, sanitizeForTitle } from "../commandBuffer"; import { DEBUG_TERMINAL, FIRST_RENDER_RESTORE_FALLBACK_MS } from "../config"; +import { logTerminalWrite, terminalRendererDebug } from "../debug"; import { type ActiveSuggestionHandle, setupClickToMoveCursor, @@ -228,6 +229,11 @@ export function useTerminalLifecycle({ if (DEBUG_TERMINAL) { console.log(`[Terminal] Mount: ${paneId}`); } + terminalRendererDebug.info( + "mount", + { paneId, workspaceId }, + { captureMessage: true, fingerprint: ["terminal.renderer", "mount"] }, + ); // Cancel pending detach from previous unmount const pendingDetach = pendingDetaches.get(paneId); @@ -269,6 +275,20 @@ export function useTerminalLifecycle({ cachedBeforeCreate?.streamReady === true && cachedBeforeCreate.subscription !== null && !hasPendingColdRestore; + terminalRendererDebug.info( + "reattach-evaluated", + { + paneId, + isReattach, + hasPendingColdRestore, + hasSubscription: cachedBeforeCreate?.subscription !== null, + streamReady: cachedBeforeCreate?.streamReady ?? false, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "reattach-evaluated"], + }, + ); if (DEBUG_TERMINAL) { console.log(`[Terminal] isReattach=${isReattach} paneId=${paneId}`); } @@ -604,6 +624,14 @@ export function useTerminalLifecycle({ if (DEBUG_TERMINAL) { console.log(`[Terminal] createOrAttach start: ${paneId}`); } + terminalRendererDebug.info( + "create-or-attach-start", + { paneId, workspaceId, requestId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "create-or-attach-start"], + }, + ); createOrAttachRef.current( { paneId, @@ -624,6 +652,23 @@ export function useTerminalLifecycle({ setConnectionError(null); syncBackendDimensions(); clearPaneInitialDataRef.current(paneId); + terminalRendererDebug.info( + "create-or-attach-success", + { + paneId, + requestId, + isColdRestore: result.isColdRestore ?? false, + isNew: result.isNew, + wasRecovered: result.wasRecovered, + }, + { + captureMessage: true, + fingerprint: [ + "terminal.renderer", + "create-or-attach-success", + ], + }, + ); // FORK NOTE: Do NOT mark the cache as streamReady here // yet. Cold-restore responses come back without a real @@ -638,6 +683,11 @@ export function useTerminalLifecycle({ setIsRestoredMode(true); setRestoredCwd(storedColdRestore.cwd); if (storedColdRestore.scrollback && xterm) { + logTerminalWrite( + "lifecycle-stored-cold-restore-scrollback", + storedColdRestore.scrollback.length, + { paneId }, + ); xterm.write( storedColdRestore.scrollback, scheduleScrollToBottom, @@ -658,6 +708,11 @@ export function useTerminalLifecycle({ setIsRestoredMode(true); setRestoredCwd(result.previousCwd || null); if (scrollback && xterm) { + logTerminalWrite( + "lifecycle-cold-restore-scrollback", + scrollback.length, + { paneId }, + ); xterm.write(scrollback, scheduleScrollToBottom); } didFirstRenderRef.current = true; @@ -717,6 +772,22 @@ export function useTerminalLifecycle({ return; } console.error("[Terminal] Failed to create/attach:", error); + terminalRendererDebug.captureException( + error, + "create-or-attach-failed", + { + paneId, + requestId, + errorMessage: + error instanceof Error ? error.message : String(error), + }, + { + fingerprint: [ + "terminal.renderer", + "create-or-attach-failed", + ], + }, + ); rejectTerminalSessionReady( paneId, new Error(error.message || "Failed to connect to terminal"), @@ -841,6 +912,11 @@ export function useTerminalLifecycle({ `[Terminal] Unmount: ${paneId}, paneDestroyed=${paneDestroyed}`, ); } + terminalRendererDebug.info( + "unmount", + { paneId, workspaceId, paneDestroyed }, + { captureMessage: true, fingerprint: ["terminal.renderer", "unmount"] }, + ); cancelInitialAttach?.(); isUnmounted = true; attachCanceled = true; @@ -878,6 +954,22 @@ export function useTerminalLifecycle({ // xterm AND stream subscription alive in the cache. // No backend detach — the session stays connected so data // continues flowing to xterm while hidden. + // これは主問題ではなく副次仮説の観測点で、 + // hidden 中の keepalive が描画崩れや重さに関係するかを + // Sentry 上で切り分けるために残している。 + terminalRendererDebug.info( + "hidden-detach-keepalive", + { + paneId, + workspaceId, + streamReady: cached.streamReady, + hasSubscription: cached.subscription !== null, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "hidden-detach-keepalive"], + }, + ); v1TerminalCache.detachFromContainer(paneId); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts index d6e04025fe0..c78a12996a8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts @@ -2,6 +2,7 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef } from "react"; import { DEBUG_TERMINAL } from "../config"; +import { logTerminalWrite, terminalRendererDebug } from "../debug"; import type { CreateOrAttachResult, TerminalExitReason, @@ -91,9 +92,31 @@ export function useTerminalRestore({ 0, pendingEventsRef.current.length, ); + terminalRendererDebug.info( + "flush-pending-events", + { + paneId, + eventCount: events.length, + dataEvents: events.filter((event) => event.type === "data").length, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "flush-pending-events"], + }, + ); for (const event of events) { if (event.type === "data") { + terminalRendererDebug.observe( + "restore-pending-data-bytes", + event.data.length, + { + data: { paneId }, + }, + ); updateModesRef.current(event.data); + logTerminalWrite("restore-pending-event", event.data.length, { + paneId, + }); xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { @@ -104,7 +127,7 @@ export function useTerminalRestore({ onDisconnectEventRef.current(event.reason); } } - }, [xtermRef, pendingEventsRef]); + }, [paneId, pendingEventsRef, xtermRef]); const maybeApplyInitialState = useCallback(() => { if (!didFirstRenderRef.current) return; @@ -130,6 +153,22 @@ export function useTerminalRestore({ // Canonical initial content: prefer snapshot (daemon mode) over scrollback const initialAnsi = result.snapshot?.snapshotAnsi ?? result.scrollback; + terminalRendererDebug.info( + "apply-initial-state", + { + paneId, + isNew: result.isNew, + isColdRestore: result.isColdRestore ?? false, + hasSnapshot: result.snapshot !== undefined, + initialAnsiBytes: initialAnsi.length, + rehydrateBytes: result.snapshot?.rehydrateSequences.length ?? 0, + pendingEvents: pendingEventsRef.current.length, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "apply-initial-state"], + }, + ); // Track alternate screen mode from snapshot isAlternateScreenRef.current = !!result.snapshot?.modes.alternateScreen; @@ -163,6 +202,9 @@ export function useTerminalRestore({ // For alt-screen (TUI) sessions, enter alt-screen and trigger SIGWINCH if (isAltScreenReattach) { + logTerminalWrite("restore-enter-alt-screen", "\x1b[?1049h".length, { + paneId, + }); xterm.write("\x1b[?1049h", () => { if (result.snapshot?.rehydrateSequences) { const ESC = "\x1b"; @@ -172,6 +214,13 @@ export function useTerminalRestore({ .split(`${ESC}[?47h`) .join(""); if (filteredRehydrate) { + logTerminalWrite( + "restore-rehydrate-filtered", + filteredRehydrate.length, + { + paneId, + }, + ); xterm.write(filteredRehydrate); } } @@ -213,10 +262,20 @@ export function useTerminalRestore({ finalizeRestore(); return; } + logTerminalWrite("restore-initial-ansi", initialAnsi.length, { + paneId, + }); xterm.write(initialAnsi, finalizeRestore); }; if (rehydrateSequences) { + logTerminalWrite( + "restore-rehydrate-sequences", + rehydrateSequences.length, + { + paneId, + }, + ); xterm.write(rehydrateSequences, writeSnapshot); } else { writeSnapshot(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index 58d5cdb88cd..8a0ea18934e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -4,6 +4,7 @@ import { useCallback, useRef } from "react"; import { useTabsStore } from "renderer/stores/tabs/store"; import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { DEBUG_TERMINAL } from "../config"; +import { logTerminalWrite, terminalRendererDebug } from "../debug"; import type { TerminalExitReason, TerminalStreamEvent } from "../types"; export interface UseTerminalStreamOptions { @@ -158,6 +159,18 @@ export function useTerminalStream({ `[Terminal] Queuing event (not ready): ${paneId}, type=${event.type}, bytes=${event.data.length}`, ); } + if (event.type === "data") { + terminalRendererDebug.increment("pending-data-events", 1, { + data: { paneId, bytes: event.data.length }, + }); + terminalRendererDebug.observe( + "pending-data-bytes", + event.data.length, + { + data: { paneId }, + }, + ); + } pendingEventsRef.current.push(event); return; } @@ -170,8 +183,15 @@ export function useTerminalStream({ `[Terminal] First stream data received: ${paneId}, ${event.data.length} bytes`, ); } + terminalRendererDebug.increment("stream-data-events", 1, { + data: { paneId }, + }); + terminalRendererDebug.observe("stream-data-bytes", event.data.length, { + data: { paneId }, + }); updateModesRef.current(event.data); + logTerminalWrite("stream-data", event.data.length, { paneId }); xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index 58881426eeb..e4156e47281 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -5,6 +5,7 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import { markTerminalSessionReady } from "renderer/lib/terminal/session-readiness"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { DEBUG_TERMINAL } from "./config"; +import { logTerminalWrite, terminalRendererDebug } from "./debug"; import { type CreateTerminalOptions, createTerminalInWrapper } from "./helpers"; import type { TerminalStreamEvent } from "./types"; @@ -111,6 +112,18 @@ export function attachToContainer( if (!entry) return; container.appendChild(entry.wrapper); + terminalRendererDebug.info( + "cache-attach-to-container", + { + paneId, + hasSubscription: entry.subscription !== null, + streamReady: entry.streamReady, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "cache-attach-to-container"], + }, + ); if (container.clientWidth > 0 && container.clientHeight > 0) { entry.fitAddon.fit(); @@ -145,6 +158,18 @@ export function detachFromContainer(paneId: string): void { if (DEBUG_TERMINAL) { console.log(`[v1-terminal-cache] detachFromContainer: ${paneId}`); } + terminalRendererDebug.info( + "cache-detach-from-container", + { + paneId, + hasSubscription: entry.subscription !== null, + streamReady: entry.streamReady, + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "cache-detach-from-container"], + }, + ); entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.wrapper.remove(); @@ -189,7 +214,11 @@ export function updateAppearance( // --- Stream subscription --- -function routeEvent(entry: CachedTerminal, event: TerminalStreamEvent): void { +function routeEvent( + paneId: string, + entry: CachedTerminal, + event: TerminalStreamEvent, +): void { // Before stream is ready: queue everything (first-mount gating). if (!entry.streamReady) { entry.pendingStreamEvents.push(event); @@ -203,7 +232,17 @@ function routeEvent(entry: CachedTerminal, event: TerminalStreamEvent): void { } // Component unmounted — write data directly to xterm, queue the rest. + // ここは hidden terminal 継続処理の観測点で、主問題ではなく副次仮説。 + // 「表示中なのに描画されない」問題とは別軸で、 + // hidden 中も xterm.write が走り続けていないかを見る。 if (event.type === "data") { + terminalRendererDebug.increment("hidden-data-events", 1, { + data: { paneId, bytes: event.data.length }, + }); + terminalRendererDebug.observe("hidden-data-bytes", event.data.length, { + data: { paneId }, + }); + logTerminalWrite("hidden-stream-data", event.data.length, { paneId }); entry.xterm.write(event.data); } else { entry.pendingLifecycleEvents.push(event); @@ -223,16 +262,35 @@ export function startStream(paneId: string): void { if (DEBUG_TERMINAL) { console.log(`[v1-terminal-cache] Starting stream: ${paneId}`); } + terminalRendererDebug.info( + "cache-start-stream", + { paneId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "cache-start-stream"], + }, + ); entry.subscription = electronTrpcClient.terminal.stream.subscribe(paneId, { onData: (event: TerminalStreamEvent) => { - routeEvent(entry, event); + routeEvent(paneId, entry, event); }, onError: (error: unknown) => { // Subscription is dead after onError — null it and reset streamReady // so the next remount goes through the full create/attach path. entry.subscription = null; entry.streamReady = false; + terminalRendererDebug.error( + "cache-stream-error", + { + paneId, + errorMessage: error instanceof Error ? error.message : String(error), + }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "cache-stream-error"], + }, + ); if (entry.subscriptionErrorHandler) { entry.subscriptionErrorHandler(error); @@ -259,11 +317,19 @@ export function setStreamReady(paneId: string): void { `[v1-terminal-cache] Stream ready: ${paneId}, flushing ${entry.pendingStreamEvents.length} queued events`, ); } + terminalRendererDebug.info( + "cache-stream-ready", + { paneId, pendingStreamEvents: entry.pendingStreamEvents.length }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "cache-stream-ready"], + }, + ); entry.streamReady = true; const pending = entry.pendingStreamEvents.splice(0); for (const event of pending) { - routeEvent(entry, event); + routeEvent(paneId, entry, event); } } @@ -325,6 +391,14 @@ export function dispose(paneId: string): void { if (DEBUG_TERMINAL) { console.log(`[v1-terminal-cache] Disposing: ${paneId}`); } + terminalRendererDebug.info( + "cache-dispose", + { paneId }, + { + captureMessage: true, + fingerprint: ["terminal.renderer", "cache-dispose"], + }, + ); entry.resizeObserver?.disconnect(); entry.subscription?.unsubscribe(); diff --git a/apps/desktop/src/shared/debug-channel.ts b/apps/desktop/src/shared/debug-channel.ts new file mode 100644 index 00000000000..430261928e4 --- /dev/null +++ b/apps/desktop/src/shared/debug-channel.ts @@ -0,0 +1,302 @@ +export type DebugLevel = "debug" | "info" | "warning" | "error"; + +type DebugPrimitive = string | number | boolean | null | undefined; + +export type DebugData = Record; + +interface DebugBreadcrumb { + namespace: string; + level: DebugLevel; + message: string; + data?: DebugData; +} + +interface DebugMessage extends DebugBreadcrumb { + fingerprint?: string[]; +} + +export interface DebugChannelTransport { + addBreadcrumb(entry: DebugBreadcrumb): void; + captureMessage(entry: DebugMessage): void; + captureException(error: unknown, entry: DebugMessage): void; +} + +export interface DebugChannelOptions { + // `enabled` を false にすると Sentry transport を含めて + // チャンネル全体が止まる。 + // 調査ログを常時 Sentry に送りたい用途では true のままにして、 + // ローカル console の騒がしさだけ `mirrorToConsole` で制御する。 + namespace: string; + enabled: boolean; + transport?: DebugChannelTransport; + mirrorToConsole?: boolean; + maxStringLength?: number; +} + +export interface DebugLogOptions { + captureMessage?: boolean; + fingerprint?: string[]; +} + +export interface DebugAggregateOptions { + intervalMs?: number; + level?: DebugLevel; + captureMessage?: boolean; + data?: DebugData; +} + +interface AggregateState { + count: number; + sum: number; + min: number; + max: number; + last: number; + data?: DebugData; + timer: ReturnType | null; +} + +const DEFAULT_AGGREGATE_INTERVAL_MS = 1_000; +const DEFAULT_MAX_STRING_LENGTH = 500; + +function truncateString(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength)}... (${value.length - maxLength} chars truncated)`; +} + +function normalizeValue( + value: unknown, + maxStringLength: number, +): DebugPrimitive { + if ( + value === null || + value === undefined || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (typeof value === "string") { + return truncateString(value, maxStringLength); + } + + if (value instanceof Error) { + return truncateString(`${value.name}: ${value.message}`, maxStringLength); + } + + try { + return truncateString(JSON.stringify(value), maxStringLength); + } catch { + return truncateString(String(value), maxStringLength); + } +} + +function normalizeData( + data: DebugData | undefined, + maxStringLength: number, +): DebugData | undefined { + if (!data) return undefined; + + const normalized: DebugData = {}; + for (const [key, value] of Object.entries(data)) { + normalized[key] = normalizeValue(value, maxStringLength); + } + return normalized; +} + +function consoleMethod(level: DebugLevel): (...args: unknown[]) => void { + switch (level) { + case "debug": + return console.debug.bind(console); + case "warning": + return console.warn.bind(console); + case "error": + return console.error.bind(console); + default: + return console.log.bind(console); + } +} + +export class DebugChannel { + private readonly namespace: string; + private readonly enabled: boolean; + private readonly transport?: DebugChannelTransport; + private readonly mirrorToConsole: boolean; + private readonly maxStringLength: number; + private readonly aggregates = new Map(); + + constructor(options: DebugChannelOptions) { + this.namespace = options.namespace; + this.enabled = options.enabled; + this.transport = options.transport; + this.mirrorToConsole = options.mirrorToConsole ?? true; + this.maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH; + } + + log( + level: DebugLevel, + message: string, + data?: DebugData, + options?: DebugLogOptions, + ): void { + if (!this.enabled) return; + + const normalizedData = normalizeData(data, this.maxStringLength); + const entry: DebugMessage = { + namespace: this.namespace, + level, + message, + data: normalizedData, + fingerprint: options?.fingerprint, + }; + + if (this.mirrorToConsole) { + const method = consoleMethod(level); + method(`[debug:${this.namespace}] ${message}`, normalizedData ?? {}); + } + + this.transport?.addBreadcrumb(entry); + if (options?.captureMessage) { + this.transport?.captureMessage(entry); + } + } + + debug(message: string, data?: DebugData, options?: DebugLogOptions): void { + this.log("debug", message, data, options); + } + + info(message: string, data?: DebugData, options?: DebugLogOptions): void { + this.log("info", message, data, options); + } + + warn(message: string, data?: DebugData, options?: DebugLogOptions): void { + this.log("warning", message, data, options); + } + + error(message: string, data?: DebugData, options?: DebugLogOptions): void { + this.log("error", message, data, options); + } + + captureException( + error: unknown, + message: string, + data?: DebugData, + options?: DebugLogOptions, + ): void { + if (!this.enabled) return; + + const normalizedData = normalizeData(data, this.maxStringLength); + const entry: DebugMessage = { + namespace: this.namespace, + level: "error", + message, + data: normalizedData, + fingerprint: options?.fingerprint, + }; + + if (this.mirrorToConsole) { + console.error( + `[debug:${this.namespace}] ${message}`, + normalizedData ?? {}, + error, + ); + } + + this.transport?.addBreadcrumb(entry); + this.transport?.captureException(error, entry); + } + + increment(metric: string, value = 1, options?: DebugAggregateOptions): void { + this.observe(metric, value, options); + } + + observe( + metric: string, + value: number, + options?: DebugAggregateOptions, + ): void { + if (!this.enabled) return; + + const key = `${metric}:${options?.intervalMs ?? DEFAULT_AGGREGATE_INTERVAL_MS}`; + const existing = this.aggregates.get(key); + const data = normalizeData(options?.data, this.maxStringLength); + const state = + existing ?? + ({ + count: 0, + sum: 0, + min: value, + max: value, + last: value, + data, + timer: null, + } satisfies AggregateState); + + state.count += 1; + state.sum += value; + state.min = Math.min(state.min, value); + state.max = Math.max(state.max, value); + state.last = value; + state.data = { + ...(state.data ?? {}), + ...(data ?? {}), + }; + + if (!existing) { + this.aggregates.set(key, state); + const intervalMs = options?.intervalMs ?? DEFAULT_AGGREGATE_INTERVAL_MS; + state.timer = setTimeout(() => { + this.flushAggregate( + key, + metric, + options?.level ?? "info", + options?.captureMessage ?? true, + ); + }, intervalMs); + } + } + + flushAll(): void { + for (const key of this.aggregates.keys()) { + const [metric] = key.split(":"); + this.flushAggregate(key, metric, "info", true); + } + } + + private flushAggregate( + key: string, + metric: string, + level: DebugLevel, + captureMessage: boolean, + ): void { + const state = this.aggregates.get(key); + if (!state) return; + + if (state.timer) { + clearTimeout(state.timer); + } + this.aggregates.delete(key); + + this.log( + level, + `aggregate:${metric}`, + { + count: state.count, + sum: Number(state.sum.toFixed(2)), + min: Number(state.min.toFixed(2)), + max: Number(state.max.toFixed(2)), + avg: Number((state.sum / state.count).toFixed(2)), + last: Number(state.last.toFixed(2)), + ...(state.data ?? {}), + }, + { + captureMessage, + fingerprint: [this.namespace, "aggregate", metric], + }, + ); + } +} + +export function createDebugChannel(options: DebugChannelOptions): DebugChannel { + return new DebugChannel(options); +}