@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.
@@ -0,0 +1,244 @@
1
+ import { inject } from '@stravigor/core/core'
2
+ import type Configuration from '@stravigor/core/config/configuration'
3
+ import type Database from '@stravigor/core/database/database'
4
+ import Emitter from '@stravigor/core/events/emitter'
5
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
6
+ import type { DevtoolsConfig, CollectorOptions } from './types.ts'
7
+
8
+ import EntryStore from './storage/entry_store.ts'
9
+ import AggregateStore from './storage/aggregate_store.ts'
10
+
11
+ import type Collector from './collectors/collector.ts'
12
+ import RequestCollector from './collectors/request_collector.ts'
13
+ import QueryCollector from './collectors/query_collector.ts'
14
+ import ExceptionCollector from './collectors/exception_collector.ts'
15
+ import LogCollector from './collectors/log_collector.ts'
16
+ import JobCollector from './collectors/job_collector.ts'
17
+
18
+ import type Recorder from './recorders/recorder.ts'
19
+ import SlowRequestsRecorder from './recorders/slow_requests.ts'
20
+ import SlowQueriesRecorder from './recorders/slow_queries.ts'
21
+
22
+ /**
23
+ * Central DI hub for the devtools package.
24
+ *
25
+ * Resolved once via the DI container — reads devtools config, creates
26
+ * storage instances, boots collectors and recorders, and exposes the
27
+ * middleware and dashboard APIs.
28
+ *
29
+ * @example
30
+ * app.singleton(DevtoolsManager)
31
+ * app.resolve(DevtoolsManager)
32
+ *
33
+ * // Use the request-tracking middleware
34
+ * router.use(DevtoolsManager.middleware())
35
+ */
36
+ @inject
37
+ export default class DevtoolsManager {
38
+ private static _config: DevtoolsConfig
39
+ private static _entryStore: EntryStore
40
+ private static _aggregateStore: AggregateStore
41
+ private static _collectors: Collector[] = []
42
+ private static _recorders: Recorder[] = []
43
+ private static _requestCollector: RequestCollector
44
+ private static _queryCollector: QueryCollector
45
+ private static _currentBatchId: string = crypto.randomUUID()
46
+ private static _booted = false
47
+
48
+ constructor(db: Database, config: Configuration) {
49
+ if (DevtoolsManager._booted) return
50
+
51
+ DevtoolsManager._config = {
52
+ enabled: config.get('devtools.enabled', true) as boolean,
53
+ storage: {
54
+ pruneAfter: config.get('devtools.storage.pruneAfter', 24) as number,
55
+ },
56
+ collectors: {
57
+ request: config.get('devtools.collectors.request', {
58
+ enabled: true,
59
+ sizeLimit: 64,
60
+ }) as CollectorOptions & { sizeLimit: number },
61
+ query: config.get('devtools.collectors.query', {
62
+ enabled: true,
63
+ slow: 100,
64
+ }) as CollectorOptions & { slow: number },
65
+ exception: config.get('devtools.collectors.exception', {
66
+ enabled: true,
67
+ }) as CollectorOptions,
68
+ log: config.get('devtools.collectors.log', {
69
+ enabled: true,
70
+ level: 'debug',
71
+ }) as CollectorOptions & { level: string },
72
+ job: config.get('devtools.collectors.job', { enabled: true }) as CollectorOptions,
73
+ },
74
+ recorders: {
75
+ slowRequests: config.get('devtools.recorders.slowRequests', {
76
+ enabled: true,
77
+ threshold: 1000,
78
+ sampleRate: 1.0,
79
+ }) as any,
80
+ slowQueries: config.get('devtools.recorders.slowQueries', {
81
+ enabled: true,
82
+ threshold: 1000,
83
+ sampleRate: 1.0,
84
+ }) as any,
85
+ },
86
+ }
87
+
88
+ if (!DevtoolsManager._config.enabled) return
89
+
90
+ // Initialize storage (use the app's DB connection)
91
+ const sql = db.sql
92
+ DevtoolsManager._entryStore = new EntryStore(sql)
93
+ DevtoolsManager._aggregateStore = new AggregateStore(sql)
94
+
95
+ // Boot collectors
96
+ const getBatchId = () => DevtoolsManager._currentBatchId
97
+
98
+ DevtoolsManager._requestCollector = new RequestCollector(
99
+ DevtoolsManager._entryStore,
100
+ DevtoolsManager._config.collectors.request
101
+ )
102
+
103
+ DevtoolsManager._queryCollector = new QueryCollector(
104
+ DevtoolsManager._entryStore,
105
+ DevtoolsManager._config.collectors.query as any,
106
+ getBatchId
107
+ )
108
+
109
+ const exceptionCollector = new ExceptionCollector(
110
+ DevtoolsManager._entryStore,
111
+ DevtoolsManager._config.collectors.exception,
112
+ getBatchId
113
+ )
114
+
115
+ const logCollector = new LogCollector(
116
+ DevtoolsManager._entryStore,
117
+ DevtoolsManager._config.collectors.log as any,
118
+ getBatchId
119
+ )
120
+
121
+ const jobCollector = new JobCollector(
122
+ DevtoolsManager._entryStore,
123
+ DevtoolsManager._config.collectors.job
124
+ )
125
+
126
+ DevtoolsManager._collectors = [
127
+ DevtoolsManager._requestCollector,
128
+ DevtoolsManager._queryCollector,
129
+ exceptionCollector,
130
+ logCollector,
131
+ jobCollector,
132
+ ]
133
+
134
+ // Boot recorders
135
+ const slowRequests = new SlowRequestsRecorder(
136
+ DevtoolsManager._aggregateStore,
137
+ DevtoolsManager._config.recorders.slowRequests
138
+ )
139
+
140
+ const slowQueries = new SlowQueriesRecorder(
141
+ DevtoolsManager._aggregateStore,
142
+ DevtoolsManager._config.recorders.slowQueries
143
+ )
144
+
145
+ DevtoolsManager._recorders = [slowRequests, slowQueries]
146
+
147
+ // Register all listeners
148
+ for (const collector of DevtoolsManager._collectors) {
149
+ collector.register()
150
+ }
151
+ for (const recorder of DevtoolsManager._recorders) {
152
+ recorder.register()
153
+ }
154
+
155
+ // Install the SQL query proxy
156
+ const proxied = DevtoolsManager._queryCollector.installProxy(sql)
157
+
158
+ // Replace the connection on the Database class
159
+ // We use Object.defineProperty because Database._connection is private
160
+ // but we need to swap it with our proxied version
161
+ ;(db as any).connection = proxied
162
+ ;(db.constructor as any)._connection = proxied
163
+
164
+ DevtoolsManager._booted = true
165
+ }
166
+
167
+ static get config(): DevtoolsConfig {
168
+ if (!DevtoolsManager._config) {
169
+ throw new ConfigurationError(
170
+ 'DevtoolsManager not configured. Resolve it through the container first.'
171
+ )
172
+ }
173
+ return DevtoolsManager._config
174
+ }
175
+
176
+ static get entryStore(): EntryStore {
177
+ return DevtoolsManager._entryStore
178
+ }
179
+
180
+ static get aggregateStore(): AggregateStore {
181
+ return DevtoolsManager._aggregateStore
182
+ }
183
+
184
+ /** Returns the request-tracking middleware. */
185
+ static middleware() {
186
+ return DevtoolsManager._requestCollector.middleware()
187
+ }
188
+
189
+ /** Set the current batch ID (called by the request middleware). */
190
+ static setBatchId(id: string): void {
191
+ DevtoolsManager._currentBatchId = id
192
+ }
193
+
194
+ /** Get the current batch ID. */
195
+ static get batchId(): string {
196
+ return DevtoolsManager._currentBatchId
197
+ }
198
+
199
+ /** Create the storage tables. Called during setup or first boot. */
200
+ static async ensureTables(): Promise<void> {
201
+ await DevtoolsManager._entryStore.ensureTable()
202
+ await DevtoolsManager._aggregateStore.ensureTable()
203
+ }
204
+
205
+ /** Emit internal events for recorders. Called by the request collector. */
206
+ static emitRequest(data: {
207
+ path: string
208
+ method: string
209
+ duration: number
210
+ status: number
211
+ }): void {
212
+ if (Emitter.listenerCount('devtools:request') > 0) {
213
+ Emitter.emit('devtools:request', data).catch(() => {})
214
+ }
215
+ }
216
+
217
+ /** Emit internal events for recorders. Called by the query collector. */
218
+ static emitQuery(data: { sql: string; duration: number }): void {
219
+ if (Emitter.listenerCount('devtools:query') > 0) {
220
+ Emitter.emit('devtools:query', data).catch(() => {})
221
+ }
222
+ }
223
+
224
+ /** Tear down all collectors and recorders. */
225
+ static teardown(): void {
226
+ for (const collector of DevtoolsManager._collectors) {
227
+ collector.teardown()
228
+ }
229
+ for (const recorder of DevtoolsManager._recorders) {
230
+ recorder.teardown()
231
+ }
232
+ DevtoolsManager._collectors = []
233
+ DevtoolsManager._recorders = []
234
+ DevtoolsManager._booted = false
235
+ }
236
+
237
+ /** Reset all static state. For testing only. */
238
+ static reset(): void {
239
+ DevtoolsManager.teardown()
240
+ DevtoolsManager._config = undefined as any
241
+ DevtoolsManager._entryStore = undefined as any
242
+ DevtoolsManager._aggregateStore = undefined as any
243
+ }
244
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
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/core/http/middleware'
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,41 @@
1
+ // Manager
2
+ export { default, default as DevtoolsManager } from './devtools_manager.ts'
3
+
4
+ // Helper
5
+ export { devtools } from './helpers.ts'
6
+
7
+ // Storage
8
+ export { default as EntryStore } from './storage/entry_store.ts'
9
+ export { default as AggregateStore, PERIODS } from './storage/aggregate_store.ts'
10
+
11
+ // Collectors
12
+ export { default as Collector } from './collectors/collector.ts'
13
+ export { default as RequestCollector } from './collectors/request_collector.ts'
14
+ export { default as QueryCollector } from './collectors/query_collector.ts'
15
+ export { default as ExceptionCollector } from './collectors/exception_collector.ts'
16
+ export { default as LogCollector } from './collectors/log_collector.ts'
17
+ export { default as JobCollector } from './collectors/job_collector.ts'
18
+
19
+ // Recorders
20
+ export { default as Recorder } from './recorders/recorder.ts'
21
+ export { default as SlowRequestsRecorder } from './recorders/slow_requests.ts'
22
+ export { default as SlowQueriesRecorder } from './recorders/slow_queries.ts'
23
+
24
+ // Dashboard
25
+ export { registerDashboard } from './dashboard/routes.ts'
26
+ export { dashboardAuth } from './dashboard/middleware.ts'
27
+
28
+ // Errors
29
+ export { DevtoolsError } from './errors.ts'
30
+
31
+ // Types
32
+ export type {
33
+ DevtoolsEntry,
34
+ EntryRecord,
35
+ AggregateRecord,
36
+ DevtoolsConfig,
37
+ EntryType,
38
+ AggregateFunction,
39
+ CollectorOptions,
40
+ RecorderOptions,
41
+ } 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/core/events/emitter'
2
+ import type { Listener } from '@stravigor/core/events/emitter'
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/core/events/emitter'
2
+ import type { Listener } from '@stravigor/core/events/emitter'
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
+ }