type LogLevel = "error" | "warn" | "info" | "http" | "debug"; export interface LogEntry { level: LogLevel; timestamp: string; message: any; metadata?: any; } interface Error { code: string; message: string; description?: string; detail?: string; error?: any; actionable?: boolean; } class BrowserLogger { private queue: LogEntry[] = []; private timer: ReturnType | null = null; private readonly BATCH_INTERVAL = 5000; // 5 seconds private readonly BATCH_SIZE_LIMIT = 50; private readonly isDev: boolean; constructor(isDev: boolean) { this.isDev = isDev; if (!this.isDev) { this.startBatchTimer(); this.setupBeforeUnloadHandler(); } } private startBatchTimer() { this.timer = setInterval(() => this.flush(), this.BATCH_INTERVAL); } private setupBeforeUnloadHandler() { // Flush logs before page unload to avoid losing them if (typeof window !== "undefined") { window.addEventListener("beforeunload", () => { this.flushSync(); }); } } private async flush() { if (this.queue.length === 0) return; const batch = [...this.queue]; this.queue = []; try { // Forward batch to Hono route await fetch("/api/logs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ logs: batch }), }); } catch (err) { console.error("Axiom batch upload failed", err); // Re-add failed logs back to queue (up to a limit to avoid memory issues) if (this.queue.length < this.BATCH_SIZE_LIMIT * 2) { this.queue.push(...batch); } } } private flushSync() { // Synchronous flush for beforeunload using sendBeacon if (this.queue.length === 0) return; const batch = [...this.queue]; this.queue = []; try { const blob = new Blob([JSON.stringify({ logs: batch })], { type: "application/json", }); navigator.sendBeacon("/api/logs", blob); } catch (err) { console.error("Failed to send logs via sendBeacon", err); } } private serializeError(error: unknown): any { if (error instanceof Error) { return { name: error.name, message: error.message, stack: error.stack, ...(error.cause && { cause: this.serializeError(error.cause) }), }; } return error; } private createLogEntry( level: LogLevel, message: any, metadata?: any, ): LogEntry { // Handle Error serialization for message const cleanMessage = message instanceof Error ? this.serializeError(message) : message; // Handle Error serialization for metadata const cleanMetadata = metadata instanceof Error ? this.serializeError(metadata) : metadata; return { level, timestamp: new Date().toISOString(), message: cleanMessage, metadata: cleanMetadata || {}, }; } private async sendLog(entry: LogEntry) { // Always log errors to console, even in production (for user debugging) // In dev, log everything to console const shouldConsoleLog = this.isDev || entry.level === "error"; if (shouldConsoleLog) { const consoleMethod = entry.level === "http" ? "debug" : entry.level; console[consoleMethod]( `[client-${entry.level}] ${entry.timestamp}:`, entry.message, entry.metadata && Object.keys(entry.metadata).length > 0 ? entry.metadata : "", ); } // In production, add to queue for batching if (!this.isDev) { this.queue.push(entry); // Safety flush if queue gets too large if (this.queue.length >= this.BATCH_SIZE_LIMIT) { await this.flush(); } } } error(message: any, metadata?: any) { this.sendLog(this.createLogEntry("error", message, metadata)); } warn(message: any, metadata?: any) { this.sendLog(this.createLogEntry("warn", message, metadata)); } info(message: any, metadata?: any) { this.sendLog(this.createLogEntry("info", message, metadata)); } http(message: any, metadata?: any) { this.sendLog(this.createLogEntry("http", message, metadata)); } debug(message: any, metadata?: any) { this.sendLog(this.createLogEntry("debug", message, metadata)); } // Manual flush method for advanced use cases async forceFlush() { await this.flush(); } // Cleanup method destroy() { if (this.timer) { clearInterval(this.timer); this.timer = null; } this.flushSync(); } } /** * Factory function to create a BrowserLogger instance * * @param isDev - Whether the app is running in development mode * @returns A new BrowserLogger instance * * @example * // SvelteKit * import { dev } from '$app/environment'; * const logger = getLoggerInstance(dev); * * @example * // Next.js * const logger = getLoggerInstance(process.env.NODE_ENV === 'development'); * * @example * // Vite * const logger = getLoggerInstance(import.meta.env.DEV); */ function getLoggerInstance(isDev: boolean): BrowserLogger { return new BrowserLogger(isDev); } function getError(logger: BrowserLogger, payload: Error, error?: any) { logger.error(payload); if (error) { logger.error(error); } return { code: payload.code, message: payload.message, description: payload.description, detail: payload.detail, error: error, actionable: payload.actionable, } as Error; } export { getError, getLoggerInstance };