@mantiq/heartbeat 0.0.1
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 +19 -0
- package/package.json +60 -0
- package/src/Heartbeat.ts +152 -0
- package/src/HeartbeatServiceProvider.ts +211 -0
- package/src/commands/InstallCommand.ts +164 -0
- package/src/contracts/Entry.ts +157 -0
- package/src/contracts/HeartbeatConfig.ts +57 -0
- package/src/contracts/Watcher.ts +48 -0
- package/src/dashboard/DashboardController.ts +157 -0
- package/src/dashboard/middleware/AuthorizeHeartbeat.ts +21 -0
- package/src/dashboard/pages/CachePage.ts +45 -0
- package/src/dashboard/pages/EventsPage.ts +26 -0
- package/src/dashboard/pages/ExceptionsPage.ts +50 -0
- package/src/dashboard/pages/JobsPage.ts +44 -0
- package/src/dashboard/pages/OverviewPage.ts +73 -0
- package/src/dashboard/pages/PerformancePage.ts +51 -0
- package/src/dashboard/pages/QueriesPage.ts +45 -0
- package/src/dashboard/pages/RequestDetailPage.ts +294 -0
- package/src/dashboard/pages/RequestsPage.ts +30 -0
- package/src/dashboard/shared/charts.ts +241 -0
- package/src/dashboard/shared/components.ts +75 -0
- package/src/dashboard/shared/layout.ts +248 -0
- package/src/errors/HeartbeatError.ts +6 -0
- package/src/helpers/SimpleEventBus.ts +46 -0
- package/src/helpers/fingerprint.ts +63 -0
- package/src/helpers/heartbeat.ts +21 -0
- package/src/helpers/sampling.ts +16 -0
- package/src/helpers/timing.ts +35 -0
- package/src/index.ts +89 -0
- package/src/jobs/RecordHeartbeatEntries.ts +24 -0
- package/src/metrics/MetricsCollector.ts +201 -0
- package/src/metrics/QueueMetrics.ts +36 -0
- package/src/metrics/RequestMetrics.ts +45 -0
- package/src/metrics/SystemMetrics.ts +42 -0
- package/src/middleware/HeartbeatMiddleware.ts +198 -0
- package/src/migrations/CreateHeartbeatTables.ts +81 -0
- package/src/models/EntryModel.ts +11 -0
- package/src/models/ExceptionGroupModel.ts +17 -0
- package/src/models/MetricModel.ts +14 -0
- package/src/models/SpanModel.ts +17 -0
- package/src/storage/HeartbeatStore.ts +245 -0
- package/src/testing/HeartbeatFake.ts +95 -0
- package/src/tracing/RequestTraceMiddleware.ts +46 -0
- package/src/tracing/Span.ts +99 -0
- package/src/tracing/TraceContext.ts +58 -0
- package/src/tracing/Tracer.ts +115 -0
- package/src/watchers/CacheWatcher.ts +34 -0
- package/src/watchers/EventWatcher.ts +38 -0
- package/src/watchers/ExceptionWatcher.ts +47 -0
- package/src/watchers/JobWatcher.ts +87 -0
- package/src/watchers/LogWatcher.ts +48 -0
- package/src/watchers/ModelWatcher.ts +32 -0
- package/src/watchers/QueryWatcher.ts +104 -0
- package/src/watchers/RequestWatcher.ts +134 -0
- package/src/watchers/ScheduleWatcher.ts +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mantiq/heartbeat
|
|
2
|
+
|
|
3
|
+
Observability, APM, and request/query/exception tracing for MantiqJS. Self-contained dashboard at `/_heartbeat`.
|
|
4
|
+
|
|
5
|
+
Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mantiq/heartbeat
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/heartbeat",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Observability, APM & queue monitoring for MantiqJS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/heartbeat",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/abdullahkhan/mantiq.git",
|
|
12
|
+
"directory": "packages/heartbeat"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/abdullahkhan/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript",
|
|
22
|
+
"framework",
|
|
23
|
+
"heartbeat"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"package.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"clean": "rm -rf dist"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mantiq/core": "workspace:*",
|
|
50
|
+
"@mantiq/cli": "workspace:*",
|
|
51
|
+
"@mantiq/queue": "workspace:*",
|
|
52
|
+
"@mantiq/events": "workspace:*",
|
|
53
|
+
"@mantiq/database": "workspace:*",
|
|
54
|
+
"@mantiq/logging": "workspace:*"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"bun-types": "latest",
|
|
58
|
+
"typescript": "^5.7.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/Heartbeat.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { HeartbeatConfig } from './contracts/HeartbeatConfig.ts'
|
|
2
|
+
import type { EntryType, PendingEntry } from './contracts/Entry.ts'
|
|
3
|
+
import type { Watcher } from './contracts/Watcher.ts'
|
|
4
|
+
import { HeartbeatStore } from './storage/HeartbeatStore.ts'
|
|
5
|
+
import { RecordHeartbeatEntries } from './jobs/RecordHeartbeatEntries.ts'
|
|
6
|
+
import { shouldSample } from './helpers/sampling.ts'
|
|
7
|
+
import type { Tracer } from './tracing/Tracer.ts'
|
|
8
|
+
import type { DatabaseConnection } from '@mantiq/database'
|
|
9
|
+
|
|
10
|
+
const ERROR_TYPES = new Set<EntryType>(['exception'])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Central orchestrator for the Heartbeat observability system.
|
|
14
|
+
*
|
|
15
|
+
* Manages watchers, buffers telemetry entries, and flushes them
|
|
16
|
+
* as batched queue jobs to the dedicated heartbeat queue.
|
|
17
|
+
*/
|
|
18
|
+
export class Heartbeat {
|
|
19
|
+
readonly store: HeartbeatStore
|
|
20
|
+
readonly config: HeartbeatConfig
|
|
21
|
+
|
|
22
|
+
private buffer: PendingEntry[] = []
|
|
23
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
24
|
+
private watchers: Watcher[] = []
|
|
25
|
+
private pruneTimer: ReturnType<typeof setInterval> | null = null
|
|
26
|
+
private _tracer: Tracer | null = null
|
|
27
|
+
|
|
28
|
+
constructor(config: HeartbeatConfig, connection: DatabaseConnection) {
|
|
29
|
+
this.config = config
|
|
30
|
+
this.store = new HeartbeatStore(connection)
|
|
31
|
+
this.store.setupModels()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Tracer ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
get tracer(): Tracer | null {
|
|
37
|
+
return this._tracer
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setTracer(tracer: Tracer): void {
|
|
41
|
+
this._tracer = tracer
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Watcher Management ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
addWatcher(watcher: Watcher): void {
|
|
47
|
+
watcher.setHeartbeat(this)
|
|
48
|
+
this.watchers.push(watcher)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getWatchers(): Watcher[] {
|
|
52
|
+
return this.watchers
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Entry Recording ─────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Record a telemetry entry. Called by watchers.
|
|
59
|
+
* Entries are buffered and flushed as batched queue jobs.
|
|
60
|
+
*/
|
|
61
|
+
record(type: EntryType, content: Record<string, any>, tags?: string[]): void {
|
|
62
|
+
if (!this.config.enabled) return
|
|
63
|
+
|
|
64
|
+
const isError = ERROR_TYPES.has(type)
|
|
65
|
+
if (!shouldSample(this.config, isError)) return
|
|
66
|
+
|
|
67
|
+
const entry: PendingEntry = {
|
|
68
|
+
type,
|
|
69
|
+
content,
|
|
70
|
+
tags,
|
|
71
|
+
requestId: this._tracer?.currentRequestId() ?? null,
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.buffer.push(entry)
|
|
76
|
+
|
|
77
|
+
if (this.buffer.length >= this.config.queue.batchSize) {
|
|
78
|
+
this.flush()
|
|
79
|
+
} else if (!this.flushTimer) {
|
|
80
|
+
this.flushTimer = setTimeout(() => this.flush(), this.config.queue.flushInterval)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Flush the entry buffer by dispatching a RecordHeartbeatEntries job.
|
|
86
|
+
* Falls back to direct store insert if the queue is unavailable.
|
|
87
|
+
*/
|
|
88
|
+
flush(): void {
|
|
89
|
+
if (this.buffer.length === 0) return
|
|
90
|
+
|
|
91
|
+
const entries = this.buffer.splice(0)
|
|
92
|
+
if (this.flushTimer) {
|
|
93
|
+
clearTimeout(this.flushTimer)
|
|
94
|
+
this.flushTimer = null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check if queue system is available — if not, write directly (async, fire-and-forget)
|
|
98
|
+
let queueAvailable = false
|
|
99
|
+
try {
|
|
100
|
+
const { getQueueManager } = require('@mantiq/queue')
|
|
101
|
+
getQueueManager() // throws if not initialized
|
|
102
|
+
queueAvailable = true
|
|
103
|
+
} catch {
|
|
104
|
+
// Queue not available
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (queueAvailable) {
|
|
108
|
+
try {
|
|
109
|
+
const { dispatch } = require('@mantiq/queue')
|
|
110
|
+
dispatch(new RecordHeartbeatEntries(entries))
|
|
111
|
+
.onQueue(this.config.queue.queue)
|
|
112
|
+
.onConnection(this.config.queue.connection)
|
|
113
|
+
.send()
|
|
114
|
+
.catch(() => {
|
|
115
|
+
this.store.insertEntries(entries).catch(() => { /* swallow */ })
|
|
116
|
+
})
|
|
117
|
+
} catch {
|
|
118
|
+
this.store.insertEntries(entries).catch(() => { /* swallow */ })
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// Direct write — async fire-and-forget fallback
|
|
122
|
+
this.store.insertEntries(entries).catch(() => { /* swallow */ })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Pruning ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
startPruning(): void {
|
|
129
|
+
if (this.pruneTimer) return
|
|
130
|
+
this.pruneTimer = setInterval(() => {
|
|
131
|
+
this.store.prune(this.config.storage.retention).catch(() => { /* swallow */ })
|
|
132
|
+
}, this.config.storage.pruneInterval * 1000)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
stopPruning(): void {
|
|
136
|
+
if (this.pruneTimer) {
|
|
137
|
+
clearInterval(this.pruneTimer)
|
|
138
|
+
this.pruneTimer = null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
shutdown(): void {
|
|
145
|
+
this.flush()
|
|
146
|
+
this.stopPruning()
|
|
147
|
+
if (this.flushTimer) {
|
|
148
|
+
clearTimeout(this.flushTimer)
|
|
149
|
+
this.flushTimer = null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ServiceProvider, ConfigRepository, RouterImpl, HttpKernel, CacheManager } from '@mantiq/core'
|
|
2
|
+
import type { EventDispatcher } from '@mantiq/core'
|
|
3
|
+
import { DatabaseManager, SQLiteConnection } from '@mantiq/database'
|
|
4
|
+
import type { HeartbeatConfig } from './contracts/HeartbeatConfig.ts'
|
|
5
|
+
import { DEFAULT_CONFIG } from './contracts/HeartbeatConfig.ts'
|
|
6
|
+
import { Heartbeat } from './Heartbeat.ts'
|
|
7
|
+
import { HEARTBEAT, setHeartbeat } from './helpers/heartbeat.ts'
|
|
8
|
+
import { Tracer } from './tracing/Tracer.ts'
|
|
9
|
+
import { MetricsCollector } from './metrics/MetricsCollector.ts'
|
|
10
|
+
import { RequestMetrics } from './metrics/RequestMetrics.ts'
|
|
11
|
+
import { QueueMetrics } from './metrics/QueueMetrics.ts'
|
|
12
|
+
import { SystemMetrics } from './metrics/SystemMetrics.ts'
|
|
13
|
+
import { RequestWatcher } from './watchers/RequestWatcher.ts'
|
|
14
|
+
import { QueryWatcher } from './watchers/QueryWatcher.ts'
|
|
15
|
+
import { ExceptionWatcher } from './watchers/ExceptionWatcher.ts'
|
|
16
|
+
import { CacheWatcher } from './watchers/CacheWatcher.ts'
|
|
17
|
+
import { JobWatcher } from './watchers/JobWatcher.ts'
|
|
18
|
+
import { EventWatcher } from './watchers/EventWatcher.ts'
|
|
19
|
+
import { ModelWatcher } from './watchers/ModelWatcher.ts'
|
|
20
|
+
import { LogWatcher } from './watchers/LogWatcher.ts'
|
|
21
|
+
import { ScheduleWatcher } from './watchers/ScheduleWatcher.ts'
|
|
22
|
+
import { CreateHeartbeatTables } from './migrations/CreateHeartbeatTables.ts'
|
|
23
|
+
import { DashboardController } from './dashboard/DashboardController.ts'
|
|
24
|
+
import { HeartbeatMiddleware } from './middleware/HeartbeatMiddleware.ts'
|
|
25
|
+
import type { Watcher } from './contracts/Watcher.ts'
|
|
26
|
+
import { SimpleEventBus } from './helpers/SimpleEventBus.ts'
|
|
27
|
+
|
|
28
|
+
export class HeartbeatServiceProvider extends ServiceProvider {
|
|
29
|
+
override register(): void {
|
|
30
|
+
this.app.singleton(Heartbeat, (c) => {
|
|
31
|
+
const config = c.make(ConfigRepository).get<HeartbeatConfig>('heartbeat', DEFAULT_CONFIG)
|
|
32
|
+
|
|
33
|
+
// Resolve connection — undefined means use app's default database connection
|
|
34
|
+
const dbManager = c.make(DatabaseManager)
|
|
35
|
+
const connection = dbManager.connection(config.storage.connection)
|
|
36
|
+
|
|
37
|
+
const heartbeat = new Heartbeat(config, connection)
|
|
38
|
+
setHeartbeat(heartbeat)
|
|
39
|
+
return heartbeat
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
this.app.alias(Heartbeat, HEARTBEAT)
|
|
43
|
+
|
|
44
|
+
// Singletons for tracing and metrics
|
|
45
|
+
this.app.singleton(Tracer, () => new Tracer())
|
|
46
|
+
this.app.singleton(MetricsCollector, () => new MetricsCollector())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override async boot(): Promise<void> {
|
|
50
|
+
const heartbeat = this.app.make(Heartbeat)
|
|
51
|
+
const config = heartbeat.config
|
|
52
|
+
|
|
53
|
+
if (!config.enabled) return
|
|
54
|
+
|
|
55
|
+
// Run heartbeat migration to ensure tables exist
|
|
56
|
+
const migration = new CreateHeartbeatTables()
|
|
57
|
+
const connection = heartbeat.store.getConnection()
|
|
58
|
+
await migration.up(connection.schema(), connection)
|
|
59
|
+
|
|
60
|
+
// Set up tracer
|
|
61
|
+
const tracer = this.app.make(Tracer)
|
|
62
|
+
tracer.setStore(heartbeat.store)
|
|
63
|
+
heartbeat.setTracer(tracer)
|
|
64
|
+
|
|
65
|
+
// Set up metrics
|
|
66
|
+
const metrics = this.app.make(MetricsCollector)
|
|
67
|
+
metrics.setStore(heartbeat.store)
|
|
68
|
+
|
|
69
|
+
this.app.instance(RequestMetrics, new RequestMetrics(metrics))
|
|
70
|
+
this.app.instance(QueueMetrics, new QueueMetrics(metrics))
|
|
71
|
+
this.app.instance(SystemMetrics, new SystemMetrics(metrics))
|
|
72
|
+
|
|
73
|
+
// Set up event bus for watcher event listeners
|
|
74
|
+
const eventBus = this.setupEventBus()
|
|
75
|
+
|
|
76
|
+
// Register watchers
|
|
77
|
+
const requestWatcher = this.registerWatchers(heartbeat, config, eventBus)
|
|
78
|
+
|
|
79
|
+
// Start periodic systems
|
|
80
|
+
metrics.start()
|
|
81
|
+
heartbeat.startPruning()
|
|
82
|
+
|
|
83
|
+
const systemMetrics = this.app.make(SystemMetrics)
|
|
84
|
+
systemMetrics.start()
|
|
85
|
+
|
|
86
|
+
// Register HeartbeatMiddleware as first global middleware
|
|
87
|
+
this.registerMiddleware(heartbeat, tracer, requestWatcher, metrics)
|
|
88
|
+
|
|
89
|
+
// Register dashboard routes
|
|
90
|
+
if (config.dashboard.enabled) {
|
|
91
|
+
this.registerDashboardRoutes(heartbeat, metrics, config)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private registerMiddleware(
|
|
96
|
+
heartbeat: Heartbeat,
|
|
97
|
+
tracer: Tracer,
|
|
98
|
+
requestWatcher: RequestWatcher | null,
|
|
99
|
+
metrics: MetricsCollector,
|
|
100
|
+
): void {
|
|
101
|
+
const kernel = this.app.make(HttpKernel)
|
|
102
|
+
|
|
103
|
+
// Register the middleware instance in the container
|
|
104
|
+
const middleware = new HeartbeatMiddleware(heartbeat, tracer, requestWatcher, metrics)
|
|
105
|
+
this.app.instance(HeartbeatMiddleware, middleware)
|
|
106
|
+
|
|
107
|
+
// Register alias and prepend to global middleware
|
|
108
|
+
kernel.registerMiddleware('heartbeat', HeartbeatMiddleware)
|
|
109
|
+
kernel.prependGlobalMiddleware('heartbeat')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private registerDashboardRoutes(heartbeat: Heartbeat, metrics: MetricsCollector, config: HeartbeatConfig): void {
|
|
113
|
+
const router = this.app.make(RouterImpl)
|
|
114
|
+
const basePath = config.dashboard.path
|
|
115
|
+
const env = this.app.make(ConfigRepository).get<string>('app.env', 'production')
|
|
116
|
+
const controller = new DashboardController(heartbeat.store, metrics, basePath)
|
|
117
|
+
|
|
118
|
+
// Register catch-all route for the dashboard
|
|
119
|
+
// The DashboardController handles sub-routing internally
|
|
120
|
+
router.any(`${basePath}`, (request) => {
|
|
121
|
+
return this.gateDashboard(env, request, controller)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
router.any(`${basePath}/*`, (request) => {
|
|
125
|
+
return this.gateDashboard(env, request, controller)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async gateDashboard(env: string, request: any, controller: DashboardController): Promise<Response> {
|
|
130
|
+
// Gate: allow in development/local/testing, block in production
|
|
131
|
+
if (env !== 'development' && env !== 'local' && env !== 'testing') {
|
|
132
|
+
return new Response('Forbidden — Heartbeat dashboard is disabled in production.', {
|
|
133
|
+
status: 403,
|
|
134
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return controller.handle(request.raw())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set up an event bus for watchers.
|
|
143
|
+
*
|
|
144
|
+
* If @mantiq/events Dispatcher is already in the container, reuse it.
|
|
145
|
+
* Otherwise, create a SimpleEventBus and hook it into SQLiteConnection,
|
|
146
|
+
* CacheManager, and RouterImpl so events flow even without the events package.
|
|
147
|
+
*/
|
|
148
|
+
private setupEventBus(): SimpleEventBus {
|
|
149
|
+
// Check if a full dispatcher already exists
|
|
150
|
+
const existingDispatcher = SQLiteConnection._dispatcher
|
|
151
|
+
if (existingDispatcher && typeof (existingDispatcher as any).on === 'function') {
|
|
152
|
+
// Wrap the existing dispatcher's on/onAny into our SimpleEventBus
|
|
153
|
+
// so watchers register on the same dispatcher that fires events
|
|
154
|
+
const bus = new SimpleEventBus()
|
|
155
|
+
// Proxy: listeners registered on our bus also get registered on the real dispatcher
|
|
156
|
+
const realOn = (existingDispatcher as any).on.bind(existingDispatcher)
|
|
157
|
+
const realOnAny = (existingDispatcher as any).onAny?.bind(existingDispatcher)
|
|
158
|
+
const origOn = bus.on.bind(bus)
|
|
159
|
+
const origOnAny = bus.onAny.bind(bus)
|
|
160
|
+
bus.on = (eventClass: any, handler: any) => { origOn(eventClass, handler); realOn(eventClass, handler) }
|
|
161
|
+
if (realOnAny) {
|
|
162
|
+
bus.onAny = (handler: any) => { origOnAny(handler); realOnAny(handler) }
|
|
163
|
+
}
|
|
164
|
+
return bus
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// No existing dispatcher — create our own and hook it into framework statics
|
|
168
|
+
const bus = new SimpleEventBus()
|
|
169
|
+
SQLiteConnection._dispatcher = bus as any
|
|
170
|
+
CacheManager._dispatcher = bus as any
|
|
171
|
+
RouterImpl._dispatcher = bus as any
|
|
172
|
+
return bus
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register watchers and return the RequestWatcher instance (if enabled).
|
|
177
|
+
*/
|
|
178
|
+
private registerWatchers(heartbeat: Heartbeat, config: HeartbeatConfig, eventBus: SimpleEventBus): RequestWatcher | null {
|
|
179
|
+
let requestWatcher: RequestWatcher | null = null
|
|
180
|
+
|
|
181
|
+
const watcherMap: Array<[keyof HeartbeatConfig['watchers'], Watcher]> = [
|
|
182
|
+
['request', new RequestWatcher()],
|
|
183
|
+
['query', new QueryWatcher()],
|
|
184
|
+
['exception', new ExceptionWatcher()],
|
|
185
|
+
['cache', new CacheWatcher()],
|
|
186
|
+
['job', new JobWatcher()],
|
|
187
|
+
['event', new EventWatcher()],
|
|
188
|
+
['model', new ModelWatcher()],
|
|
189
|
+
['log', new LogWatcher()],
|
|
190
|
+
['schedule', new ScheduleWatcher()],
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
const on = eventBus.on.bind(eventBus)
|
|
194
|
+
const onAny = eventBus.onAny.bind(eventBus)
|
|
195
|
+
|
|
196
|
+
for (const [key, watcher] of watcherMap) {
|
|
197
|
+
const watcherConfig = config.watchers[key]
|
|
198
|
+
if (!watcherConfig.enabled) continue
|
|
199
|
+
|
|
200
|
+
watcher.configure(watcherConfig as Record<string, any>)
|
|
201
|
+
heartbeat.addWatcher(watcher)
|
|
202
|
+
watcher.register(on, onAny)
|
|
203
|
+
|
|
204
|
+
if (key === 'request') {
|
|
205
|
+
requestWatcher = watcher as RequestWatcher
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return requestWatcher
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Command } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Publishes the Heartbeat config and service provider into the app.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* bun run mantiq.ts heartbeat:install
|
|
11
|
+
*
|
|
12
|
+
* Published files:
|
|
13
|
+
* config/heartbeat.ts — Heartbeat configuration
|
|
14
|
+
* app/Providers/HeartbeatServiceProvider.ts — App-level provider (extends package provider)
|
|
15
|
+
*/
|
|
16
|
+
export class InstallCommand extends Command {
|
|
17
|
+
override name = 'heartbeat:install'
|
|
18
|
+
override description = 'Install the Heartbeat observability package'
|
|
19
|
+
|
|
20
|
+
override async handle(_args: ParsedArgs): Promise<number> {
|
|
21
|
+
this.io.heading('Installing Heartbeat')
|
|
22
|
+
this.io.newLine()
|
|
23
|
+
|
|
24
|
+
const basePath = process.cwd()
|
|
25
|
+
let published = 0
|
|
26
|
+
|
|
27
|
+
// 1. Publish config
|
|
28
|
+
published += await this.publishFile(
|
|
29
|
+
`${basePath}/config/heartbeat.ts`,
|
|
30
|
+
CONFIG_STUB,
|
|
31
|
+
'config/heartbeat.ts',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// 2. Publish service provider
|
|
35
|
+
published += await this.publishFile(
|
|
36
|
+
`${basePath}/app/Providers/HeartbeatServiceProvider.ts`,
|
|
37
|
+
PROVIDER_STUB,
|
|
38
|
+
'app/Providers/HeartbeatServiceProvider.ts',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
this.io.newLine()
|
|
42
|
+
|
|
43
|
+
if (published > 0) {
|
|
44
|
+
this.io.success(`Published ${published} file(s).`)
|
|
45
|
+
} else {
|
|
46
|
+
this.io.info('All files already exist. No changes made.')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.io.newLine()
|
|
50
|
+
this.io.info('Next steps:')
|
|
51
|
+
this.io.twoColumn(' 1.', 'Register the provider in your app bootstrap:')
|
|
52
|
+
this.io.newLine()
|
|
53
|
+
this.io.line(" import { HeartbeatServiceProvider } from './app/Providers/HeartbeatServiceProvider.ts'")
|
|
54
|
+
this.io.newLine()
|
|
55
|
+
this.io.line(" await app.registerProviders([..., HeartbeatServiceProvider])")
|
|
56
|
+
this.io.newLine()
|
|
57
|
+
this.io.twoColumn(' 2.', `Visit ${this.io.cyan('/_heartbeat')} to view the dashboard.`)
|
|
58
|
+
this.io.newLine()
|
|
59
|
+
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async publishFile(filePath: string, content: string, displayPath: string): Promise<number> {
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
this.io.warn(`${displayPath} already exists, skipping.`)
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
70
|
+
await Bun.write(filePath, content)
|
|
71
|
+
this.io.success(`Published ${displayPath}`)
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Stubs ──────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const CONFIG_STUB = `import { env } from '@mantiq/core'
|
|
79
|
+
|
|
80
|
+
export default {
|
|
81
|
+
/**
|
|
82
|
+
* Master switch — set to false to completely disable Heartbeat.
|
|
83
|
+
*/
|
|
84
|
+
enabled: env('HEARTBEAT_ENABLED', true),
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Storage settings.
|
|
88
|
+
*
|
|
89
|
+
* connection: The database connection to use. Leave undefined to use the
|
|
90
|
+
* app's default connection. Set a string to use a dedicated one.
|
|
91
|
+
* retention: How long to keep entries (seconds). Default 24h.
|
|
92
|
+
* pruneInterval: How often to prune old entries (seconds). Default 5 min.
|
|
93
|
+
*/
|
|
94
|
+
storage: {
|
|
95
|
+
connection: undefined,
|
|
96
|
+
retention: 86_400,
|
|
97
|
+
pruneInterval: 300,
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Queue settings for non-blocking telemetry writes.
|
|
102
|
+
*
|
|
103
|
+
* In development (sync driver), jobs execute immediately.
|
|
104
|
+
* In production, use an async driver so telemetry doesn't block requests.
|
|
105
|
+
*/
|
|
106
|
+
queue: {
|
|
107
|
+
connection: 'sync',
|
|
108
|
+
queue: 'heartbeat',
|
|
109
|
+
batchSize: 50,
|
|
110
|
+
flushInterval: 1_000,
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Toggle individual watchers and configure their behaviour.
|
|
115
|
+
*/
|
|
116
|
+
watchers: {
|
|
117
|
+
request: { enabled: true, slow_threshold: 1_000, ignore: [] },
|
|
118
|
+
query: { enabled: true, slow_threshold: 100, detect_n_plus_one: true },
|
|
119
|
+
exception: { enabled: true, ignore: [] },
|
|
120
|
+
cache: { enabled: true },
|
|
121
|
+
job: { enabled: true },
|
|
122
|
+
event: { enabled: true, ignore: [] },
|
|
123
|
+
model: { enabled: true },
|
|
124
|
+
log: { enabled: true, level: 'debug' },
|
|
125
|
+
schedule: { enabled: true },
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Distributed tracing via AsyncLocalStorage.
|
|
130
|
+
*/
|
|
131
|
+
tracing: { enabled: true, propagate: true },
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Sampling — reduce volume in high-traffic production environments.
|
|
135
|
+
* rate: 1.0 = record everything, 0.1 = record 10% of requests.
|
|
136
|
+
*/
|
|
137
|
+
sampling: { rate: 1.0, always_sample_errors: true },
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Dashboard settings.
|
|
141
|
+
*
|
|
142
|
+
* path: The URL prefix where the dashboard is served.
|
|
143
|
+
* enabled: Set to false to disable the dashboard entirely.
|
|
144
|
+
*/
|
|
145
|
+
dashboard: {
|
|
146
|
+
path: '/_heartbeat',
|
|
147
|
+
middleware: [],
|
|
148
|
+
enabled: true,
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
`
|
|
152
|
+
|
|
153
|
+
const PROVIDER_STUB = `import { HeartbeatServiceProvider as BaseProvider } from '@mantiq/heartbeat'
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* App-level Heartbeat service provider.
|
|
157
|
+
*
|
|
158
|
+
* Extend the base provider to customise authorisation, add custom watchers,
|
|
159
|
+
* or hook into Heartbeat lifecycle events.
|
|
160
|
+
*/
|
|
161
|
+
export class HeartbeatServiceProvider extends BaseProvider {
|
|
162
|
+
//
|
|
163
|
+
}
|
|
164
|
+
`
|