@djangocfg/devtools 2.1.459

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @djangocfg/devtools
2
+
3
+ Frontend devtools for django-cfg apps. One package, one event stream:
4
+
5
+ - **Capture** — `window.onerror`, unhandled rejections, `console.warn/error`,
6
+ zod validation events from `@djangocfg/api` clients.
7
+ - **Ingest** — errors/warnings are batched to `POST /cfg/monitor/ingest/`
8
+ (django-cfg's `django_monitor` extension) with dedupe, keepalive flush on
9
+ unload, and a circuit breaker.
10
+ - **Debug panel** — floating button (auto in dev, `?debug=1` in prod, ⌘D)
11
+ with a filterable log feed and app-defined custom tabs.
12
+
13
+ Replaces the retired `@djangocfg/monitor` + `@djangocfg/debuger` pair.
14
+
15
+ ## Usage
16
+
17
+ ```tsx
18
+ // Root layout — that's the whole setup
19
+ import { Devtools } from '@djangocfg/devtools'
20
+
21
+ <Devtools project="my-app" environment={process.env.NODE_ENV} />
22
+ ```
23
+
24
+ ```ts
25
+ // Anywhere in client code
26
+ import { devtools } from '@djangocfg/devtools'
27
+
28
+ devtools.error('Payment widget crashed', { orderId }) // panel + backend
29
+ const log = devtools.log('CheckoutForm') // panel only
30
+ log.info('step changed', { step })
31
+ ```
32
+
33
+ ```ts
34
+ // Server (route handlers / RSC)
35
+ import { serverDevtools } from '@djangocfg/devtools/server'
36
+
37
+ serverDevtools.configure({ project: 'my-app', baseUrl: process.env.API_URL })
38
+ await serverDevtools.captureError(err, { url: req.url })
39
+ ```
40
+
41
+ In the browser console: `window.devtools.status()`, `window.devtools.error('test')`.
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@djangocfg/devtools",
3
+ "version": "2.1.459",
4
+ "description": "Frontend devtools for django-cfg apps: error/console capture with backend ingest, plus a floating debug panel. One package, one event stream.",
5
+ "keywords": [
6
+ "django",
7
+ "devtools",
8
+ "debug-panel",
9
+ "error-tracking",
10
+ "monitoring",
11
+ "react",
12
+ "typescript"
13
+ ],
14
+ "author": {
15
+ "name": "DjangoCFG",
16
+ "url": "https://djangocfg.com"
17
+ },
18
+ "homepage": "https://djangocfg.com",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/markolofsen/django-cfg.git",
22
+ "directory": "packages/devtools"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/markolofsen/django-cfg/issues"
26
+ },
27
+ "license": "MIT",
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./src/index.ts",
32
+ "import": "./src/index.ts",
33
+ "require": "./src/index.ts"
34
+ },
35
+ "./server": {
36
+ "types": "./src/server.ts",
37
+ "import": "./src/server.ts",
38
+ "require": "./src/server.ts"
39
+ },
40
+ "./styles": "./src/styles.css"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "README.md"
45
+ ],
46
+ "scripts": {
47
+ "check": "tsc --noEmit",
48
+ "lint": "eslint ."
49
+ },
50
+ "peerDependencies": {
51
+ "@djangocfg/ui-core": "^2.1.459",
52
+ "lucide-react": ">=0.400.0",
53
+ "react": "^19.2.4",
54
+ "react-dom": "^19.2.4",
55
+ "zustand": "^5.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@djangocfg/typescript-config": "^2.1.459",
59
+ "@djangocfg/ui-core": "^2.1.459",
60
+ "@types/node": "^25.2.3",
61
+ "@types/react": "^19.2.15",
62
+ "lucide-react": "^0.545.0",
63
+ "typescript": "^5.9.3",
64
+ "zustand": "^5.0.0"
65
+ }
66
+ }
@@ -0,0 +1,48 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * <Devtools /> — the one component.
5
+ *
6
+ * Drop it once into the app root; it installs error/console capture with
7
+ * backend ingest AND renders the floating debug button + panel. Replaces the
8
+ * old MonitorProvider + DebugButton + monitorBridge trio.
9
+ *
10
+ * @example
11
+ * // app/layout.tsx (or BaseApp)
12
+ * import { Devtools } from '@djangocfg/devtools'
13
+ *
14
+ * <Devtools project="my-app" environment={process.env.NODE_ENV} />
15
+ */
16
+
17
+ import React, { memo, useEffect } from 'react'
18
+
19
+ import { devtools } from './instance'
20
+ import { DevtoolsButton } from './panel/DevtoolsButton'
21
+ import type { DevtoolsPanelProps } from './panel/DevtoolsPanel'
22
+ import type { DevtoolsConfig } from './types'
23
+
24
+ export interface DevtoolsProps extends DevtoolsConfig {
25
+ /** Panel customization (custom tabs, position, size) */
26
+ panel?: DevtoolsPanelProps
27
+ /** Hide the debug button entirely (capture + ingest still run). `?debug=1` overrides. */
28
+ button?: boolean
29
+ children?: React.ReactNode
30
+ }
31
+
32
+ function DevtoolsRaw({ panel, button, children, ...config }: DevtoolsProps) {
33
+ useEffect(() => {
34
+ devtools.init(config)
35
+ return () => devtools.destroy()
36
+ // Init once per mount — config is captured in the closure.
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ }, [])
39
+
40
+ return (
41
+ <>
42
+ {children}
43
+ <DevtoolsButton enabled={button} panel={panel} />
44
+ </>
45
+ )
46
+ }
47
+
48
+ export const Devtools = memo(DevtoolsRaw)
package/src/capture.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Browser capture hooks. Each installer returns its cleanup function.
3
+ * Everything funnels into `devtoolsStore.capture()` — dedupe, panel entry,
4
+ * and ingest buffering all happen there, once.
5
+ */
6
+
7
+ import { devtoolsStore } from './store'
8
+ import { INGEST_PATTERN, getSessionId, truncate } from './internal'
9
+ import { EventLevel, EventType } from './types'
10
+ import type { DevtoolsEvent } from './types'
11
+
12
+ const MSG_MAX = 2000
13
+ const ARG_MAX = 500
14
+
15
+ // Hydration mismatches caused by browser extensions (Grammarly, translators…).
16
+ // Not application bugs — filtering them prevents alert noise.
17
+ const HYDRATION_NOISE: RegExp[] = [
18
+ /hydration failed/i,
19
+ /there was an error while hydrating/i,
20
+ /minified react error #418/i,
21
+ /minified react error #423/i,
22
+ /minified react error #425/i,
23
+ /text content does not match server-rendered html/i,
24
+ ]
25
+
26
+ function isNoise(msg: string, stack?: string): boolean {
27
+ if (HYDRATION_NOISE.some((p) => p.test(msg))) return true
28
+ // Our own transport failing must never loop back into capture.
29
+ return INGEST_PATTERN.test(msg) || INGEST_PATTERN.test(stack ?? '')
30
+ }
31
+
32
+ function browserContext(): Pick<DevtoolsEvent, 'url' | 'session_id' | 'user_agent'> {
33
+ return {
34
+ url: typeof window !== 'undefined' ? window.location.href : '',
35
+ session_id: getSessionId(),
36
+ user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
37
+ }
38
+ }
39
+
40
+ // ─── window.onerror + unhandledrejection ────────────────────────────────────
41
+
42
+ export function installJsErrorCapture(): () => void {
43
+ if (typeof window === 'undefined') return () => {}
44
+
45
+ const report = (msg: string, stack: string) => {
46
+ try {
47
+ if (isNoise(msg, stack)) return
48
+ devtoolsStore.getState().capture({
49
+ event_type: EventType.JS_ERROR,
50
+ level: EventLevel.ERROR,
51
+ message: truncate(msg, MSG_MAX),
52
+ stack_trace: stack,
53
+ ...browserContext(),
54
+ })
55
+ } catch {
56
+ /* never crash the host app */
57
+ }
58
+ }
59
+
60
+ const onError = (e: ErrorEvent) => {
61
+ const msg = e.message || String(e.error ?? 'Unknown error')
62
+ report(msg, e.error?.stack ?? `at ${e.filename}:${e.lineno}:${e.colno}`)
63
+ }
64
+
65
+ const onRejection = (e: PromiseRejectionEvent) => {
66
+ const reason = e.reason
67
+ const msg =
68
+ reason instanceof Error
69
+ ? reason.message
70
+ : typeof reason === 'string'
71
+ ? reason
72
+ : 'Unhandled promise rejection'
73
+ report(msg, reason instanceof Error ? (reason.stack ?? '') : '')
74
+ }
75
+
76
+ window.addEventListener('error', onError)
77
+ window.addEventListener('unhandledrejection', onRejection)
78
+ return () => {
79
+ window.removeEventListener('error', onError)
80
+ window.removeEventListener('unhandledrejection', onRejection)
81
+ }
82
+ }
83
+
84
+ // ─── console.warn / console.error ────────────────────────────────────────────
85
+
86
+ function stringifyArgs(args: unknown[]): string {
87
+ return args
88
+ .map((a) => {
89
+ let s: string
90
+ if (typeof a === 'string') s = a
91
+ else if (a instanceof Error) s = a.message
92
+ else {
93
+ try {
94
+ s = JSON.stringify(a)
95
+ } catch {
96
+ s = String(a)
97
+ }
98
+ }
99
+ return truncate(s, ARG_MAX)
100
+ })
101
+ .join(' ')
102
+ }
103
+
104
+ export function installConsoleCapture(): () => void {
105
+ if (typeof window === 'undefined') return () => {}
106
+
107
+ const report = (level: 'warn' | 'error', args: unknown[]) => {
108
+ try {
109
+ const message = stringifyArgs(args)
110
+ const stack = args.find((a): a is Error => a instanceof Error)?.stack
111
+ if (isNoise(message, stack)) return
112
+ devtoolsStore.getState().capture({
113
+ event_type: level === 'error' ? EventType.ERROR : EventType.WARNING,
114
+ level: level === 'error' ? EventLevel.ERROR : EventLevel.WARNING,
115
+ message,
116
+ stack_trace: stack,
117
+ ...browserContext(),
118
+ })
119
+ } catch {
120
+ /* never crash */
121
+ }
122
+ }
123
+
124
+ const origWarn = console.warn.bind(console)
125
+ const origError = console.error.bind(console)
126
+ console.warn = (...args: unknown[]) => {
127
+ origWarn(...args)
128
+ report('warn', args)
129
+ }
130
+ console.error = (...args: unknown[]) => {
131
+ origError(...args)
132
+ report('error', args)
133
+ }
134
+ return () => {
135
+ console.warn = origWarn
136
+ console.error = origError
137
+ }
138
+ }
139
+
140
+ // ─── zod-validation-error events (fired by @djangocfg/api clients) ──────────
141
+
142
+ interface ValidationErrorDetail {
143
+ operation: string
144
+ path: string
145
+ method: string
146
+ error: { message: string }
147
+ }
148
+
149
+ export function installValidationCapture(): () => void {
150
+ if (typeof window === 'undefined') return () => {}
151
+
152
+ const handler = (event: Event) => {
153
+ if (!(event instanceof CustomEvent)) return
154
+ try {
155
+ const detail = event.detail as ValidationErrorDetail
156
+ devtoolsStore.getState().capture({
157
+ event_type: EventType.WARNING,
158
+ level: EventLevel.WARNING,
159
+ message: truncate(
160
+ `Zod validation error in ${detail.operation}: ${detail.error?.message ?? 'unknown'}`,
161
+ MSG_MAX,
162
+ ),
163
+ http_method: detail.method,
164
+ http_url: detail.path,
165
+ extra: { operation: detail.operation, path: detail.path, method: detail.method },
166
+ ...browserContext(),
167
+ })
168
+ } catch {
169
+ /* never crash */
170
+ }
171
+ }
172
+
173
+ window.addEventListener('zod-validation-error', handler)
174
+ return () => window.removeEventListener('zod-validation-error', handler)
175
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @djangocfg/devtools — client entry.
3
+ *
4
+ * One package for frontend observability in django-cfg apps:
5
+ * - `<Devtools />`: error/console capture + backend ingest + debug panel
6
+ * - `devtools`: imperative capture API + component-scoped local loggers
7
+ * - also available in the browser console as `window.devtools`
8
+ *
9
+ * Server-side reporting lives in `@djangocfg/devtools/server`.
10
+ */
11
+
12
+ // The component
13
+ export { Devtools } from './Devtools'
14
+ export type { DevtoolsProps } from './Devtools'
15
+
16
+ // Imperative API
17
+ export { devtools } from './instance'
18
+ export type { DevtoolsApi } from './instance'
19
+
20
+ // Store (advanced: custom tabs subscribing to the entry feed)
21
+ export { devtoolsStore } from './store'
22
+ export type { DevtoolsState } from './store'
23
+
24
+ // Types
25
+ export { EventLevel, EventType } from './types'
26
+ export type {
27
+ CustomDebugTab,
28
+ DevtoolsConfig,
29
+ DevtoolsEvent,
30
+ LogEntry,
31
+ Logger,
32
+ LogLevel,
33
+ ServerDevtoolsConfig,
34
+ } from './types'
35
+
36
+ // Panel pieces (for apps that want the panel without the auto button)
37
+ export { DevtoolsButton } from './panel/DevtoolsButton'
38
+ export type { DevtoolsButtonProps } from './panel/DevtoolsButton'
39
+ export { DevtoolsPanel } from './panel/DevtoolsPanel'
40
+ export type { DevtoolsPanelProps } from './panel/DevtoolsPanel'
41
+
42
+ // Utilities
43
+ export { useDebugMode } from './useDebugMode'
44
+ export { getSessionId, isDevelopment, isProduction } from './internal'
package/src/ingest.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ingest transport — one plain fetch to `POST /cfg/monitor/ingest/`.
3
+ *
4
+ * The endpoint is AllowAny + rate-limited on the backend and always answers
5
+ * 202; nothing here needs auth headers or a generated client. `keepalive` is
6
+ * used on page-unload flushes so the request survives the document.
7
+ */
8
+
9
+ import { INGEST_PATH } from './internal'
10
+ import type { DevtoolsEvent } from './types'
11
+
12
+ export async function sendBatch(
13
+ baseUrl: string,
14
+ events: DevtoolsEvent[],
15
+ useBeacon = false,
16
+ ): Promise<void> {
17
+ if (events.length === 0) return
18
+
19
+ const res = await fetch(`${baseUrl}${INGEST_PATH}`, {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({ events }),
23
+ credentials: 'include',
24
+ ...(useBeacon ? { keepalive: true } : {}),
25
+ })
26
+ if (!res.ok) throw new Error(`ingest ${res.status}`)
27
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The `devtools` singleton — the imperative API.
3
+ *
4
+ * React apps normally never call `init`/`destroy` directly; the `<Devtools />`
5
+ * component does. Everything else (`capture`, `error`, `log`) is safe to call
6
+ * from anywhere, any time — before init it just fills the panel feed.
7
+ *
8
+ * From the browser console the same API is available as `window.devtools`.
9
+ */
10
+
11
+ import {
12
+ installConsoleCapture,
13
+ installJsErrorCapture,
14
+ installValidationCapture,
15
+ } from './capture'
16
+ import { DEFAULT_FLUSH_INTERVAL, getSessionId, isDevelopment } from './internal'
17
+ import { devtoolsStore } from './store'
18
+ import { EventLevel, EventType } from './types'
19
+ import type { DevtoolsConfig, DevtoolsEvent, Logger } from './types'
20
+
21
+ let flushTimer: ReturnType<typeof setInterval> | null = null
22
+ const cleanups: Array<() => void> = []
23
+
24
+ function makeEvent(
25
+ type: EventType,
26
+ level: EventLevel,
27
+ message: string,
28
+ extra?: Record<string, unknown>,
29
+ ): DevtoolsEvent {
30
+ return {
31
+ event_type: type,
32
+ level,
33
+ message,
34
+ url: typeof window !== 'undefined' ? window.location.href : '',
35
+ session_id: getSessionId(),
36
+ user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
37
+ ...(extra ? { extra } : {}),
38
+ }
39
+ }
40
+
41
+ /** Pull an Error out of `data.error` so the panel gets a proper stack. */
42
+ function extractStack(data?: Record<string, unknown>): {
43
+ cleanData?: Record<string, unknown>
44
+ stack?: string
45
+ } {
46
+ if (!data) return {}
47
+ const cleanData = { ...data }
48
+ let stack: string | undefined
49
+ const err = data.error
50
+ if (err instanceof Error) {
51
+ stack = err.stack
52
+ cleanData.error = { name: err.name, message: err.message }
53
+ } else if (typeof err === 'object' && err !== null) {
54
+ const e = err as Record<string, unknown>
55
+ if (typeof e.stack === 'string') stack = e.stack
56
+ if (typeof e.message === 'string') cleanData.error = e.message
57
+ }
58
+ return { cleanData, stack }
59
+ }
60
+
61
+ export const devtools = {
62
+ /** Install capture hooks + start the ingest pump. Idempotent per mount. */
63
+ init(config: DevtoolsConfig = {}): void {
64
+ if (typeof window === 'undefined') return
65
+
66
+ const resolved: DevtoolsConfig = {
67
+ ...config,
68
+ baseUrl: config.baseUrl ?? process.env.NEXT_PUBLIC_API_URL ?? '',
69
+ }
70
+ devtoolsStore.getState().setConfig(resolved)
71
+ getSessionId() // ensure the session cookie exists
72
+
73
+ if (resolved.captureJsErrors !== false) cleanups.push(installJsErrorCapture())
74
+ if (resolved.captureConsole !== false) cleanups.push(installConsoleCapture())
75
+ cleanups.push(installValidationCapture())
76
+
77
+ flushTimer = setInterval(
78
+ () => devtoolsStore.getState().flush(),
79
+ resolved.flushInterval ?? DEFAULT_FLUSH_INTERVAL,
80
+ )
81
+ const onHide = () => {
82
+ if (document.visibilityState === 'hidden') devtoolsStore.getState().flush(true)
83
+ }
84
+ document.addEventListener('visibilitychange', onHide)
85
+ cleanups.push(() => document.removeEventListener('visibilitychange', onHide))
86
+
87
+ window.devtools = devtools
88
+
89
+ if (resolved.debug) console.info('[devtools] initialized', resolved)
90
+ },
91
+
92
+ destroy(): void {
93
+ if (flushTimer !== null) {
94
+ clearInterval(flushTimer)
95
+ flushTimer = null
96
+ }
97
+ cleanups.forEach((fn) => fn())
98
+ cleanups.length = 0
99
+ },
100
+
101
+ /** Capture a raw event (panel + backend ingest). */
102
+ capture(event: DevtoolsEvent): void {
103
+ devtoolsStore.getState().capture(event)
104
+ },
105
+
106
+ error(message: string, extra?: Record<string, unknown>): void {
107
+ devtoolsStore.getState().capture(makeEvent(EventType.JS_ERROR, EventLevel.ERROR, message, extra))
108
+ },
109
+
110
+ warn(message: string, extra?: Record<string, unknown>): void {
111
+ devtoolsStore.getState().capture(makeEvent(EventType.WARNING, EventLevel.WARNING, message, extra))
112
+ },
113
+
114
+ info(message: string, extra?: Record<string, unknown>): void {
115
+ devtoolsStore.getState().capture(makeEvent(EventType.WARNING, EventLevel.INFO, message, extra))
116
+ },
117
+
118
+ network(status: number, method: string, url: string, extra?: Record<string, unknown>): void {
119
+ devtoolsStore.getState().capture({
120
+ ...makeEvent(
121
+ EventType.NETWORK_ERROR,
122
+ status >= 500 ? EventLevel.ERROR : EventLevel.WARNING,
123
+ `${method} ${url} → ${status}`,
124
+ extra,
125
+ ),
126
+ http_status: status,
127
+ http_method: method,
128
+ http_url: url,
129
+ })
130
+ },
131
+
132
+ /**
133
+ * Component-scoped local logger. Entries go to the debug panel (and the
134
+ * console in dev) — never to the backend. For "send this to the backend",
135
+ * use `capture`/`error` instead.
136
+ */
137
+ log(source: string): Logger {
138
+ const write = (
139
+ level: 'debug' | 'info' | 'warn' | 'error' | 'success',
140
+ message: string,
141
+ data?: Record<string, unknown>,
142
+ ) => {
143
+ const { cleanData, stack } = extractStack(data)
144
+ if (typeof window !== 'undefined') {
145
+ devtoolsStore.getState().addEntry({ level, source, message, data: cleanData, stack })
146
+ }
147
+ if (isDevelopment) {
148
+ const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'
149
+ if (cleanData) console[method](`[${source}]`, message, cleanData)
150
+ else console[method](`[${source}]`, message)
151
+ }
152
+ }
153
+ return {
154
+ debug: (msg, data) => write('debug', msg, data),
155
+ info: (msg, data) => write('info', msg, data),
156
+ warn: (msg, data) => write('warn', msg, data),
157
+ error: (msg, data) => write('error', msg, data),
158
+ success: (msg, data) => write('success', msg, data),
159
+ }
160
+ },
161
+
162
+ /** Force-flush the ingest outbox. */
163
+ flush(): void {
164
+ devtoolsStore.getState().flush()
165
+ },
166
+
167
+ /** Print current state to the console (for `window.devtools.status()`). */
168
+ status(): void {
169
+ const s = devtoolsStore.getState()
170
+ console.group('[devtools] status')
171
+ console.log('config:', s.config)
172
+ console.log('panel entries:', s.entries.length)
173
+ console.log('ingest outbox:', s.outbox.length)
174
+ console.log('session_id:', getSessionId())
175
+ console.groupEnd()
176
+ },
177
+ }
178
+
179
+ export type DevtoolsApi = typeof devtools
180
+
181
+ declare global {
182
+ interface Window {
183
+ devtools?: DevtoolsApi
184
+ }
185
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Internal utilities: environment, session identity, fingerprinting, dedupe.
3
+ */
4
+
5
+ // ─── Environment ─────────────────────────────────────────────────────────────
6
+
7
+ /** True only in next dev / jest / vitest */
8
+ export const isDevelopment = process.env.NODE_ENV === 'development'
9
+
10
+ /** True in any non-development environment */
11
+ export const isProduction = !isDevelopment
12
+
13
+ /** Ingest endpoint (django_monitor extension). */
14
+ export const INGEST_PATH = '/cfg/monitor/ingest/'
15
+
16
+ /** Matches requests to the ingest endpoint — prevents capture→flush feedback loops. */
17
+ export const INGEST_PATTERN = /cfg\/monitor\/ingest/
18
+
19
+ export const DEFAULT_DEDUPE_TTL = 30_000
20
+ export const DEFAULT_FLUSH_INTERVAL = 5_000
21
+ export const DEFAULT_MAX_BUFFER = 20
22
+
23
+ // ─── Session identity ────────────────────────────────────────────────────────
24
+ // Key kept from @djangocfg/monitor so existing visitors keep their session id.
25
+
26
+ const SESSION_KEY = 'fm_session_id'
27
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365
28
+
29
+ function generateUUID(): string {
30
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
31
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
32
+ const r = (Math.random() * 16) | 0
33
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
34
+ })
35
+ }
36
+
37
+ export function getSessionId(): string {
38
+ if (typeof localStorage === 'undefined') return ''
39
+ try {
40
+ let id = localStorage.getItem(SESSION_KEY)
41
+ if (!id) {
42
+ id = generateUUID()
43
+ localStorage.setItem(SESSION_KEY, id)
44
+ document.cookie = `${SESSION_KEY}=${id}; path=/; SameSite=Lax; max-age=${COOKIE_MAX_AGE}`
45
+ }
46
+ return id
47
+ } catch {
48
+ return ''
49
+ }
50
+ }
51
+
52
+ // ─── Fingerprint (sync — no crypto.subtle round-trip needed for dedup) ──────
53
+
54
+ export function computeFingerprint(message: string, stack: string, url: string): string {
55
+ const raw = `${message}|${stack}|${url}`
56
+ let hash = 0
57
+ for (let i = 0; i < raw.length; i++) {
58
+ hash = (hash << 5) - hash + raw.charCodeAt(i)
59
+ hash = hash & hash
60
+ }
61
+ return Math.abs(hash).toString(16).padStart(8, '0')
62
+ }
63
+
64
+ // ─── Dedupe ──────────────────────────────────────────────────────────────────
65
+
66
+ /** TTL-based seen-set with a size cap. One instance guards one capture path. */
67
+ export function makeDeduper(max = 200): (key: string, ttl: number) => boolean {
68
+ const seen = new Map<string, number>()
69
+ return (key, ttl) => {
70
+ const now = Date.now()
71
+ const last = seen.get(key)
72
+ if (last !== undefined && now - last < ttl) return true
73
+ seen.set(key, now)
74
+ if (seen.size > max) {
75
+ for (const [k, ts] of seen) {
76
+ if (now - ts > ttl) seen.delete(k)
77
+ }
78
+ }
79
+ return false
80
+ }
81
+ }
82
+
83
+ export function truncate(s: string, max: number): string {
84
+ return s.length > max ? s.slice(0, max - 1) + '…' : s
85
+ }