@stravigor/devtools 0.1.0
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/package.json +22 -0
- package/src/collectors/collector.ts +65 -0
- package/src/collectors/exception_collector.ts +56 -0
- package/src/collectors/job_collector.ts +117 -0
- package/src/collectors/log_collector.ts +69 -0
- package/src/collectors/query_collector.ts +106 -0
- package/src/collectors/request_collector.ts +126 -0
- package/src/commands/devtools_prune.ts +55 -0
- package/src/dashboard/middleware.ts +42 -0
- package/src/dashboard/routes.ts +509 -0
- package/src/devtools_manager.ts +244 -0
- package/src/errors.ts +3 -0
- package/src/helpers.ts +82 -0
- package/src/index.ts +41 -0
- package/src/recorders/recorder.ts +51 -0
- package/src/recorders/slow_queries.ts +44 -0
- package/src/recorders/slow_requests.ts +44 -0
- package/src/storage/aggregate_store.ts +195 -0
- package/src/storage/entry_store.ts +160 -0
- package/src/types.ts +81 -0
- package/stubs/config/devtools.ts +24 -0
- package/stubs/schemas/devtools_aggregates.ts +14 -0
- package/stubs/schemas/devtools_entries.ts +13 -0
- package/tsconfig.json +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/devtools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Debug inspector and performance monitor for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"strav": {
|
|
12
|
+
"commands": "src/commands"
|
|
13
|
+
},
|
|
14
|
+
"files": ["src/", "stubs/", "package.json", "tsconfig.json"],
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@stravigor/core": "0.2.6"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test tests/",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DevtoolsEntry, EntryType, CollectorOptions } from '../types.ts'
|
|
2
|
+
import type EntryStore from '../storage/entry_store.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base class for all collectors (the Telescope-like component).
|
|
6
|
+
*
|
|
7
|
+
* A collector listens to framework events and produces {@link DevtoolsEntry}
|
|
8
|
+
* objects that are flushed to the {@link EntryStore} in batches.
|
|
9
|
+
*/
|
|
10
|
+
export default abstract class Collector {
|
|
11
|
+
protected enabled: boolean
|
|
12
|
+
protected queue: DevtoolsEntry[] = []
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
protected store: EntryStore,
|
|
16
|
+
protected options: CollectorOptions
|
|
17
|
+
) {
|
|
18
|
+
this.enabled = options.enabled !== false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Register event listeners. Called once during DevtoolsManager boot. */
|
|
22
|
+
abstract register(): void
|
|
23
|
+
|
|
24
|
+
/** Remove event listeners. Called during teardown. */
|
|
25
|
+
abstract teardown(): void
|
|
26
|
+
|
|
27
|
+
/** Flush buffered entries to the store. */
|
|
28
|
+
async flush(): Promise<void> {
|
|
29
|
+
if (this.queue.length === 0) return
|
|
30
|
+
const batch = this.queue.splice(0)
|
|
31
|
+
try {
|
|
32
|
+
await this.store.store(batch)
|
|
33
|
+
} catch {
|
|
34
|
+
// Devtools must never crash the app
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Create an entry and buffer it for storage. */
|
|
39
|
+
protected record(
|
|
40
|
+
type: EntryType,
|
|
41
|
+
batchId: string,
|
|
42
|
+
content: Record<string, unknown>,
|
|
43
|
+
tags: string[] = [],
|
|
44
|
+
familyHash: string | null = null
|
|
45
|
+
): void {
|
|
46
|
+
if (!this.enabled) return
|
|
47
|
+
|
|
48
|
+
this.queue.push({
|
|
49
|
+
uuid: crypto.randomUUID(),
|
|
50
|
+
batchId,
|
|
51
|
+
type,
|
|
52
|
+
familyHash,
|
|
53
|
+
content,
|
|
54
|
+
tags,
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Generate a simple hash for grouping similar entries (e.g. same SQL pattern). */
|
|
60
|
+
protected hash(input: string): string {
|
|
61
|
+
const hasher = new Bun.CryptoHasher('md5')
|
|
62
|
+
hasher.update(input)
|
|
63
|
+
return hasher.digest('hex')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
2
|
+
import type { Listener } from '@stravigor/core/events/emitter'
|
|
3
|
+
import Collector from './collector.ts'
|
|
4
|
+
import type EntryStore from '../storage/entry_store.ts'
|
|
5
|
+
import type { CollectorOptions } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Captures exceptions emitted by the ExceptionHandler.
|
|
9
|
+
*
|
|
10
|
+
* Listens to the `http:error` event added to the core ExceptionHandler.
|
|
11
|
+
* Records exception class, message, stack trace, and request context.
|
|
12
|
+
*/
|
|
13
|
+
export default class ExceptionCollector extends Collector {
|
|
14
|
+
private listener: Listener | null = null
|
|
15
|
+
private getBatchId: () => string
|
|
16
|
+
|
|
17
|
+
constructor(store: EntryStore, options: CollectorOptions, getBatchId: () => string) {
|
|
18
|
+
super(store, options)
|
|
19
|
+
this.getBatchId = getBatchId
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
register(): void {
|
|
23
|
+
if (!this.enabled) return
|
|
24
|
+
|
|
25
|
+
this.listener = (payload: { error: Error; ctx?: { path?: string; method?: string } }) => {
|
|
26
|
+
const { error, ctx } = payload
|
|
27
|
+
const batchId = this.getBatchId()
|
|
28
|
+
|
|
29
|
+
const content: Record<string, unknown> = {
|
|
30
|
+
class: error.constructor.name,
|
|
31
|
+
message: error.message,
|
|
32
|
+
stack: error.stack?.split('\n').slice(0, 20),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (ctx) {
|
|
36
|
+
content.method = ctx.method
|
|
37
|
+
content.path = ctx.path
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tags = [error.constructor.name]
|
|
41
|
+
const familyHash = this.hash(`${error.constructor.name}:${error.message}`)
|
|
42
|
+
|
|
43
|
+
this.record('exception', batchId, content, tags, familyHash)
|
|
44
|
+
this.flush()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Emitter.on('http:error', this.listener)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
teardown(): void {
|
|
51
|
+
if (this.listener) {
|
|
52
|
+
Emitter.off('http:error', this.listener)
|
|
53
|
+
this.listener = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
2
|
+
import type { Listener } from '@stravigor/core/events/emitter'
|
|
3
|
+
import Collector from './collector.ts'
|
|
4
|
+
import type EntryStore from '../storage/entry_store.ts'
|
|
5
|
+
import type { CollectorOptions } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Captures queue job lifecycle events.
|
|
9
|
+
*
|
|
10
|
+
* Listens to:
|
|
11
|
+
* - `queue:dispatched` — when a job is pushed onto the queue
|
|
12
|
+
* - `queue:processed` — when a job completes successfully
|
|
13
|
+
* - `queue:failed` — when a job fails after max attempts
|
|
14
|
+
*/
|
|
15
|
+
export default class JobCollector extends Collector {
|
|
16
|
+
private dispatchedListener: Listener | null = null
|
|
17
|
+
private processedListener: Listener | null = null
|
|
18
|
+
private failedListener: Listener | null = null
|
|
19
|
+
|
|
20
|
+
constructor(store: EntryStore, options: CollectorOptions) {
|
|
21
|
+
super(store, options)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
register(): void {
|
|
25
|
+
if (!this.enabled) return
|
|
26
|
+
|
|
27
|
+
this.dispatchedListener = (payload: {
|
|
28
|
+
id: number
|
|
29
|
+
name: string
|
|
30
|
+
queue: string
|
|
31
|
+
payload: unknown
|
|
32
|
+
}) => {
|
|
33
|
+
const batchId = crypto.randomUUID()
|
|
34
|
+
|
|
35
|
+
this.record(
|
|
36
|
+
'job',
|
|
37
|
+
batchId,
|
|
38
|
+
{
|
|
39
|
+
status: 'dispatched',
|
|
40
|
+
jobId: payload.id,
|
|
41
|
+
name: payload.name,
|
|
42
|
+
queue: payload.queue,
|
|
43
|
+
payload: payload.payload,
|
|
44
|
+
},
|
|
45
|
+
[payload.name, 'dispatched']
|
|
46
|
+
)
|
|
47
|
+
this.flush()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.processedListener = (payload: {
|
|
51
|
+
job: string
|
|
52
|
+
id: number
|
|
53
|
+
queue: string
|
|
54
|
+
duration: number
|
|
55
|
+
}) => {
|
|
56
|
+
const batchId = crypto.randomUUID()
|
|
57
|
+
|
|
58
|
+
this.record(
|
|
59
|
+
'job',
|
|
60
|
+
batchId,
|
|
61
|
+
{
|
|
62
|
+
status: 'processed',
|
|
63
|
+
jobId: payload.id,
|
|
64
|
+
name: payload.job,
|
|
65
|
+
queue: payload.queue,
|
|
66
|
+
duration: Math.round(payload.duration * 100) / 100,
|
|
67
|
+
},
|
|
68
|
+
[payload.job, 'processed']
|
|
69
|
+
)
|
|
70
|
+
this.flush()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.failedListener = (payload: {
|
|
74
|
+
job: string
|
|
75
|
+
id: number
|
|
76
|
+
queue: string
|
|
77
|
+
error: string
|
|
78
|
+
duration: number
|
|
79
|
+
}) => {
|
|
80
|
+
const batchId = crypto.randomUUID()
|
|
81
|
+
|
|
82
|
+
this.record(
|
|
83
|
+
'job',
|
|
84
|
+
batchId,
|
|
85
|
+
{
|
|
86
|
+
status: 'failed',
|
|
87
|
+
jobId: payload.id,
|
|
88
|
+
name: payload.job,
|
|
89
|
+
queue: payload.queue,
|
|
90
|
+
error: payload.error,
|
|
91
|
+
duration: Math.round(payload.duration * 100) / 100,
|
|
92
|
+
},
|
|
93
|
+
[payload.job, 'failed']
|
|
94
|
+
)
|
|
95
|
+
this.flush()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
Emitter.on('queue:dispatched', this.dispatchedListener)
|
|
99
|
+
Emitter.on('queue:processed', this.processedListener)
|
|
100
|
+
Emitter.on('queue:failed', this.failedListener)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
teardown(): void {
|
|
104
|
+
if (this.dispatchedListener) {
|
|
105
|
+
Emitter.off('queue:dispatched', this.dispatchedListener)
|
|
106
|
+
this.dispatchedListener = null
|
|
107
|
+
}
|
|
108
|
+
if (this.processedListener) {
|
|
109
|
+
Emitter.off('queue:processed', this.processedListener)
|
|
110
|
+
this.processedListener = null
|
|
111
|
+
}
|
|
112
|
+
if (this.failedListener) {
|
|
113
|
+
Emitter.off('queue:failed', this.failedListener)
|
|
114
|
+
this.failedListener = null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
2
|
+
import type { Listener } from '@stravigor/core/events/emitter'
|
|
3
|
+
import Collector from './collector.ts'
|
|
4
|
+
import type EntryStore from '../storage/entry_store.ts'
|
|
5
|
+
import type { CollectorOptions } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const
|
|
8
|
+
|
|
9
|
+
interface LogCollectorOptions extends CollectorOptions {
|
|
10
|
+
level?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Captures log entries emitted by the Logger.
|
|
15
|
+
*
|
|
16
|
+
* Listens to the `log:entry` event added to the core Logger.
|
|
17
|
+
* Filters by minimum log level (default: 'debug').
|
|
18
|
+
*/
|
|
19
|
+
export default class LogCollector extends Collector {
|
|
20
|
+
private listener: Listener | null = null
|
|
21
|
+
private minLevelIndex: number
|
|
22
|
+
private getBatchId: () => string
|
|
23
|
+
|
|
24
|
+
constructor(store: EntryStore, options: LogCollectorOptions, getBatchId: () => string) {
|
|
25
|
+
super(store, options)
|
|
26
|
+
const level = options.level ?? 'debug'
|
|
27
|
+
this.minLevelIndex = LOG_LEVELS.indexOf(level as (typeof LOG_LEVELS)[number])
|
|
28
|
+
if (this.minLevelIndex === -1) this.minLevelIndex = 1 // default to debug
|
|
29
|
+
this.getBatchId = getBatchId
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
register(): void {
|
|
33
|
+
if (!this.enabled) return
|
|
34
|
+
|
|
35
|
+
this.listener = (payload: {
|
|
36
|
+
level: string
|
|
37
|
+
msg: string
|
|
38
|
+
context?: Record<string, unknown>
|
|
39
|
+
}) => {
|
|
40
|
+
const levelIndex = LOG_LEVELS.indexOf(payload.level as (typeof LOG_LEVELS)[number])
|
|
41
|
+
if (levelIndex < this.minLevelIndex) return
|
|
42
|
+
|
|
43
|
+
const batchId = this.getBatchId()
|
|
44
|
+
|
|
45
|
+
const content: Record<string, unknown> = {
|
|
46
|
+
level: payload.level,
|
|
47
|
+
message: payload.msg,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (payload.context) {
|
|
51
|
+
content.context = payload.context
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tags = [payload.level]
|
|
55
|
+
if (levelIndex >= 4) tags.push('error')
|
|
56
|
+
|
|
57
|
+
this.record('log', batchId, content, tags)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Emitter.on('log:entry', this.listener)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
teardown(): void {
|
|
64
|
+
if (this.listener) {
|
|
65
|
+
Emitter.off('log:entry', this.listener)
|
|
66
|
+
this.listener = null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
import Collector from './collector.ts'
|
|
3
|
+
import DevtoolsManager from '../devtools_manager.ts'
|
|
4
|
+
import type EntryStore from '../storage/entry_store.ts'
|
|
5
|
+
import type { CollectorOptions } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
interface QueryCollectorOptions extends CollectorOptions {
|
|
8
|
+
slow?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Captures SQL queries by proxying the Bun.sql tagged template call.
|
|
13
|
+
*
|
|
14
|
+
* When enabled, wraps the SQL connection with a Proxy that intercepts
|
|
15
|
+
* tagged template literal calls to record query text, duration, and bindings.
|
|
16
|
+
*/
|
|
17
|
+
export default class QueryCollector extends Collector {
|
|
18
|
+
private slowThreshold: number
|
|
19
|
+
private originalSql: SQL | null = null
|
|
20
|
+
private getBatchId: () => string
|
|
21
|
+
|
|
22
|
+
constructor(store: EntryStore, options: QueryCollectorOptions, getBatchId: () => string) {
|
|
23
|
+
super(store, options)
|
|
24
|
+
this.slowThreshold = options.slow ?? 100
|
|
25
|
+
this.getBatchId = getBatchId
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
register(): void {
|
|
29
|
+
// Proxying is handled by installProxy()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
teardown(): void {
|
|
33
|
+
// Proxy removal is handled by DevtoolsManager
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a SQL connection with a Proxy that intercepts queries.
|
|
38
|
+
* Returns the proxied SQL instance.
|
|
39
|
+
*/
|
|
40
|
+
installProxy(sql: SQL): SQL {
|
|
41
|
+
if (!this.enabled) return sql
|
|
42
|
+
this.originalSql = sql
|
|
43
|
+
|
|
44
|
+
const collector = this
|
|
45
|
+
|
|
46
|
+
return new Proxy(sql, {
|
|
47
|
+
apply(target, thisArg, args) {
|
|
48
|
+
// Tagged template calls pass an array of strings as the first argument
|
|
49
|
+
if (!Array.isArray(args[0])) {
|
|
50
|
+
return Reflect.apply(target, thisArg, args)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const strings = args[0] as string[]
|
|
54
|
+
const bindings = Array.prototype.slice.call(args, 1)
|
|
55
|
+
|
|
56
|
+
// Build a normalized SQL string for hashing (replace values with $N)
|
|
57
|
+
const sqlText = strings.reduce((acc: string, str: string, i: number) => {
|
|
58
|
+
return i === 0 ? str : `${acc}$${i}${str}`
|
|
59
|
+
}, '')
|
|
60
|
+
|
|
61
|
+
const start = performance.now()
|
|
62
|
+
const result = Reflect.apply(target, thisArg, args)
|
|
63
|
+
|
|
64
|
+
// Handle async results (most queries return promises)
|
|
65
|
+
if (result && typeof result.then === 'function') {
|
|
66
|
+
return result.then((res: unknown) => {
|
|
67
|
+
collector.recordQuery(sqlText, bindings, performance.now() - start)
|
|
68
|
+
return res
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
collector.recordQuery(sqlText, bindings, performance.now() - start)
|
|
73
|
+
return result
|
|
74
|
+
},
|
|
75
|
+
}) as SQL
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get the original (unwrapped) SQL connection. */
|
|
79
|
+
getOriginalSql(): SQL | null {
|
|
80
|
+
return this.originalSql
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private recordQuery(sql: string, bindings: unknown[], duration: number): void {
|
|
84
|
+
const batchId = this.getBatchId()
|
|
85
|
+
const durationRounded = Math.round(duration * 100) / 100
|
|
86
|
+
|
|
87
|
+
const tags: string[] = []
|
|
88
|
+
if (duration >= this.slowThreshold) tags.push('slow')
|
|
89
|
+
|
|
90
|
+
// Exclude devtools' own queries
|
|
91
|
+
if (sql.includes('_strav_devtools_')) return
|
|
92
|
+
|
|
93
|
+
const content: Record<string, unknown> = {
|
|
94
|
+
sql,
|
|
95
|
+
duration: durationRounded,
|
|
96
|
+
bindings: bindings.length > 0 ? bindings : undefined,
|
|
97
|
+
slow: duration >= this.slowThreshold,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const familyHash = this.hash(sql)
|
|
101
|
+
this.record('query', batchId, content, tags, familyHash)
|
|
102
|
+
|
|
103
|
+
// Emit for recorders (slow queries aggregation)
|
|
104
|
+
DevtoolsManager.emitQuery({ sql, duration: durationRounded })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type { Middleware, Next } from '@stravigor/core/http/middleware'
|
|
3
|
+
import Collector from './collector.ts'
|
|
4
|
+
import DevtoolsManager from '../devtools_manager.ts'
|
|
5
|
+
import type EntryStore from '../storage/entry_store.ts'
|
|
6
|
+
import type { CollectorOptions } from '../types.ts'
|
|
7
|
+
|
|
8
|
+
interface RequestCollectorOptions extends CollectorOptions {
|
|
9
|
+
sizeLimit?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Captures HTTP request/response data as devtools entries.
|
|
14
|
+
*
|
|
15
|
+
* Unlike other collectors that listen to Emitter events, this one is a
|
|
16
|
+
* **middleware** — it wraps the request lifecycle to capture timing, headers,
|
|
17
|
+
* body, and response status.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import { devtools } from '@stravigor/devtools'
|
|
21
|
+
* router.use(devtools.middleware())
|
|
22
|
+
*/
|
|
23
|
+
export default class RequestCollector extends Collector {
|
|
24
|
+
private sizeLimit: number
|
|
25
|
+
|
|
26
|
+
constructor(store: EntryStore, options: RequestCollectorOptions) {
|
|
27
|
+
super(store, options)
|
|
28
|
+
this.sizeLimit = (options.sizeLimit ?? 64) * 1024 // KB → bytes
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
register(): void {
|
|
32
|
+
// No-op — request collection uses middleware, not Emitter
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
teardown(): void {
|
|
36
|
+
// No-op
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns a middleware that records request and response data.
|
|
41
|
+
* The batchId is set on the context so other collectors (query, cache, etc.)
|
|
42
|
+
* can correlate their entries to this request.
|
|
43
|
+
*/
|
|
44
|
+
middleware(): Middleware {
|
|
45
|
+
const collector = this
|
|
46
|
+
|
|
47
|
+
return async (ctx: Context, next: Next): Promise<Response> => {
|
|
48
|
+
if (!collector.enabled) return next()
|
|
49
|
+
|
|
50
|
+
const batchId = crypto.randomUUID()
|
|
51
|
+
ctx.set('_devtools_batch_id', batchId)
|
|
52
|
+
DevtoolsManager.setBatchId(batchId)
|
|
53
|
+
|
|
54
|
+
const start = performance.now()
|
|
55
|
+
let response: Response
|
|
56
|
+
let error: Error | undefined
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
response = await next()
|
|
60
|
+
} catch (err) {
|
|
61
|
+
error = err instanceof Error ? err : new Error(String(err))
|
|
62
|
+
throw err
|
|
63
|
+
} finally {
|
|
64
|
+
const duration = performance.now() - start
|
|
65
|
+
|
|
66
|
+
const content: Record<string, unknown> = {
|
|
67
|
+
method: ctx.method,
|
|
68
|
+
path: ctx.path,
|
|
69
|
+
url: ctx.url.toString(),
|
|
70
|
+
status: error ? 500 : (response!?.status ?? 500),
|
|
71
|
+
duration: Math.round(duration * 100) / 100,
|
|
72
|
+
ip: ctx.header('x-forwarded-for') ?? ctx.header('x-real-ip') ?? 'unknown',
|
|
73
|
+
memory: Math.round((process.memoryUsage.rss() / 1024 / 1024) * 100) / 100,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Request headers (redact sensitive ones)
|
|
77
|
+
const requestHeaders: Record<string, string> = {}
|
|
78
|
+
ctx.headers.forEach((value, key) => {
|
|
79
|
+
if (key === 'authorization' || key === 'cookie') {
|
|
80
|
+
requestHeaders[key] = '********'
|
|
81
|
+
} else {
|
|
82
|
+
requestHeaders[key] = value
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
content.requestHeaders = requestHeaders
|
|
86
|
+
|
|
87
|
+
// Response headers
|
|
88
|
+
if (response!) {
|
|
89
|
+
const responseHeaders: Record<string, string> = {}
|
|
90
|
+
response.headers.forEach((value, key) => {
|
|
91
|
+
responseHeaders[key] = value
|
|
92
|
+
})
|
|
93
|
+
content.responseHeaders = responseHeaders
|
|
94
|
+
content.status = response.status
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (error) {
|
|
98
|
+
content.error = error.message
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tags: string[] = []
|
|
102
|
+
tags.push(`status:${content.status}`)
|
|
103
|
+
if (duration > 1000) tags.push('slow')
|
|
104
|
+
|
|
105
|
+
// Tag authenticated user if present
|
|
106
|
+
const user = ctx.get<{ id?: unknown }>('user')
|
|
107
|
+
if (user?.id) tags.push(`user:${user.id}`)
|
|
108
|
+
|
|
109
|
+
collector.record('request', batchId, content, tags)
|
|
110
|
+
|
|
111
|
+
// Emit for recorders (slow requests aggregation)
|
|
112
|
+
DevtoolsManager.emitRequest({
|
|
113
|
+
path: ctx.path,
|
|
114
|
+
method: ctx.method,
|
|
115
|
+
duration: Math.round(duration * 100) / 100,
|
|
116
|
+
status: (content.status as number) ?? 500,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Flush immediately — one request = one flush
|
|
120
|
+
collector.flush()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return response!
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { bootstrap, shutdown } from '@stravigor/core/cli/bootstrap'
|
|
4
|
+
import DevtoolsManager from '../devtools_manager.ts'
|
|
5
|
+
|
|
6
|
+
export function register(program: Command): void {
|
|
7
|
+
program
|
|
8
|
+
.command('devtools:prune')
|
|
9
|
+
.description('Prune old devtools entries and aggregates')
|
|
10
|
+
.option('--hours <hours>', 'Delete entries older than this many hours', '24')
|
|
11
|
+
.action(async (options: { hours: string }) => {
|
|
12
|
+
let db
|
|
13
|
+
try {
|
|
14
|
+
const { db: database, config } = await bootstrap()
|
|
15
|
+
db = database
|
|
16
|
+
|
|
17
|
+
new DevtoolsManager(db, config)
|
|
18
|
+
|
|
19
|
+
const hours = parseInt(options.hours, 10)
|
|
20
|
+
console.log(chalk.dim(`Pruning entries older than ${hours} hours...`))
|
|
21
|
+
|
|
22
|
+
const entries = await DevtoolsManager.entryStore.prune(hours)
|
|
23
|
+
const aggregates = await DevtoolsManager.aggregateStore.prune(hours)
|
|
24
|
+
|
|
25
|
+
console.log(chalk.green(`Pruned ${entries} entries and ${aggregates} aggregates.`))
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
28
|
+
process.exit(1)
|
|
29
|
+
} finally {
|
|
30
|
+
if (db) await shutdown(db)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('devtools:setup')
|
|
36
|
+
.description('Create the devtools storage tables')
|
|
37
|
+
.action(async () => {
|
|
38
|
+
let db
|
|
39
|
+
try {
|
|
40
|
+
const { db: database, config } = await bootstrap()
|
|
41
|
+
db = database
|
|
42
|
+
|
|
43
|
+
new DevtoolsManager(db, config)
|
|
44
|
+
|
|
45
|
+
console.log(chalk.dim('Creating devtools tables...'))
|
|
46
|
+
await DevtoolsManager.ensureTables()
|
|
47
|
+
console.log(chalk.green('Devtools tables created successfully.'))
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
50
|
+
process.exit(1)
|
|
51
|
+
} finally {
|
|
52
|
+
if (db) await shutdown(db)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type { Middleware, Next } from '@stravigor/core/http/middleware'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authorization gate for the devtools dashboard.
|
|
6
|
+
*
|
|
7
|
+
* By default, only allows access in the 'local' environment.
|
|
8
|
+
* Pass a custom guard function for production access control.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { dashboardAuth } from '@stravigor/devtools/dashboard/middleware'
|
|
12
|
+
*
|
|
13
|
+
* // Default: local environment only
|
|
14
|
+
* router.group({ prefix: '/_devtools', middleware: [dashboardAuth()] }, ...)
|
|
15
|
+
*
|
|
16
|
+
* // Custom guard
|
|
17
|
+
* router.group({
|
|
18
|
+
* prefix: '/_devtools',
|
|
19
|
+
* middleware: [dashboardAuth((ctx) => {
|
|
20
|
+
* const user = ctx.get('user')
|
|
21
|
+
* return user?.isAdmin === true
|
|
22
|
+
* })]
|
|
23
|
+
* }, ...)
|
|
24
|
+
*/
|
|
25
|
+
export function dashboardAuth(guard?: (ctx: Context) => boolean | Promise<boolean>): Middleware {
|
|
26
|
+
return async (ctx: Context, next: Next): Promise<Response> => {
|
|
27
|
+
if (guard) {
|
|
28
|
+
const allowed = await guard(ctx)
|
|
29
|
+
if (!allowed) {
|
|
30
|
+
return ctx.json({ error: 'Unauthorized' }, 403)
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
// Default: only allow in local/development environment
|
|
34
|
+
const env = process.env.NODE_ENV ?? process.env.APP_ENV ?? 'production'
|
|
35
|
+
if (env !== 'local' && env !== 'development') {
|
|
36
|
+
return ctx.json({ error: 'Unauthorized' }, 403)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return next()
|
|
41
|
+
}
|
|
42
|
+
}
|