@mantiq/heartbeat 0.3.5 → 0.4.0-rc.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/heartbeat",
3
- "version": "0.3.5",
3
+ "version": "0.4.0-rc.2",
4
4
  "description": "Observability, APM & queue monitoring for MantiqJS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,14 +40,20 @@
40
40
  "LICENSE"
41
41
  ],
42
42
  "scripts": {
43
- "build": "bun build ./src/index.ts --outdir ./dist --target bun",
43
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun --packages=external",
44
44
  "test": "bun test",
45
45
  "typecheck": "tsc --noEmit",
46
46
  "clean": "rm -rf dist"
47
47
  },
48
48
  "devDependencies": {
49
49
  "bun-types": "latest",
50
- "typescript": "^5.7.0"
50
+ "typescript": "^5.7.0",
51
+ "@mantiq/core": "workspace:*",
52
+ "@mantiq/cli": "workspace:*",
53
+ "@mantiq/queue": "workspace:*",
54
+ "@mantiq/events": "workspace:*",
55
+ "@mantiq/database": "workspace:*",
56
+ "@mantiq/logging": "workspace:*"
51
57
  },
52
58
  "peerDependencies": {
53
59
  "@mantiq/core": "^0.1.0",
@@ -56,5 +62,8 @@
56
62
  "@mantiq/events": "^0.1.0",
57
63
  "@mantiq/database": "^0.1.0",
58
64
  "@mantiq/logging": "^0.1.0"
65
+ },
66
+ "mantiq": {
67
+ "provider": "HeartbeatServiceProvider"
59
68
  }
60
69
  }
@@ -19,7 +19,7 @@ export type EntryType =
19
19
  export interface PendingEntry {
20
20
  type: EntryType
21
21
  content: Record<string, any>
22
- tags?: string[]
22
+ tags?: string[] | undefined
23
23
  requestId: string | null
24
24
  createdAt: number
25
25
  }
@@ -4,7 +4,7 @@ import type { HeartbeatStore } from '../../storage/HeartbeatStore.ts'
4
4
  import type { MailEntryContent } from '../../contracts/Entry.ts'
5
5
 
6
6
  export async function renderMailPage(store: HeartbeatStore, basePath: string): Promise<string> {
7
- const entries = await store.getEntries('mail', 50)
7
+ const entries = await store.getEntries({ type: 'mail', limit: 50 })
8
8
 
9
9
  const rows = entries.map((e) => {
10
10
  let c: MailEntryContent
@@ -136,29 +136,41 @@ export class HeartbeatMiddleware implements Middleware {
136
136
  }
137
137
  }
138
138
 
139
- // Attach debug stats header: duration;memory;status;queries
140
- if (process.env.APP_DEBUG === 'true' && response!) {
141
- try {
142
- const mem = (Math.abs(process.memoryUsage().rss - startMemory) / 1024 / 1024).toFixed(1)
143
- const headers = new Headers(response!.headers)
144
- headers.set('X-Heartbeat', `${Math.round(duration)}ms;${mem}MB;${response!.status};0q`)
145
- headers.set('Access-Control-Expose-Headers', [headers.get('Access-Control-Expose-Headers'), 'X-Heartbeat'].filter(Boolean).join(', '))
146
- response = new Response(response!.body, { status: response!.status, statusText: response!.statusText, headers })
147
- } catch { /* ignore */ }
148
- }
149
-
150
139
  // Flush entries (fire-and-forget)
151
140
  this.heartbeat.flush()
152
141
  }
153
142
 
154
- // Inject debug widget into HTML responses when APP_DEBUG=true
143
+ // Debug mode: attach X-Heartbeat header + inject widget
155
144
  if (process.env.APP_DEBUG === 'true' && response!) {
156
- const ct = response!.headers.get('content-type') ?? ''
157
- if (ct.includes('text/html') && response!.status < 400) {
158
- const duration = performance.now() - startTime
159
- const memUsage = process.memoryUsage().rss - startMemory
160
- response = await this.injectWidget(response!, duration, memUsage)
161
- }
145
+ const totalDuration = performance.now() - startTime
146
+ const totalMemory = Math.abs(process.memoryUsage().rss - startMemory)
147
+ const mem = (totalMemory / 1024 / 1024).toFixed(1)
148
+ const statsHeader = `${Math.round(totalDuration)}ms;${mem}MB;${response!.status};0q`
149
+
150
+ try {
151
+ const ct = response!.headers.get('content-type') ?? ''
152
+ const isHtml = ct.includes('text/html') && response!.status < 400
153
+ const cloned = response!.clone()
154
+ const body = await cloned.text()
155
+ const headers = new Headers(response!.headers)
156
+
157
+ headers.set('X-Heartbeat', statsHeader)
158
+ headers.set('Access-Control-Expose-Headers', [headers.get('Access-Control-Expose-Headers'), 'X-Heartbeat'].filter(Boolean).join(', '))
159
+
160
+ let finalBody = body
161
+ if (isHtml && this.heartbeat.config.widget?.enabled !== false && body.includes('</body>')) {
162
+ const widget = renderWidget({
163
+ duration: totalDuration,
164
+ memory: totalMemory,
165
+ status: response!.status,
166
+ queries: 0,
167
+ dashboardPath: this.heartbeat.config.dashboard.path,
168
+ })
169
+ finalBody = body.replace('</body>', widget + '\n</body>')
170
+ }
171
+
172
+ response = new Response(finalBody, { status: response!.status, statusText: response!.statusText, headers })
173
+ } catch (e) { console.error('[Heartbeat Widget]', e) }
162
174
  }
163
175
 
164
176
  return response!
@@ -225,29 +237,4 @@ export class HeartbeatMiddleware implements Middleware {
225
237
  }
226
238
  }
227
239
 
228
- private async injectWidget(response: Response, duration: number, memory: number): Promise<Response> {
229
- if (this.heartbeat.config.widget?.enabled === false) return response
230
-
231
- try {
232
- const html = await response.text()
233
- if (!html.includes('</body>')) return new Response(html, { status: response.status, statusText: response.statusText, headers: response.headers })
234
-
235
- const widget = renderWidget({
236
- duration,
237
- memory: Math.abs(memory),
238
- status: response.status,
239
- queries: 0, // TODO: wire up query count from QueryWatcher
240
- dashboardPath: this.heartbeat.config.dashboard.path,
241
- })
242
-
243
- const injected = html.replace('</body>', widget + '\n</body>')
244
- return new Response(injected, {
245
- status: response.status,
246
- statusText: response.statusText,
247
- headers: response.headers,
248
- })
249
- } catch {
250
- return response
251
- }
252
- }
253
240
  }
@@ -2,9 +2,11 @@ import { Model } from '@mantiq/database'
2
2
 
3
3
  export class EntryModel extends Model {
4
4
  static override table = 'heartbeat_entries'
5
+ static override incrementing = true
6
+ static override keyType = 'int' as const
5
7
  static override timestamps = false
6
8
  static override fillable = ['uuid', 'type', 'request_id', 'content', 'tags', 'created_at']
7
- static override casts: Record<string, string> = {
9
+ static override casts: Record<string, 'int' | 'float' | 'boolean' | 'string' | 'json' | 'date' | 'datetime' | 'array'> = {
8
10
  id: 'int',
9
11
  created_at: 'int',
10
12
  }
@@ -3,12 +3,14 @@ import { Model } from '@mantiq/database'
3
3
  export class ExceptionGroupModel extends Model {
4
4
  static override table = 'heartbeat_exception_groups'
5
5
  static override primaryKey = 'fingerprint'
6
+ static override incrementing = false
7
+ static override keyType = 'string' as const
6
8
  static override timestamps = false
7
9
  static override fillable = [
8
10
  'fingerprint', 'class', 'message', 'count',
9
11
  'first_seen_at', 'last_seen_at', 'last_entry_uuid', 'resolved_at',
10
12
  ]
11
- static override casts: Record<string, string> = {
13
+ static override casts: Record<string, 'int' | 'float' | 'boolean' | 'string' | 'json' | 'date' | 'datetime' | 'array'> = {
12
14
  count: 'int',
13
15
  first_seen_at: 'int',
14
16
  last_seen_at: 'int',
@@ -2,9 +2,11 @@ import { Model } from '@mantiq/database'
2
2
 
3
3
  export class MetricModel extends Model {
4
4
  static override table = 'heartbeat_metrics'
5
+ static override incrementing = true
6
+ static override keyType = 'int' as const
5
7
  static override timestamps = false
6
8
  static override fillable = ['name', 'type', 'value', 'tags', 'period', 'bucket', 'created_at']
7
- static override casts: Record<string, string> = {
9
+ static override casts: Record<string, 'int' | 'float' | 'boolean' | 'string' | 'json' | 'date' | 'datetime' | 'array'> = {
8
10
  id: 'int',
9
11
  value: 'float',
10
12
  period: 'int',
@@ -2,12 +2,14 @@ import { Model } from '@mantiq/database'
2
2
 
3
3
  export class SpanModel extends Model {
4
4
  static override table = 'heartbeat_spans'
5
+ static override incrementing = true
6
+ static override keyType = 'int' as const
5
7
  static override timestamps = false
6
8
  static override fillable = [
7
9
  'trace_id', 'span_id', 'parent_span_id', 'name', 'type', 'status',
8
10
  'start_time', 'end_time', 'duration', 'attributes', 'events', 'created_at',
9
11
  ]
10
- static override casts: Record<string, string> = {
12
+ static override casts: Record<string, 'int' | 'float' | 'boolean' | 'string' | 'json' | 'date' | 'datetime' | 'array'> = {
11
13
  id: 'int',
12
14
  start_time: 'int',
13
15
  end_time: 'int',
@@ -54,10 +54,10 @@ export class HeartbeatStore {
54
54
  * Query entries with optional type filter and pagination.
55
55
  */
56
56
  async getEntries(options: {
57
- type?: EntryType
58
- limit?: number
59
- offset?: number
60
- requestId?: string
57
+ type?: EntryType | undefined
58
+ limit?: number | undefined
59
+ offset?: number | undefined
60
+ requestId?: string | undefined
61
61
  } = {}): Promise<HeartbeatEntry[]> {
62
62
  const { type, limit = 50, offset = 0, requestId } = options
63
63
 
@@ -14,7 +14,7 @@ export class Span {
14
14
  endTime: number | null = null
15
15
  status: SpanStatus = 'ok'
16
16
  attributes: Record<string, string | number | boolean> = {}
17
- events: Array<{ name: string; timestamp: number; attributes?: Record<string, any> }> = []
17
+ events: Array<{ name: string; timestamp: number; attributes?: Record<string, any> | undefined }> = []
18
18
 
19
19
  constructor(
20
20
  traceId: string,
@@ -26,7 +26,7 @@ export class CacheWatcher extends Watcher {
26
26
  duration: null,
27
27
  }
28
28
 
29
- const tags = [operation]
29
+ const tags: string[] = [operation]
30
30
  if (operation === 'miss') tags.push('cache-miss')
31
31
 
32
32
  this.record('cache', content, tags)
@@ -27,7 +27,7 @@ export class ScheduleWatcher extends Watcher {
27
27
  status: data.status,
28
28
  }
29
29
 
30
- const tags = [data.status]
30
+ const tags: string[] = [data.status]
31
31
  if (data.status === 'error') tags.push('failed')
32
32
 
33
33
  this.record('schedule', content, tags)