// Browser-side logger with levels, timestamps and a ring buffer. // // Level: NEXT_PUBLIC_LOG_LEVEL at build time, overridable per browser with // localStorage.setItem("iotp_log_level", "debug"). The last 300 entries are // kept in window.__iotpLogs and can be copied with window.__iotpDumpLogs(). export type LogLevel = "debug" | "info" | "warn" | "error" const ORDER: Record = { debug: 10, info: 20, warn: 30, error: 40 } const BUFFER_SIZE = 300 interface LogEntry { ts: string level: LogLevel scope: string message: string data?: unknown } declare global { interface Window { __iotpLogs?: LogEntry[] __iotpDumpLogs?: () => string } } function configuredLevel(): LogLevel { let level = (process.env.NEXT_PUBLIC_LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug")) as LogLevel if (typeof window !== "undefined") { try { const override = window.localStorage.getItem("iotp_log_level") as LogLevel | null if (override && override in ORDER) level = override } catch { /* storage may be unavailable */ } } return level in ORDER ? level : "info" } function buffer(): LogEntry[] { if (typeof window === "undefined") return [] if (!window.__iotpLogs) { window.__iotpLogs = [] window.__iotpDumpLogs = () => (window.__iotpLogs || []) .map((e) => `${e.ts} ${e.level.toUpperCase().padEnd(5)} [${e.scope}] ${e.message}${e.data !== undefined ? " " + safeJson(e.data) : ""}`) .join("\n") } return window.__iotpLogs } function safeJson(v: unknown): string { try { return JSON.stringify(v) } catch { return String(v) } } function emit(level: LogLevel, scope: string, message: string, data?: unknown) { if (ORDER[level] < ORDER[configuredLevel()]) return const ts = new Date().toISOString() const buf = buffer() buf.push({ ts, level, scope, message, data }) if (buf.length > BUFFER_SIZE) buf.splice(0, buf.length - BUFFER_SIZE) const line = `[iotp ${ts.slice(11, 23)}] [${scope}] ${message}` const fn = level === "error" ? console.error : level === "warn" ? console.warn : level === "info" ? console.info : console.debug if (data !== undefined) fn(line, data) else fn(line) } export function getLogger(scope: string) { return { debug: (message: string, data?: unknown) => emit("debug", scope, message, data), info: (message: string, data?: unknown) => emit("info", scope, message, data), warn: (message: string, data?: unknown) => emit("warn", scope, message, data), error: (message: string, data?: unknown) => emit("error", scope, message, data), } } // Describe any thrown value for logs and user messages. export function describeError(err: unknown): string { if (err instanceof Error) return `${err.name}: ${err.message}` if (typeof err === "string") return err return safeJson(err) } // Global handlers so uncaught browser errors land in the buffer too. export function installGlobalErrorLogging() { if (typeof window === "undefined") return const w = window as Window & { __iotpGlobalInstalled?: boolean } if (w.__iotpGlobalInstalled) return w.__iotpGlobalInstalled = true const log = getLogger("window") window.addEventListener("error", (ev) => log.error(`Uncaught: ${ev.message}`, { file: ev.filename, line: ev.lineno })) window.addEventListener("unhandledrejection", (ev) => log.error(`Unhandled rejection: ${describeError(ev.reason)}`)) log.debug(`Logging active at level "${configuredLevel()}"; call window.__iotpDumpLogs() to copy the buffer`) }