@strav/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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @stravigor/devtools
2
+
3
+ Debug inspector and performance monitor for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Request inspector, SQL query profiler, exception tracker, log viewer, and APM dashboard.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add -d @stravigor/devtools
9
+ bun strav install devtools
10
+ ```
11
+
12
+ Requires `@stravigor/core` as a peer dependency.
13
+
14
+ ## Setup
15
+
16
+ ```ts
17
+ import { DevtoolsProvider } from '@stravigor/devtools'
18
+
19
+ app.use(new DevtoolsProvider())
20
+ ```
21
+
22
+ The provider auto-registers the middleware and serves the dashboard at `/_devtools`.
23
+
24
+ ## Collectors
25
+
26
+ Collectors capture data from your application:
27
+
28
+ - **RequestCollector** — HTTP requests and responses
29
+ - **QueryCollector** — SQL queries with timing
30
+ - **ExceptionCollector** — Unhandled exceptions
31
+ - **LogCollector** — Log entries
32
+ - **JobCollector** — Queue job execution
33
+
34
+ ## Recorders
35
+
36
+ Recorders aggregate data for performance monitoring:
37
+
38
+ - **SlowRequestsRecorder** — Tracks slow HTTP requests
39
+ - **SlowQueriesRecorder** — Tracks slow SQL queries
40
+
41
+ ## Usage
42
+
43
+ ```ts
44
+ import { devtools } from '@stravigor/devtools'
45
+
46
+ // Access collector data programmatically
47
+ const entries = await devtools.entries({ type: 'request', limit: 50 })
48
+ const aggregates = await devtools.aggregates('slow-requests', '1h')
49
+ ```
50
+
51
+ ## CLI
52
+
53
+ ```bash
54
+ bun strav devtools:setup # Create the devtools tables
55
+ bun strav devtools:prune # Clean up old entries
56
+ ```
57
+
58
+ ## Documentation
59
+
60
+ See the full [Devtools guide](../../guides/devtools.md).
61
+
62
+ ## License
63
+
64
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@strav/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": [
15
+ "src/",
16
+ "stubs/",
17
+ "package.json",
18
+ "tsconfig.json"
19
+ ],
20
+ "peerDependencies": {
21
+ "@strav/kernel": "0.1.0",
22
+ "@strav/http": "0.1.0",
23
+ "@strav/database": "0.1.0",
24
+ "@strav/cli": "0.1.0"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test tests/",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "devDependencies": {
31
+ "commander": "^14.0.3"
32
+ }
33
+ }
@@ -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/kernel'
2
+ import type { Listener } from '@stravigor/kernel'
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,119 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Listener } from '@stravigor/kernel'
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
+ private getBatchId: () => string
20
+
21
+ constructor(store: EntryStore, options: CollectorOptions, getBatchId: () => string) {
22
+ super(store, options)
23
+ this.getBatchId = getBatchId
24
+ }
25
+
26
+ register(): void {
27
+ if (!this.enabled) return
28
+
29
+ this.dispatchedListener = (payload: {
30
+ id: number
31
+ name: string
32
+ queue: string
33
+ payload: unknown
34
+ }) => {
35
+ const batchId = this.getBatchId()
36
+
37
+ this.record(
38
+ 'job',
39
+ batchId,
40
+ {
41
+ status: 'dispatched',
42
+ jobId: payload.id,
43
+ name: payload.name,
44
+ queue: payload.queue,
45
+ payload: payload.payload,
46
+ },
47
+ [payload.name, 'dispatched']
48
+ )
49
+ this.flush()
50
+ }
51
+
52
+ this.processedListener = (payload: {
53
+ job: string
54
+ id: number
55
+ queue: string
56
+ duration: number
57
+ }) => {
58
+ const batchId = this.getBatchId()
59
+
60
+ this.record(
61
+ 'job',
62
+ batchId,
63
+ {
64
+ status: 'processed',
65
+ jobId: payload.id,
66
+ name: payload.job,
67
+ queue: payload.queue,
68
+ duration: Math.round(payload.duration * 100) / 100,
69
+ },
70
+ [payload.job, 'processed']
71
+ )
72
+ this.flush()
73
+ }
74
+
75
+ this.failedListener = (payload: {
76
+ job: string
77
+ id: number
78
+ queue: string
79
+ error: string
80
+ duration: number
81
+ }) => {
82
+ const batchId = this.getBatchId()
83
+
84
+ this.record(
85
+ 'job',
86
+ batchId,
87
+ {
88
+ status: 'failed',
89
+ jobId: payload.id,
90
+ name: payload.job,
91
+ queue: payload.queue,
92
+ error: payload.error,
93
+ duration: Math.round(payload.duration * 100) / 100,
94
+ },
95
+ [payload.job, 'failed']
96
+ )
97
+ this.flush()
98
+ }
99
+
100
+ Emitter.on('queue:dispatched', this.dispatchedListener)
101
+ Emitter.on('queue:processed', this.processedListener)
102
+ Emitter.on('queue:failed', this.failedListener)
103
+ }
104
+
105
+ teardown(): void {
106
+ if (this.dispatchedListener) {
107
+ Emitter.off('queue:dispatched', this.dispatchedListener)
108
+ this.dispatchedListener = null
109
+ }
110
+ if (this.processedListener) {
111
+ Emitter.off('queue:processed', this.processedListener)
112
+ this.processedListener = null
113
+ }
114
+ if (this.failedListener) {
115
+ Emitter.off('queue:failed', this.failedListener)
116
+ this.failedListener = null
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,69 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Listener } from '@stravigor/kernel'
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,125 @@
1
+ import type { Context, Middleware, Next } from '@stravigor/http'
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 RequestCollectorOptions extends CollectorOptions {
8
+ sizeLimit?: number
9
+ }
10
+
11
+ /**
12
+ * Captures HTTP request/response data as devtools entries.
13
+ *
14
+ * Unlike other collectors that listen to Emitter events, this one is a
15
+ * **middleware** — it wraps the request lifecycle to capture timing, headers,
16
+ * body, and response status.
17
+ *
18
+ * @example
19
+ * import { devtools } from '@stravigor/devtools'
20
+ * router.use(devtools.middleware())
21
+ */
22
+ export default class RequestCollector extends Collector {
23
+ private sizeLimit: number
24
+
25
+ constructor(store: EntryStore, options: RequestCollectorOptions) {
26
+ super(store, options)
27
+ this.sizeLimit = (options.sizeLimit ?? 64) * 1024 // KB → bytes
28
+ }
29
+
30
+ register(): void {
31
+ // No-op — request collection uses middleware, not Emitter
32
+ }
33
+
34
+ teardown(): void {
35
+ // No-op
36
+ }
37
+
38
+ /**
39
+ * Returns a middleware that records request and response data.
40
+ * The batchId is set on the context so other collectors (query, cache, etc.)
41
+ * can correlate their entries to this request.
42
+ */
43
+ middleware(): Middleware {
44
+ const collector = this
45
+
46
+ return async (ctx: Context, next: Next): Promise<Response> => {
47
+ if (!collector.enabled) return next()
48
+
49
+ const batchId = crypto.randomUUID()
50
+ ctx.set('_devtools_batch_id', batchId)
51
+ DevtoolsManager.setBatchId(batchId)
52
+
53
+ const start = performance.now()
54
+ let response: Response
55
+ let error: Error | undefined
56
+
57
+ try {
58
+ response = await next()
59
+ } catch (err) {
60
+ error = err instanceof Error ? err : new Error(String(err))
61
+ throw err
62
+ } finally {
63
+ const duration = performance.now() - start
64
+
65
+ const content: Record<string, unknown> = {
66
+ method: ctx.method,
67
+ path: ctx.path,
68
+ url: ctx.url.toString(),
69
+ status: error ? 500 : (response!?.status ?? 500),
70
+ duration: Math.round(duration * 100) / 100,
71
+ ip: ctx.header('x-forwarded-for') ?? ctx.header('x-real-ip') ?? 'unknown',
72
+ memory: Math.round((process.memoryUsage.rss() / 1024 / 1024) * 100) / 100,
73
+ }
74
+
75
+ // Request headers (redact sensitive ones)
76
+ const requestHeaders: Record<string, string> = {}
77
+ ctx.headers.forEach((value, key) => {
78
+ if (key === 'authorization' || key === 'cookie') {
79
+ requestHeaders[key] = '********'
80
+ } else {
81
+ requestHeaders[key] = value
82
+ }
83
+ })
84
+ content.requestHeaders = requestHeaders
85
+
86
+ // Response headers
87
+ if (response!) {
88
+ const responseHeaders: Record<string, string> = {}
89
+ response.headers.forEach((value, key) => {
90
+ responseHeaders[key] = value
91
+ })
92
+ content.responseHeaders = responseHeaders
93
+ content.status = response.status
94
+ }
95
+
96
+ if (error) {
97
+ content.error = error.message
98
+ }
99
+
100
+ const tags: string[] = []
101
+ tags.push(`status:${content.status}`)
102
+ if (duration > 1000) tags.push('slow')
103
+
104
+ // Tag authenticated user if present
105
+ const user = ctx.get<{ id?: unknown }>('user')
106
+ if (user?.id) tags.push(`user:${user.id}`)
107
+
108
+ collector.record('request', batchId, content, tags)
109
+
110
+ // Emit for recorders (slow requests aggregation)
111
+ DevtoolsManager.emitRequest({
112
+ path: ctx.path,
113
+ method: ctx.method,
114
+ duration: Math.round(duration * 100) / 100,
115
+ status: (content.status as number) ?? 500,
116
+ })
117
+
118
+ // Flush immediately — one request = one flush
119
+ collector.flush()
120
+ }
121
+
122
+ return response!
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,55 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '@stravigor/cli'
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
+ }