/** * The `devtools` singleton — the imperative API. * * React apps normally never call `init`/`destroy` directly; the `` * component does. Everything else (`capture`, `error`, `log`) is safe to call * from anywhere, any time — before init it just fills the panel feed. * * From the browser console the same API is available as `window.devtools`. */ import { installConsoleCapture, installJsErrorCapture, installValidationCapture, } from './capture' import { DEFAULT_FLUSH_INTERVAL, getSessionId, isDevelopment } from './internal' import { devtoolsStore } from './store' import { EventLevel, EventType } from './types' import type { DevtoolsConfig, DevtoolsEvent, Logger } from './types' let flushTimer: ReturnType | null = null const cleanups: Array<() => void> = [] function makeEvent( type: EventType, level: EventLevel, message: string, extra?: Record, ): DevtoolsEvent { return { event_type: type, level, message, url: typeof window !== 'undefined' ? window.location.href : '', session_id: getSessionId(), user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '', ...(extra ? { extra } : {}), } } /** Pull an Error out of `data.error` so the panel gets a proper stack. */ function extractStack(data?: Record): { cleanData?: Record stack?: string } { if (!data) return {} const cleanData = { ...data } let stack: string | undefined const err = data.error if (err instanceof Error) { stack = err.stack cleanData.error = { name: err.name, message: err.message } } else if (typeof err === 'object' && err !== null) { const e = err as Record if (typeof e.stack === 'string') stack = e.stack if (typeof e.message === 'string') cleanData.error = e.message } return { cleanData, stack } } export const devtools = { /** Install capture hooks + start the ingest pump. Idempotent per mount. */ init(config: DevtoolsConfig = {}): void { if (typeof window === 'undefined') return const resolved: DevtoolsConfig = { ...config, baseUrl: config.baseUrl ?? process.env.NEXT_PUBLIC_API_URL ?? '', } devtoolsStore.getState().setConfig(resolved) getSessionId() // ensure the session cookie exists if (resolved.captureJsErrors !== false) cleanups.push(installJsErrorCapture()) if (resolved.captureConsole !== false) cleanups.push(installConsoleCapture()) cleanups.push(installValidationCapture()) flushTimer = setInterval( () => devtoolsStore.getState().flush(), resolved.flushInterval ?? DEFAULT_FLUSH_INTERVAL, ) const onHide = () => { if (document.visibilityState === 'hidden') devtoolsStore.getState().flush(true) } document.addEventListener('visibilitychange', onHide) cleanups.push(() => document.removeEventListener('visibilitychange', onHide)) window.devtools = devtools if (resolved.debug) console.info('[devtools] initialized', resolved) }, destroy(): void { if (flushTimer !== null) { clearInterval(flushTimer) flushTimer = null } cleanups.forEach((fn) => fn()) cleanups.length = 0 }, /** Capture a raw event (panel + backend ingest). */ capture(event: DevtoolsEvent): void { devtoolsStore.getState().capture(event) }, error(message: string, extra?: Record): void { devtoolsStore.getState().capture(makeEvent(EventType.JS_ERROR, EventLevel.ERROR, message, extra)) }, warn(message: string, extra?: Record): void { devtoolsStore.getState().capture(makeEvent(EventType.WARNING, EventLevel.WARNING, message, extra)) }, info(message: string, extra?: Record): void { devtoolsStore.getState().capture(makeEvent(EventType.WARNING, EventLevel.INFO, message, extra)) }, network(status: number, method: string, url: string, extra?: Record): void { devtoolsStore.getState().capture({ ...makeEvent( EventType.NETWORK_ERROR, status >= 500 ? EventLevel.ERROR : EventLevel.WARNING, `${method} ${url} → ${status}`, extra, ), http_status: status, http_method: method, http_url: url, }) }, /** * Component-scoped local logger. Entries go to the debug panel (and the * console in dev) — never to the backend. For "send this to the backend", * use `capture`/`error` instead. */ log(source: string): Logger { const write = ( level: 'debug' | 'info' | 'warn' | 'error' | 'success', message: string, data?: Record, ) => { const { cleanData, stack } = extractStack(data) if (typeof window !== 'undefined') { devtoolsStore.getState().addEntry({ level, source, message, data: cleanData, stack }) } if (isDevelopment) { const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log' if (cleanData) console[method](`[${source}]`, message, cleanData) else console[method](`[${source}]`, message) } } return { debug: (msg, data) => write('debug', msg, data), info: (msg, data) => write('info', msg, data), warn: (msg, data) => write('warn', msg, data), error: (msg, data) => write('error', msg, data), success: (msg, data) => write('success', msg, data), } }, /** Force-flush the ingest outbox. */ flush(): void { devtoolsStore.getState().flush() }, /** Print current state to the console (for `window.devtools.status()`). */ status(): void { const s = devtoolsStore.getState() console.group('[devtools] status') console.log('config:', s.config) console.log('panel entries:', s.entries.length) console.log('ingest outbox:', s.outbox.length) console.log('session_id:', getSessionId()) console.groupEnd() }, } export type DevtoolsApi = typeof devtools declare global { interface Window { devtools?: DevtoolsApi } }