/**
* 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
}
}