@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.
Files changed (55) hide show
  1. package/README.md +19 -0
  2. package/package.json +60 -0
  3. package/src/Heartbeat.ts +152 -0
  4. package/src/HeartbeatServiceProvider.ts +211 -0
  5. package/src/commands/InstallCommand.ts +164 -0
  6. package/src/contracts/Entry.ts +157 -0
  7. package/src/contracts/HeartbeatConfig.ts +57 -0
  8. package/src/contracts/Watcher.ts +48 -0
  9. package/src/dashboard/DashboardController.ts +157 -0
  10. package/src/dashboard/middleware/AuthorizeHeartbeat.ts +21 -0
  11. package/src/dashboard/pages/CachePage.ts +45 -0
  12. package/src/dashboard/pages/EventsPage.ts +26 -0
  13. package/src/dashboard/pages/ExceptionsPage.ts +50 -0
  14. package/src/dashboard/pages/JobsPage.ts +44 -0
  15. package/src/dashboard/pages/OverviewPage.ts +73 -0
  16. package/src/dashboard/pages/PerformancePage.ts +51 -0
  17. package/src/dashboard/pages/QueriesPage.ts +45 -0
  18. package/src/dashboard/pages/RequestDetailPage.ts +294 -0
  19. package/src/dashboard/pages/RequestsPage.ts +30 -0
  20. package/src/dashboard/shared/charts.ts +241 -0
  21. package/src/dashboard/shared/components.ts +75 -0
  22. package/src/dashboard/shared/layout.ts +248 -0
  23. package/src/errors/HeartbeatError.ts +6 -0
  24. package/src/helpers/SimpleEventBus.ts +46 -0
  25. package/src/helpers/fingerprint.ts +63 -0
  26. package/src/helpers/heartbeat.ts +21 -0
  27. package/src/helpers/sampling.ts +16 -0
  28. package/src/helpers/timing.ts +35 -0
  29. package/src/index.ts +89 -0
  30. package/src/jobs/RecordHeartbeatEntries.ts +24 -0
  31. package/src/metrics/MetricsCollector.ts +201 -0
  32. package/src/metrics/QueueMetrics.ts +36 -0
  33. package/src/metrics/RequestMetrics.ts +45 -0
  34. package/src/metrics/SystemMetrics.ts +42 -0
  35. package/src/middleware/HeartbeatMiddleware.ts +198 -0
  36. package/src/migrations/CreateHeartbeatTables.ts +81 -0
  37. package/src/models/EntryModel.ts +11 -0
  38. package/src/models/ExceptionGroupModel.ts +17 -0
  39. package/src/models/MetricModel.ts +14 -0
  40. package/src/models/SpanModel.ts +17 -0
  41. package/src/storage/HeartbeatStore.ts +245 -0
  42. package/src/testing/HeartbeatFake.ts +95 -0
  43. package/src/tracing/RequestTraceMiddleware.ts +46 -0
  44. package/src/tracing/Span.ts +99 -0
  45. package/src/tracing/TraceContext.ts +58 -0
  46. package/src/tracing/Tracer.ts +115 -0
  47. package/src/watchers/CacheWatcher.ts +34 -0
  48. package/src/watchers/EventWatcher.ts +38 -0
  49. package/src/watchers/ExceptionWatcher.ts +47 -0
  50. package/src/watchers/JobWatcher.ts +87 -0
  51. package/src/watchers/LogWatcher.ts +48 -0
  52. package/src/watchers/ModelWatcher.ts +32 -0
  53. package/src/watchers/QueryWatcher.ts +104 -0
  54. package/src/watchers/RequestWatcher.ts +134 -0
  55. 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
+ }
@@ -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
+ `