@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 +41 -0
- package/package.json +66 -0
- package/src/Devtools.tsx +48 -0
- package/src/capture.ts +175 -0
- package/src/index.ts +44 -0
- package/src/ingest.ts +27 -0
- package/src/instance.ts +185 -0
- package/src/internal.ts +85 -0
- package/src/panel/DevtoolsButton.tsx +129 -0
- package/src/panel/DevtoolsPanel.tsx +142 -0
- package/src/panel/LogsTab.tsx +165 -0
- package/src/server.ts +86 -0
- package/src/store.ts +170 -0
- package/src/styles.css +6 -0
- package/src/types.ts +113 -0
- package/src/useDebugMode.ts +58 -0
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
|
+
}
|
package/src/Devtools.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/instance.ts
ADDED
|
@@ -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
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -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
|
+
}
|