From a166f40ec74b9fa266c20945d60f63dfd5c30fa6 Mon Sep 17 00:00:00 2001 From: Lukas Kosina Date: Thu, 5 Mar 2026 10:39:30 +0100 Subject: [PATCH] Add logging system with dashboard toggle (#43) - Add logging module that writes JSON log entries to ~/.claude-code-dashboard/logs/ - Log all hook events with timestamp, action type (status), session ID, and full payload - Add GET/POST /api/logging endpoints to query and toggle logging state - Add logging toggle switch to dashboard footer (default off) - Add Notification and SubagentStop hook events for complete coverage - Add comprehensive tests for logging module --- src/bin.ts | 3 + src/dashboard.ts | 36 +++++++++++ src/hooks.test.ts | 4 +- src/hooks.ts | 2 + src/logging.test.ts | 146 ++++++++++++++++++++++++++++++++++++++++++++ src/logging.ts | 61 ++++++++++++++++++ src/server.test.ts | 3 + src/server.ts | 37 ++++++++++- 8 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 src/logging.test.ts create mode 100644 src/logging.ts diff --git a/src/bin.ts b/src/bin.ts index 26dad55..354cf2d 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,6 +3,7 @@ import * as http from "node:http"; import * as net from "node:net"; import { installHooks, removeHooks } from "./hooks.ts"; import { install, readLockFile, removeLockFile, uninstall, writeLockFile } from "./installer.ts"; +import { createLogger } from "./logging.ts"; import { createServer } from "./server.ts"; import { createStore } from "./state.ts"; @@ -257,6 +258,7 @@ function main(): void { function startDashboard(port: number, noHooks: boolean, noOpen: boolean): void { const store = createStore(); + const logger = createLogger(); let cleanedUp = false; function cleanup() { @@ -276,6 +278,7 @@ function startDashboard(port: number, noHooks: boolean, noOpen: boolean): void { const dashboard = createServer({ store, + logger, onShutdown() { cleanup(); process.exit(0); diff --git a/src/dashboard.ts b/src/dashboard.ts index 71aa85c..a66fe1f 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -442,6 +442,13 @@ export function getDashboardHtml(): string { @@ -455,6 +462,7 @@ export function getDashboardHtml(): string { var btnRestart = document.getElementById('btnRestart'); var notifToggle = document.getElementById('notifToggle'); var notifBanner = document.getElementById('notifBanner'); + var logToggle = document.getElementById('logToggle'); var notificationsEnabled = localStorage.getItem('notificationsEnabled') !== 'false'; var sessions = []; var previousStatuses = {}; @@ -720,6 +728,34 @@ export function getDashboardHtml(): string { }; } + // Logging toggle + function fetchLoggingStatus() { + var req = new XMLHttpRequest(); + req.open('GET', '/api/logging', true); + req.onload = function() { + if (req.status === 200) { + var data = JSON.parse(req.responseText); + logToggle.checked = data.enabled; + } + }; + req.send(); + } + + logToggle.addEventListener('change', function() { + var req = new XMLHttpRequest(); + req.open('POST', '/api/logging', true); + req.setRequestHeader('Content-Type', 'application/json'); + req.onload = function() { + if (req.status === 200) { + var data = JSON.parse(req.responseText); + logToggle.checked = data.enabled; + } + }; + req.send(JSON.stringify({ enabled: logToggle.checked })); + }); + + fetchLoggingStatus(); + // Update time-ago values every 10 seconds setInterval(render, 10000); diff --git a/src/hooks.test.ts b/src/hooks.test.ts index f7e5314..6e3a2a2 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -37,7 +37,7 @@ describe("installHooks", () => { assert.ok(settings.hooks); }); - it("creates all 7 hook events including PreToolUse, PermissionRequest, and PostToolUse", () => { + it("creates all 9 hook events including PreToolUse, PermissionRequest, PostToolUse, Notification, and SubagentStop", () => { installHooks(8377, tmpDir); const settings = readSettings() as { hooks: Record }; assert.ok(settings.hooks.SessionStart); @@ -47,6 +47,8 @@ describe("installHooks", () => { assert.ok(settings.hooks.PreToolUse); assert.ok(settings.hooks.PermissionRequest); assert.ok(settings.hooks.PostToolUse); + assert.ok(settings.hooks.Notification); + assert.ok(settings.hooks.SubagentStop); }); it("PreToolUse hook captures all tools without matcher", () => { diff --git a/src/hooks.ts b/src/hooks.ts index 1fc9855..806b6f9 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -13,6 +13,8 @@ const HOOK_EVENTS = [ "SessionEnd", "PermissionRequest", "PostToolUse", + "Notification", + "SubagentStop", ] as const; interface HookEntry { diff --git a/src/logging.test.ts b/src/logging.test.ts new file mode 100644 index 0000000..a989b63 --- /dev/null +++ b/src/logging.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { createLogger } from "./logging.ts"; + +let tmpDir: string; +let logDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccd-log-test-")); + logDir = path.join(tmpDir, "logs"); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("createLogger", () => { + it("starts disabled by default", () => { + const logger = createLogger(logDir); + assert.equal(logger.isEnabled(), false); + }); + + it("can be enabled and disabled", () => { + const logger = createLogger(logDir); + logger.setEnabled(true); + assert.equal(logger.isEnabled(), true); + logger.setEnabled(false); + assert.equal(logger.isEnabled(), false); + }); + + it("does not write logs when disabled", () => { + const logger = createLogger(logDir); + logger.logEvent( + { session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" }, + { + sessionId: "s1", + status: "done", + cwd: "/test", + lastEvent: "SessionStart", + updatedAt: Date.now(), + startedAt: Date.now(), + }, + ); + assert.equal(fs.existsSync(logDir), false); + }); + + it("writes log file when enabled", () => { + const logger = createLogger(logDir); + logger.setEnabled(true); + logger.logEvent( + { session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" }, + { + sessionId: "s1", + status: "done", + cwd: "/test", + lastEvent: "SessionStart", + updatedAt: Date.now(), + startedAt: Date.now(), + }, + ); + + assert.ok(fs.existsSync(logDir)); + + const files = fs.readdirSync(logDir); + assert.equal(files.length, 1); + assert.ok(files[0].endsWith(".log")); + + const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8"); + const entry = JSON.parse(content.trim()); + assert.equal(entry.hook_event_name, "SessionStart"); + assert.equal(entry.session_id, "s1"); + assert.equal(entry.status, "done"); + assert.ok(entry.timestamp); + assert.ok(entry.payload); + }); + + it("logs null session for SessionEnd events", () => { + const logger = createLogger(logDir); + logger.setEnabled(true); + logger.logEvent({ session_id: "s1", hook_event_name: "SessionEnd" }, null); + + const files = fs.readdirSync(logDir); + const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8"); + const entry = JSON.parse(content.trim()); + assert.equal(entry.status, "n/a"); + assert.equal(entry.hook_event_name, "SessionEnd"); + }); + + it("appends multiple entries to same log file", () => { + const logger = createLogger(logDir); + logger.setEnabled(true); + logger.logEvent( + { session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" }, + { + sessionId: "s1", + status: "done", + cwd: "/test", + lastEvent: "SessionStart", + updatedAt: Date.now(), + startedAt: Date.now(), + }, + ); + logger.logEvent( + { session_id: "s1", hook_event_name: "UserPromptSubmit", cwd: "/test" }, + { + sessionId: "s1", + status: "running", + cwd: "/test", + lastEvent: "UserPromptSubmit", + updatedAt: Date.now(), + startedAt: Date.now(), + }, + ); + + const files = fs.readdirSync(logDir); + assert.equal(files.length, 1); + + const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8"); + const lines = content.trim().split("\n"); + assert.equal(lines.length, 2); + }); + + it("includes full payload with extra fields like tool_name", () => { + const logger = createLogger(logDir); + logger.setEnabled(true); + logger.logEvent( + { session_id: "s1", hook_event_name: "PreToolUse", tool_name: "Bash", cwd: "/test" }, + { + sessionId: "s1", + status: "running", + cwd: "/test", + lastEvent: "Bash", + updatedAt: Date.now(), + startedAt: Date.now(), + }, + ); + + const files = fs.readdirSync(logDir); + const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8"); + const entry = JSON.parse(content.trim()); + assert.equal(entry.payload.tool_name, "Bash"); + }); +}); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..e5d7d71 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { HookPayload, Session } from "./state.ts"; + +export interface Logger { + isEnabled(): boolean; + setEnabled(enabled: boolean): void; + logEvent(payload: HookPayload, session: Session | null): void; +} + +function getLogDir(logDir?: string): string { + return logDir ?? path.join(os.homedir(), ".claude-code-dashboard", "logs"); +} + +function getLogFilePath(logDir?: string): string { + const date = new Date().toISOString().slice(0, 10); + return path.join(getLogDir(logDir), `${date}.log`); +} + +function formatLogEntry(payload: HookPayload, session: Session | null): string { + const timestamp = new Date().toISOString(); + const status = session ? session.status : "n/a"; + const entry = { + timestamp, + status, + hook_event_name: payload.hook_event_name, + session_id: payload.session_id, + payload, + }; + return JSON.stringify(entry); +} + +export function createLogger(logDir?: string): Logger { + let enabled = false; + + return { + isEnabled() { + return enabled; + }, + + setEnabled(value: boolean) { + enabled = value; + }, + + logEvent(payload: HookPayload, session: Session | null) { + if (!enabled) return; + + try { + const dir = getLogDir(logDir); + fs.mkdirSync(dir, { recursive: true }); + + const logFile = getLogFilePath(logDir); + const line = `${formatLogEntry(payload, session)}\n`; + fs.appendFileSync(logFile, line, "utf-8"); + } catch { + // Silently ignore logging errors to avoid disrupting the dashboard + } + }, + }; +} diff --git a/src/server.test.ts b/src/server.test.ts index ab7da86..fc76169 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import * as http from "node:http"; import { afterEach, describe, it } from "node:test"; +import { createLogger } from "./logging.ts"; import { createServer, type DashboardServer, type ServerOptions } from "./server.ts"; import { createStore } from "./state.ts"; @@ -11,8 +12,10 @@ function startServer( ): Promise<{ port: number; dashboard: DashboardServer }> { return new Promise((resolve) => { const store = createStore(); + const logger = createLogger(); const d = createServer({ store, + logger, onShutdown: opts?.onShutdown, onRestart: opts?.onRestart, idleTimeoutMs: opts?.idleTimeoutMs, diff --git a/src/server.ts b/src/server.ts index 785f400..586ed50 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import * as http from "node:http"; import { getDashboardHtml } from "./dashboard.ts"; +import type { Logger } from "./logging.ts"; import type { HookPayload, Store } from "./state.ts"; export interface DashboardServer { @@ -10,6 +11,7 @@ export interface DashboardServer { export interface ServerOptions { store: Store; + logger: Logger; idleTimeoutMs?: number; cleanupIntervalMs?: number; onShutdown?: () => void; @@ -17,7 +19,7 @@ export interface ServerOptions { } export function createServer(options: ServerOptions): DashboardServer { - const { store, onShutdown, onRestart } = options; + const { store, logger, onShutdown, onRestart } = options; const idleTimeoutMs = options.idleTimeoutMs ?? 5 * 60 * 1000; const cleanupIntervalMs = options.cleanupIntervalMs ?? 60_000; const sseClients = new Set(); @@ -55,7 +57,8 @@ export function createServer(options: ServerOptions): DashboardServer { res.end(JSON.stringify({ error: "Missing session_id or hook_event_name" })); return; } - store.handleEvent(payload); + const session = store.handleEvent(payload); + logger.logEvent(payload, session); if (payload.hook_event_name !== "Ping") { broadcast(); } @@ -92,6 +95,36 @@ export function createServer(options: ServerOptions): DashboardServer { return; } + if (req.method === "GET" && pathname === "/api/logging") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ enabled: logger.isEnabled() })); + return; + } + + if (req.method === "POST" && pathname === "/api/logging") { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + try { + const { enabled } = JSON.parse(body); + if (typeof enabled !== "boolean") { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing boolean 'enabled'" })); + return; + } + logger.setEnabled(enabled); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ enabled: logger.isEnabled() })); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + return; + } + if (req.method === "POST" && pathname === "/api/shutdown") { broadcastEvent("shutdown", JSON.stringify({ ok: true })); res.writeHead(200, { "Content-Type": "application/json" });