@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 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
+ }