@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.
@@ -0,0 +1,242 @@
1
+ import { inject, Configuration, Emitter, ConfigurationError } from '@stravigor/kernel'
2
+ import { Database } from '@stravigor/database'
3
+ import type { DevtoolsConfig, CollectorOptions } from './types.ts'
4
+
5
+ import EntryStore from './storage/entry_store.ts'
6
+ import AggregateStore from './storage/aggregate_store.ts'
7
+
8
+ import type Collector from './collectors/collector.ts'
9
+ import RequestCollector from './collectors/request_collector.ts'
10
+ import QueryCollector from './collectors/query_collector.ts'
11
+ import ExceptionCollector from './collectors/exception_collector.ts'
12
+ import LogCollector from './collectors/log_collector.ts'
13
+ import JobCollector from './collectors/job_collector.ts'
14
+
15
+ import type Recorder from './recorders/recorder.ts'
16
+ import SlowRequestsRecorder from './recorders/slow_requests.ts'
17
+ import SlowQueriesRecorder from './recorders/slow_queries.ts'
18
+
19
+ /**
20
+ * Central DI hub for the devtools package.
21
+ *
22
+ * Resolved once via the DI container — reads devtools config, creates
23
+ * storage instances, boots collectors and recorders, and exposes the
24
+ * middleware and dashboard APIs.
25
+ *
26
+ * @example
27
+ * app.singleton(DevtoolsManager)
28
+ * app.resolve(DevtoolsManager)
29
+ *
30
+ * // Use the request-tracking middleware
31
+ * router.use(DevtoolsManager.middleware())
32
+ */
33
+ @inject
34
+ export default class DevtoolsManager {
35
+ private static _config: DevtoolsConfig
36
+ private static _entryStore: EntryStore
37
+ private static _aggregateStore: AggregateStore
38
+ private static _collectors: Collector[] = []
39
+ private static _recorders: Recorder[] = []
40
+ private static _requestCollector: RequestCollector
41
+ private static _queryCollector: QueryCollector
42
+ private static _currentBatchId: string = crypto.randomUUID()
43
+ private static _booted = false
44
+
45
+ constructor(db: Database, config: Configuration) {
46
+ if (DevtoolsManager._booted) return
47
+
48
+ DevtoolsManager._config = {
49
+ enabled: config.get('devtools.enabled', true) as boolean,
50
+ storage: {
51
+ pruneAfter: config.get('devtools.storage.pruneAfter', 24) as number,
52
+ },
53
+ collectors: {
54
+ request: config.get('devtools.collectors.request', {
55
+ enabled: true,
56
+ sizeLimit: 64,
57
+ }) as CollectorOptions & { sizeLimit: number },
58
+ query: config.get('devtools.collectors.query', {
59
+ enabled: true,
60
+ slow: 100,
61
+ }) as CollectorOptions & { slow: number },
62
+ exception: config.get('devtools.collectors.exception', {
63
+ enabled: true,
64
+ }) as CollectorOptions,
65
+ log: config.get('devtools.collectors.log', {
66
+ enabled: true,
67
+ level: 'debug',
68
+ }) as CollectorOptions & { level: string },
69
+ job: config.get('devtools.collectors.job', { enabled: true }) as CollectorOptions,
70
+ },
71
+ recorders: {
72
+ slowRequests: config.get('devtools.recorders.slowRequests', {
73
+ enabled: true,
74
+ threshold: 1000,
75
+ sampleRate: 1.0,
76
+ }) as any,
77
+ slowQueries: config.get('devtools.recorders.slowQueries', {
78
+ enabled: true,
79
+ threshold: 1000,
80
+ sampleRate: 1.0,
81
+ }) as any,
82
+ },
83
+ }
84
+
85
+ if (!DevtoolsManager._config.enabled) return
86
+
87
+ // Initialize storage (use the app's DB connection)
88
+ const sql = db.sql
89
+ DevtoolsManager._entryStore = new EntryStore(sql)
90
+ DevtoolsManager._aggregateStore = new AggregateStore(sql)
91
+
92
+ // Boot collectors
93
+ const getBatchId = () => DevtoolsManager._currentBatchId
94
+
95
+ DevtoolsManager._requestCollector = new RequestCollector(
96
+ DevtoolsManager._entryStore,
97
+ DevtoolsManager._config.collectors.request
98
+ )
99
+
100
+ DevtoolsManager._queryCollector = new QueryCollector(
101
+ DevtoolsManager._entryStore,
102
+ DevtoolsManager._config.collectors.query as any,
103
+ getBatchId
104
+ )
105
+
106
+ const exceptionCollector = new ExceptionCollector(
107
+ DevtoolsManager._entryStore,
108
+ DevtoolsManager._config.collectors.exception,
109
+ getBatchId
110
+ )
111
+
112
+ const logCollector = new LogCollector(
113
+ DevtoolsManager._entryStore,
114
+ DevtoolsManager._config.collectors.log as any,
115
+ getBatchId
116
+ )
117
+
118
+ const jobCollector = new JobCollector(
119
+ DevtoolsManager._entryStore,
120
+ DevtoolsManager._config.collectors.job,
121
+ getBatchId
122
+ )
123
+
124
+ DevtoolsManager._collectors = [
125
+ DevtoolsManager._requestCollector,
126
+ DevtoolsManager._queryCollector,
127
+ exceptionCollector,
128
+ logCollector,
129
+ jobCollector,
130
+ ]
131
+
132
+ // Boot recorders
133
+ const slowRequests = new SlowRequestsRecorder(
134
+ DevtoolsManager._aggregateStore,
135
+ DevtoolsManager._config.recorders.slowRequests
136
+ )
137
+
138
+ const slowQueries = new SlowQueriesRecorder(
139
+ DevtoolsManager._aggregateStore,
140
+ DevtoolsManager._config.recorders.slowQueries
141
+ )
142
+
143
+ DevtoolsManager._recorders = [slowRequests, slowQueries]
144
+
145
+ // Register all listeners
146
+ for (const collector of DevtoolsManager._collectors) {
147
+ collector.register()
148
+ }
149
+ for (const recorder of DevtoolsManager._recorders) {
150
+ recorder.register()
151
+ }
152
+
153
+ // Install the SQL query proxy
154
+ const proxied = DevtoolsManager._queryCollector.installProxy(sql)
155
+
156
+ // Replace the connection on the Database class
157
+ // We use Object.defineProperty because Database._connection is private
158
+ // but we need to swap it with our proxied version
159
+ ;(db as any).connection = proxied
160
+ ;(db.constructor as any)._connection = proxied
161
+
162
+ DevtoolsManager._booted = true
163
+ }
164
+
165
+ static get config(): DevtoolsConfig {
166
+ if (!DevtoolsManager._config) {
167
+ throw new ConfigurationError(
168
+ 'DevtoolsManager not configured. Resolve it through the container first.'
169
+ )
170
+ }
171
+ return DevtoolsManager._config
172
+ }
173
+
174
+ static get entryStore(): EntryStore {
175
+ return DevtoolsManager._entryStore
176
+ }
177
+
178
+ static get aggregateStore(): AggregateStore {
179
+ return DevtoolsManager._aggregateStore
180
+ }
181
+
182
+ /** Returns the request-tracking middleware. */
183
+ static middleware() {
184
+ return DevtoolsManager._requestCollector.middleware()
185
+ }
186
+
187
+ /** Set the current batch ID (called by the request middleware). */
188
+ static setBatchId(id: string): void {
189
+ DevtoolsManager._currentBatchId = id
190
+ }
191
+
192
+ /** Get the current batch ID. */
193
+ static get batchId(): string {
194
+ return DevtoolsManager._currentBatchId
195
+ }
196
+
197
+ /** Create the storage tables. Called during setup or first boot. */
198
+ static async ensureTables(): Promise<void> {
199
+ await DevtoolsManager._entryStore.ensureTable()
200
+ await DevtoolsManager._aggregateStore.ensureTable()
201
+ }
202
+
203
+ /** Emit internal events for recorders. Called by the request collector. */
204
+ static emitRequest(data: {
205
+ path: string
206
+ method: string
207
+ duration: number
208
+ status: number
209
+ }): void {
210
+ if (Emitter.listenerCount('devtools:request') > 0) {
211
+ Emitter.emit('devtools:request', data).catch(() => {})
212
+ }
213
+ }
214
+
215
+ /** Emit internal events for recorders. Called by the query collector. */
216
+ static emitQuery(data: { sql: string; duration: number }): void {
217
+ if (Emitter.listenerCount('devtools:query') > 0) {
218
+ Emitter.emit('devtools:query', data).catch(() => {})
219
+ }
220
+ }
221
+
222
+ /** Tear down all collectors and recorders. */
223
+ static teardown(): void {
224
+ for (const collector of DevtoolsManager._collectors) {
225
+ collector.teardown()
226
+ }
227
+ for (const recorder of DevtoolsManager._recorders) {
228
+ recorder.teardown()
229
+ }
230
+ DevtoolsManager._collectors = []
231
+ DevtoolsManager._recorders = []
232
+ DevtoolsManager._booted = false
233
+ }
234
+
235
+ /** Reset all static state. For testing only. */
236
+ static reset(): void {
237
+ DevtoolsManager.teardown()
238
+ DevtoolsManager._config = undefined as any
239
+ DevtoolsManager._entryStore = undefined as any
240
+ DevtoolsManager._aggregateStore = undefined as any
241
+ }
242
+ }
@@ -0,0 +1,54 @@
1
+ import { ServiceProvider } from '@stravigor/kernel'
2
+ import type { Application } from '@stravigor/kernel'
3
+ import type { Context } from '@stravigor/http'
4
+ import { Router } from '@stravigor/http'
5
+ import DevtoolsManager from './devtools_manager.ts'
6
+ import { registerDashboard } from './dashboard/routes.ts'
7
+
8
+ export interface DevtoolsProviderOptions {
9
+ /** Auto-create the devtools tables. Default: `true` */
10
+ ensureTables?: boolean
11
+ /** Auto-register the request-tracking middleware on the router. Default: `true` */
12
+ middleware?: boolean
13
+ /** Auto-register the dashboard routes at `/_devtools`. Default: `true` */
14
+ dashboard?: boolean
15
+ /** Custom auth guard for the dashboard. Receives the request context, returns boolean. */
16
+ guard?: (ctx: Context) => boolean | Promise<boolean>
17
+ }
18
+
19
+ export default class DevtoolsProvider extends ServiceProvider {
20
+ readonly name = 'devtools'
21
+ override readonly dependencies = ['database']
22
+
23
+ constructor(private options?: DevtoolsProviderOptions) {
24
+ super()
25
+ }
26
+
27
+ override register(app: Application): void {
28
+ app.singleton(DevtoolsManager)
29
+ }
30
+
31
+ override async boot(app: Application): Promise<void> {
32
+ app.resolve(DevtoolsManager)
33
+
34
+ if (this.options?.ensureTables !== false) {
35
+ await DevtoolsManager.ensureTables()
36
+ }
37
+
38
+ if (!DevtoolsManager.config.enabled) return
39
+
40
+ const router = app.resolve(Router)
41
+
42
+ if (this.options?.middleware !== false) {
43
+ router.use(DevtoolsManager.middleware())
44
+ }
45
+
46
+ if (this.options?.dashboard !== false) {
47
+ registerDashboard(router, this.options?.guard)
48
+ }
49
+ }
50
+
51
+ override shutdown(): void {
52
+ DevtoolsManager.teardown()
53
+ }
54
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { ConfigurationError } from '@stravigor/kernel'
2
+
3
+ export class DevtoolsError extends ConfigurationError {}
package/src/helpers.ts ADDED
@@ -0,0 +1,82 @@
1
+ import DevtoolsManager from './devtools_manager.ts'
2
+ import type { Middleware } from '@stravigor/http'
3
+ import type { EntryRecord, AggregateRecord, EntryType, AggregateFunction } from './types.ts'
4
+
5
+ /**
6
+ * Devtools helper — the primary convenience API.
7
+ *
8
+ * @example
9
+ * import { devtools } from '@stravigor/devtools'
10
+ *
11
+ * // Add the middleware to capture requests
12
+ * router.use(devtools.middleware())
13
+ *
14
+ * // Query entries
15
+ * const requests = await devtools.entries('request', 50)
16
+ * const entry = await devtools.find(uuid)
17
+ * const related = await devtools.batch(batchId)
18
+ */
19
+ export const devtools = {
20
+ /** Returns the request-tracking middleware. */
21
+ middleware(): Middleware {
22
+ return DevtoolsManager.middleware()
23
+ },
24
+
25
+ /** Create storage tables (idempotent). */
26
+ async ensureTables(): Promise<void> {
27
+ return DevtoolsManager.ensureTables()
28
+ },
29
+
30
+ /** List entries, optionally filtered by type. */
31
+ async entries(type?: EntryType, limit = 50, offset = 0): Promise<EntryRecord[]> {
32
+ return DevtoolsManager.entryStore.list(type, limit, offset)
33
+ },
34
+
35
+ /** Find a single entry by UUID. */
36
+ async find(uuid: string): Promise<EntryRecord | null> {
37
+ return DevtoolsManager.entryStore.find(uuid)
38
+ },
39
+
40
+ /** Find all entries belonging to a batch. */
41
+ async batch(batchId: string): Promise<EntryRecord[]> {
42
+ return DevtoolsManager.entryStore.batch(batchId)
43
+ },
44
+
45
+ /** Search entries by tag. */
46
+ async byTag(tag: string, limit = 50): Promise<EntryRecord[]> {
47
+ return DevtoolsManager.entryStore.byTag(tag, limit)
48
+ },
49
+
50
+ /** Query aggregated metrics. */
51
+ async aggregates(
52
+ type: string,
53
+ period: number,
54
+ aggregate: AggregateFunction,
55
+ limit = 24
56
+ ): Promise<AggregateRecord[]> {
57
+ return DevtoolsManager.aggregateStore.query(type, period, aggregate, limit)
58
+ },
59
+
60
+ /** Top keys by aggregated value. */
61
+ async topKeys(
62
+ type: string,
63
+ period: number,
64
+ aggregate: AggregateFunction,
65
+ limit = 10
66
+ ): Promise<AggregateRecord[]> {
67
+ return DevtoolsManager.aggregateStore.topKeys(type, period, aggregate, limit)
68
+ },
69
+
70
+ /** Prune old entries and aggregates. */
71
+ async prune(hours?: number): Promise<{ entries: number; aggregates: number }> {
72
+ const h = hours ?? DevtoolsManager.config.storage.pruneAfter
73
+ const entries = await DevtoolsManager.entryStore.prune(h)
74
+ const aggregates = await DevtoolsManager.aggregateStore.prune(h)
75
+ return { entries, aggregates }
76
+ },
77
+
78
+ /** Count entries, optionally by type. */
79
+ async count(type?: EntryType): Promise<number> {
80
+ return DevtoolsManager.entryStore.count(type)
81
+ },
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Manager
2
+ export { default, default as DevtoolsManager } from './devtools_manager.ts'
3
+
4
+ // Provider
5
+ export { default as DevtoolsProvider } from './devtools_provider.ts'
6
+ export type { DevtoolsProviderOptions } from './devtools_provider.ts'
7
+
8
+ // Helper
9
+ export { devtools } from './helpers.ts'
10
+
11
+ // Storage
12
+ export { default as EntryStore } from './storage/entry_store.ts'
13
+ export { default as AggregateStore, PERIODS } from './storage/aggregate_store.ts'
14
+
15
+ // Collectors
16
+ export { default as Collector } from './collectors/collector.ts'
17
+ export { default as RequestCollector } from './collectors/request_collector.ts'
18
+ export { default as QueryCollector } from './collectors/query_collector.ts'
19
+ export { default as ExceptionCollector } from './collectors/exception_collector.ts'
20
+ export { default as LogCollector } from './collectors/log_collector.ts'
21
+ export { default as JobCollector } from './collectors/job_collector.ts'
22
+
23
+ // Recorders
24
+ export { default as Recorder } from './recorders/recorder.ts'
25
+ export { default as SlowRequestsRecorder } from './recorders/slow_requests.ts'
26
+ export { default as SlowQueriesRecorder } from './recorders/slow_queries.ts'
27
+
28
+ // Dashboard
29
+ export { registerDashboard } from './dashboard/routes.ts'
30
+ export { dashboardAuth } from './dashboard/middleware.ts'
31
+
32
+ // Errors
33
+ export { DevtoolsError } from './errors.ts'
34
+
35
+ // Types
36
+ export type {
37
+ DevtoolsEntry,
38
+ EntryRecord,
39
+ AggregateRecord,
40
+ DevtoolsConfig,
41
+ EntryType,
42
+ AggregateFunction,
43
+ CollectorOptions,
44
+ RecorderOptions,
45
+ } from './types.ts'
@@ -0,0 +1,51 @@
1
+ import type AggregateStore from '../storage/aggregate_store.ts'
2
+ import type { AggregateFunction, RecorderOptions } from '../types.ts'
3
+
4
+ /**
5
+ * Base class for all recorders (the Pulse-like component).
6
+ *
7
+ * A recorder listens to the same framework events as collectors but
8
+ * produces aggregated metrics instead of individual entries.
9
+ */
10
+ export default abstract class Recorder {
11
+ protected enabled: boolean
12
+ protected threshold: number
13
+ protected sampleRate: number
14
+
15
+ constructor(
16
+ protected store: AggregateStore,
17
+ protected options: RecorderOptions
18
+ ) {
19
+ this.enabled = options.enabled !== false
20
+ this.threshold = options.threshold ?? 1000
21
+ this.sampleRate = options.sampleRate ?? 1.0
22
+ }
23
+
24
+ /** Register event listeners. Called once during DevtoolsManager boot. */
25
+ abstract register(): void
26
+
27
+ /** Remove event listeners. Called during teardown. */
28
+ abstract teardown(): void
29
+
30
+ /** Check if this event should be sampled (for high-traffic apps). */
31
+ protected shouldSample(): boolean {
32
+ if (this.sampleRate >= 1.0) return true
33
+ return Math.random() < this.sampleRate
34
+ }
35
+
36
+ /** Record a metric value into the aggregate store. */
37
+ protected async aggregate(
38
+ type: string,
39
+ key: string,
40
+ value: number,
41
+ aggregates: AggregateFunction[] = ['count', 'max', 'avg']
42
+ ): Promise<void> {
43
+ if (!this.enabled || !this.shouldSample()) return
44
+
45
+ try {
46
+ await this.store.record(type, key, value, aggregates)
47
+ } catch {
48
+ // Recorders must never crash the app
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,44 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Listener } from '@stravigor/kernel'
3
+ import Recorder from './recorder.ts'
4
+ import type AggregateStore from '../storage/aggregate_store.ts'
5
+ import type { RecorderOptions } from '../types.ts'
6
+
7
+ /**
8
+ * Records aggregated metrics for slow database queries.
9
+ *
10
+ * Listens to the `devtools:query` event emitted by the QueryCollector
11
+ * after each query. Only records queries that exceed the configured threshold.
12
+ */
13
+ export default class SlowQueriesRecorder extends Recorder {
14
+ private listener: Listener | null = null
15
+
16
+ constructor(store: AggregateStore, options: RecorderOptions) {
17
+ super(store, options)
18
+ }
19
+
20
+ register(): void {
21
+ if (!this.enabled) return
22
+
23
+ this.listener = (payload: { sql: string; duration: number }) => {
24
+ if (payload.duration < this.threshold) return
25
+
26
+ // Normalize SQL for grouping (strip specific values)
27
+ const normalized = payload.sql
28
+ .replace(/\$\d+/g, '$?')
29
+ .replace(/'[^']*'/g, "'?'")
30
+ .slice(0, 200)
31
+
32
+ this.aggregate('slow_query', normalized, payload.duration)
33
+ }
34
+
35
+ Emitter.on('devtools:query', this.listener)
36
+ }
37
+
38
+ teardown(): void {
39
+ if (this.listener) {
40
+ Emitter.off('devtools:query', this.listener)
41
+ this.listener = null
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,44 @@
1
+ import { Emitter } from '@stravigor/kernel'
2
+ import type { Listener } from '@stravigor/kernel'
3
+ import Recorder from './recorder.ts'
4
+ import type AggregateStore from '../storage/aggregate_store.ts'
5
+ import type { RecorderOptions } from '../types.ts'
6
+
7
+ /**
8
+ * Records aggregated metrics for slow HTTP requests.
9
+ *
10
+ * Listens to the `devtools:request` event emitted by the RequestCollector
11
+ * after each request. Only records requests that exceed the configured threshold.
12
+ */
13
+ export default class SlowRequestsRecorder extends Recorder {
14
+ private listener: Listener | null = null
15
+
16
+ constructor(store: AggregateStore, options: RecorderOptions) {
17
+ super(store, options)
18
+ }
19
+
20
+ register(): void {
21
+ if (!this.enabled) return
22
+
23
+ this.listener = (payload: {
24
+ path: string
25
+ method: string
26
+ duration: number
27
+ status: number
28
+ }) => {
29
+ if (payload.duration < this.threshold) return
30
+
31
+ const key = `${payload.method} ${payload.path}`
32
+ this.aggregate('slow_request', key, payload.duration)
33
+ }
34
+
35
+ Emitter.on('devtools:request', this.listener)
36
+ }
37
+
38
+ teardown(): void {
39
+ if (this.listener) {
40
+ Emitter.off('devtools:request', this.listener)
41
+ this.listener = null
42
+ }
43
+ }
44
+ }