export type FrontendLogLevel = "debug" | "info" | "warn" | "error"; export interface WebtermScopedLogger { debug: (text: string, extra?: Record) => void; info: (text: string, extra?: Record) => void; warn: (text: string, extra?: Record) => void; error: (text: string, extra?: Record) => void; } declare global { interface Window { webtermClientLog?: (text: string, extra?: Record) => Promise; webtermClientLogSessionID?: string; webtermClientBuildVersion?: string; webtermLogs?: { bug: WebtermScopedLogger; ui: WebtermScopedLogger; voice: WebtermScopedLogger; ws: WebtermScopedLogger; }; } } export const FRONTEND_BUILD_VERSION = "clawtap"; function createFrontendLogSessionID(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let out = ""; for (let i = 0; i < 24; i += 1) { out += chars[Math.floor(Math.random() * chars.length)]; } return out; } export const frontendLogSessionID = createFrontendLogSessionID(); function normalizeFrontendLogValue(value: unknown): unknown { if (value instanceof Error) { return { name: value.name, message: value.message, stack: value.stack, }; } if (Array.isArray(value)) { return value.map((item) => normalizeFrontendLogValue(item)); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record).map(([key, item]) => [key, normalizeFrontendLogValue(item)]) ); } return value; } function serializeFrontendLogContext(extra: Record = {}): string { const entries = Object.entries(extra); if (entries.length === 0) { return ""; } try { return JSON.stringify(Object.fromEntries(entries.map(([key, value]) => [key, normalizeFrontendLogValue(value)]))); } catch { return String(extra); } } export async function postFrontendLog( level: FrontendLogLevel, text: string, extra: Record = {} ): Promise { const message = typeof text === "string" ? text.trim() : ""; if (!message) return; try { await fetch("/api/client-log", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ session_id: frontendLogSessionID, text: message, frontend_version: FRONTEND_BUILD_VERSION, level, context: serializeFrontendLogContext(extra), }), keepalive: true, }); } catch (error) { console.warn("[webterm] Failed to post client log:", error); } } function logFrontend(level: FrontendLogLevel, text: string, extra: Record = {}): void { void postFrontendLog(level, text, extra); } function logFrontendDebug(text: string, extra: Record = {}): void { logFrontend("debug", text, extra); } function logFrontendInfo(text: string, extra: Record = {}): void { logFrontend("info", text, extra); } function logFrontendWarn(text: string, extra: Record = {}): void { logFrontend("warn", text, extra); } function logFrontendError(text: string, extra: Record = {}): void { logFrontend("error", text, extra); } export function createScopedFrontendLogger(scope: string): WebtermScopedLogger { const withScope = (extra: Record = {}): Record => ({ scope, ...extra, }); return { debug: (text, extra = {}) => logFrontendDebug(text, withScope(extra)), info: (text, extra = {}) => logFrontendInfo(text, withScope(extra)), warn: (text, extra = {}) => logFrontendWarn(text, withScope(extra)), error: (text, extra = {}) => logFrontendError(text, withScope(extra)), }; } export const bugLog = createScopedFrontendLogger("bug"); export const uiLog = createScopedFrontendLogger("ui"); export const voiceLog = createScopedFrontendLogger("voice"); export const wsLog = createScopedFrontendLogger("ws"); window.webtermClientLog = (text: string, extra: Record = {}) => postFrontendLog("info", text, extra); window.webtermClientLogSessionID = frontendLogSessionID; window.webtermClientBuildVersion = FRONTEND_BUILD_VERSION; window.webtermLogs = { bug: bugLog, ui: uiLog, voice: voiceLog, ws: wsLog, };