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