@aidra/pmx-io 9.18.0 → 9.20.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/index.d.ts CHANGED
@@ -142,6 +142,12 @@ export interface IOConfig {
142
142
  proxy?: string
143
143
  }
144
144
  tracing?: boolean | TracingConfig
145
+ /**
146
+ * count every call this process makes or receives (HTTP, databases) per minute for prodman's
147
+ * Service Map, with tracing on or off (83 CMP-001); default env PMX_IO_SERVICE_MAP, else true.
148
+ * prodman's setting (`service_map`) takes precedence.
149
+ */
150
+ serviceMap?: boolean
145
151
  /** report exceptions that frameworks turn into ≥ 500 responses (80 NIO-223), default true */
146
152
  httpErrors?: boolean
147
153
  /** instrument Node descendants and worker threads through this process (80 NIO-290), default true */
package/lib/activate.js CHANGED
@@ -347,11 +347,13 @@ function entryIsEsm (argv, execArgv) {
347
347
 
348
348
  /**
349
349
  * NIO-085 / NIO-210: register the module customization hooks that wrap ES-module libraries,
350
- * only when tracing is enabled (called from preload.mjs / standalone.mjs).
350
+ * only when tracing or the call map (83 CMP-003) is enabled (called from preload.mjs /
351
+ * standalone.mjs).
351
352
  */
352
353
  function registerEsmHooks () {
353
354
  const s = state()
354
- if (!s.active || s.esmHooked || !s.instance || !s.instance._tracingEnabled()) return false
355
+ if (!s.active || s.esmHooked || !s.instance) return false
356
+ if (!s.instance._tracingEnabled() && !(typeof s.instance._serviceMapEnabled === 'function' && s.instance._serviceMapEnabled())) return false
355
357
  let mod
356
358
  try {
357
359
  mod = require('module')
package/lib/bun.js CHANGED
@@ -37,9 +37,20 @@ function serverState (protocol, req, route) {
37
37
  function wrapHandler (fn, protocol, route) {
38
38
  const w = function (req) {
39
39
  const c = inbound.consumers
40
- if (!c.metrics && !c.tracing && !c.errors) return fn.apply(this, arguments)
40
+ if (!c.metrics && !c.tracing && !c.errors && !c.callmap) return fn.apply(this, arguments)
41
41
  const doneMetric = c.metrics && c.metrics.start ? c.metrics.start(protocol) : null
42
42
  const st = serverState(protocol, req, route)
43
+ const cm = c.callmap
44
+ let key = null
45
+ if (cm) {
46
+ // 83 CMP-011: the server's port and the client's address (Bun.serve passes the server)
47
+ try {
48
+ const server = arguments[1]
49
+ const ip = server && typeof server.requestIP === 'function' ? server.requestIP(req) : null
50
+ key = cm.httpInKeyOf(server && typeof server.port === 'number' ? server.port : null, ip && ip.address)
51
+ } catch (_) {}
52
+ }
53
+ const t0 = key !== null ? cm.now() : 0
43
54
  if (c.tracing) {
44
55
  try {
45
56
  const host = (req.headers && req.headers.get && req.headers.get('host')) || 'localhost'
@@ -84,6 +95,7 @@ function wrapHandler (fn, protocol, route) {
84
95
  c.tracing.finishServerSpan(st.span, status, st.route, st.error)
85
96
  } catch (_) {}
86
97
  }
98
+ if (key !== null) cm.record(key, cm.now() - t0, status >= 500 || Boolean(st.error))
87
99
  }
88
100
  let r
89
101
  try {
package/lib/callmap.js ADDED
@@ -0,0 +1,427 @@
1
+ // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
+ 'use strict'
3
+
4
+ // The call map (83-call-map.md): every outbound HTTP request, inbound HTTP request and database
5
+ // operation is counted per UTC minute and key — whether tracing is on or not (CMP-002) — and the
6
+ // finished minute is sent as one `service:calls` message (CMP-020…022).
7
+ //
8
+ // Recording (CMP-004) allocates nothing once a key exists: the hooks resolve a Key object (cached
9
+ // in nested Maps by the strings and numbers they already hold, or on the connection / socket),
10
+ // and the Key remembers its record for the current minute. Records of a minute live in a bucket
11
+ // keyed by the key's identity string, so two Key objects with the same fields share one record.
12
+
13
+ const MINUTE = 60000
14
+ const MAX_KEYS = 200 // CMP-014
15
+ const BUCKETS = 74 // CMP-015
16
+ const MAX_BYTES = 256 * 1024 // CMP-022
17
+ const MAX_DELAY_MS = 5000 // CMP-020
18
+ const KEY_CACHE_MAX = 5000 // Key objects kept for lookups (a bound, not a protocol limit)
19
+ const NAME_MAX = 100
20
+ const OTHER = '(other)'
21
+ // {"type":"service:calls","data":{"v":1,"minute":1234567890123,"calls":[]}}
22
+ const ENVELOPE_BYTES = 80
23
+
24
+ const FIELDS = {
25
+ http_out: ['host', 'port', 'scheme'],
26
+ http_in: ['local_port', 'peer'],
27
+ db: ['system', 'host', 'port', 'name']
28
+ }
29
+ const NUMERIC = new Set(['port', 'local_port'])
30
+
31
+ // `on` is the one flag the hooks check (CMP-003 / ITG-007)
32
+ const state = { on: false }
33
+ // the message size limit (changed by the tests only)
34
+ const limits = { maxBytes: MAX_BYTES }
35
+ let send = null // (data) => void
36
+ let epoch = 0 // raised by every flush of the current minute: Keys re-resolve their record
37
+ const buckets = new Map() // minute → { minute, recs: Map<id, rec>, count, overflow: {kind: rec} }
38
+ let timer = null
39
+ let timerMinute = -1
40
+ let exitHooked = false
41
+ let keyCount = 0
42
+
43
+ let perf = null
44
+ /** Monotonic milliseconds (resolved on first use, NIO-024). */
45
+ function now () {
46
+ if (perf === null) perf = globalThis.performance || require('perf_hooks').performance
47
+ return perf.now()
48
+ }
49
+
50
+ /** CMP-015: 0 for < 1 ms, else min(73, 1 + floor(4 · log2(ms))). */
51
+ function bucketOf (ms) {
52
+ if (!(ms >= 1)) return 0
53
+ const b = 1 + Math.floor(4 * Math.log2(ms))
54
+ return b > BUCKETS - 1 ? BUCKETS - 1 : b
55
+ }
56
+
57
+ class Key {
58
+ constructor (kind, fields) {
59
+ this.kind = kind
60
+ this.fields = fields
61
+ let id = kind
62
+ for (const f of FIELDS[kind]) id += '\u0000' + (fields[f] === null ? '' : String(fields[f]))
63
+ this.id = id
64
+ this.minute = -1
65
+ this.epoch = -1
66
+ this.rec = null
67
+ }
68
+ }
69
+
70
+ // ------------------------------------------------------------------------------ key caches
71
+
72
+ let outKeys = new Map() // scheme → host → authority → Key (ClientRequest)
73
+ let originKeys = new Map() // origin → Key (fetch)
74
+ let inKeys = new Map() // local port → peer → Key
75
+ let dbKeys = new Map() // system → host → port → name → Key
76
+ let byId = new Map() // id → Key (shares Keys between the caches)
77
+
78
+ function sharedKey (kind, fields) {
79
+ if (keyCount >= KEY_CACHE_MAX) {
80
+ // bounded: dropped Keys are made again (their records are found by id)
81
+ outKeys = new Map()
82
+ originKeys = new Map()
83
+ inKeys = new Map()
84
+ dbKeys = new Map()
85
+ byId = new Map()
86
+ keyCount = 0
87
+ }
88
+ const k = new Key(kind, fields)
89
+ const prev = byId.get(k.id)
90
+ if (prev) return prev
91
+ byId.set(k.id, k)
92
+ keyCount++
93
+ return k
94
+ }
95
+
96
+ function sub (map, k) {
97
+ let m = map.get(k)
98
+ if (m === undefined) {
99
+ m = new Map()
100
+ map.set(k, m)
101
+ }
102
+ return m
103
+ }
104
+
105
+ /** CMP-010 host: lowercased, no trailing dot, no userinfo, IPv6 without brackets. */
106
+ function normHost (host) {
107
+ let h = String(host || '')
108
+ const at = h.lastIndexOf('@')
109
+ if (at !== -1) h = h.slice(at + 1)
110
+ if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1)
111
+ if (h.endsWith('.')) h = h.slice(0, -1)
112
+ return h.toLowerCase() || 'localhost'
113
+ }
114
+
115
+ function defaultPort (scheme) {
116
+ return scheme === 'https' ? 443 : 80
117
+ }
118
+
119
+ /**
120
+ * CMP-010 for a ClientRequest: `protocol` ('http:' | 'https:'), `host` (req.host, the host the
121
+ * request connects to) and the Host header (its port, else the scheme's default).
122
+ */
123
+ function httpOutKey (protocol, host, authority) {
124
+ const byHost = sub(outKeys, protocol)
125
+ const byAuth = sub(byHost, host)
126
+ const a = typeof authority === 'string' ? authority : ''
127
+ let k = byAuth.get(a)
128
+ if (k !== undefined) return k
129
+ const scheme = protocol === 'https:' ? 'https' : 'http'
130
+ let port = null
131
+ const m = /:(\d+)$/.exec(a)
132
+ if (m && !/^\[[^\]]*$/.test(a.slice(0, m.index))) port = Number(m[1])
133
+ k = sharedKey('http_out', { host: normHost(host), port: port || defaultPort(scheme), scheme })
134
+ byAuth.set(a, k)
135
+ return k
136
+ }
137
+
138
+ /** CMP-010 for fetch: the request's origin (`http://host:port`). */
139
+ function originKey (origin) {
140
+ let k = originKeys.get(origin)
141
+ if (k !== undefined) return k
142
+ let scheme = 'http'
143
+ let host = 'localhost'
144
+ let port = null
145
+ try {
146
+ const u = new URL(String(origin))
147
+ scheme = u.protocol === 'https:' ? 'https' : 'http'
148
+ host = u.hostname
149
+ port = u.port ? Number(u.port) : null
150
+ } catch (_) {}
151
+ k = sharedKey('http_out', { host: normHost(host), port: port || defaultPort(scheme), scheme })
152
+ originKeys.set(origin, k)
153
+ return k
154
+ }
155
+
156
+ /** CMP-010 for a URL (Bun's fetch): parsed every call. */
157
+ function urlKey (url) {
158
+ try {
159
+ const u = url instanceof URL ? url : new URL(String(url))
160
+ return originKey(u.origin)
161
+ } catch (_) {
162
+ return originKey('')
163
+ }
164
+ }
165
+
166
+ const SOCKET_KEY = Symbol('pmx-callmap-in')
167
+
168
+ /** CMP-011: (local port, peer) of the request's socket, cached on the socket. */
169
+ function httpInKey (socket) {
170
+ if (!socket) return inKey(null, null)
171
+ let k
172
+ try {
173
+ k = socket[SOCKET_KEY]
174
+ if (k !== undefined) return k
175
+ k = httpInKeyOf(socket.localPort, socket.remoteAddress)
176
+ socket[SOCKET_KEY] = k
177
+ } catch (_) {
178
+ k = inKey(null, null)
179
+ }
180
+ return k
181
+ }
182
+
183
+ /** CMP-011 from a local port and a peer address (IPv4-mapped IPv6 as IPv4); null when unknown. */
184
+ function httpInKeyOf (localPort, address) {
185
+ const lp = typeof localPort === 'number' && localPort > 0 ? localPort : null
186
+ let peer = typeof address === 'string' && address ? address : null
187
+ if (peer !== null && peer.startsWith('::ffff:') && peer.indexOf('.') !== -1) peer = peer.slice(7)
188
+ return inKey(lp, peer)
189
+ }
190
+
191
+ function inKey (localPort, peer) {
192
+ const m = sub(inKeys, localPort)
193
+ let k = m.get(peer)
194
+ if (k === undefined) {
195
+ k = sharedKey('http_in', { local_port: localPort, peer })
196
+ m.set(peer, k)
197
+ }
198
+ return k
199
+ }
200
+
201
+ /** CMP-012: (system, host, port, name); port a number or null, name ≤ 100 characters or null. */
202
+ function dbKey (system, host, port, name) {
203
+ const m = sub(sub(sub(dbKeys, system), host), port)
204
+ let k = m.get(name)
205
+ if (k === undefined) {
206
+ const n = name === null || name === undefined || name === '' ? null : String(name).slice(0, NAME_MAX)
207
+ k = sharedKey('db', { system: String(system || 'other'), host: host === null || host === undefined ? null : String(host), port: port === null || port === undefined || !Number.isFinite(Number(port)) ? null : Number(port), name: n })
208
+ m.set(name, k)
209
+ }
210
+ return k
211
+ }
212
+
213
+ /** A db Key from an integration's span attributes (server.address/port or net.peer.*, db.name). */
214
+ function dbKeyFromTags (tags) {
215
+ const system = tags['db.system']
216
+ const host = tags['server.address'] !== undefined ? tags['server.address'] : tags['net.peer.name']
217
+ const rawPort = tags['server.port'] !== undefined ? tags['server.port'] : tags['net.peer.port']
218
+ const port = rawPort === undefined || rawPort === null || rawPort === '' ? null : Number(rawPort)
219
+ const name = system === 'redis' ? null : (tags['db.name'] !== undefined ? tags['db.name'] : tags['db.namespace'])
220
+ return dbKey(system, host === undefined ? null : host, Number.isFinite(port) ? port : null, name === undefined ? null : name)
221
+ }
222
+
223
+ // ------------------------------------------------------------------------------ recording
224
+
225
+ function newRec (kind, fields) {
226
+ return { kind, fields, calls: 0, errors: 0, sum: 0, hist: new Uint32Array(BUCKETS) }
227
+ }
228
+
229
+ function overflowFields (kind) {
230
+ const o = {}
231
+ for (const f of FIELDS[kind]) o[f] = NUMERIC.has(f) ? null : OTHER
232
+ return o
233
+ }
234
+
235
+ function recFor (key, minute) {
236
+ let b = buckets.get(minute)
237
+ if (b === undefined) {
238
+ b = { minute, recs: new Map(), count: 0, overflow: {} }
239
+ buckets.set(minute, b)
240
+ schedule(minute)
241
+ }
242
+ let rec = b.recs.get(key.id)
243
+ if (rec !== undefined) return rec
244
+ if (b.count >= MAX_KEYS) {
245
+ // CMP-014: further keys of this minute go to the kind's overflow record
246
+ rec = b.overflow[key.kind]
247
+ if (rec === undefined) rec = b.overflow[key.kind] = newRec(key.kind, overflowFields(key.kind))
248
+ return rec
249
+ }
250
+ rec = newRec(key.kind, key.fields)
251
+ b.recs.set(key.id, rec)
252
+ b.count++
253
+ return rec
254
+ }
255
+
256
+ /** Count one call of `key` that took `ms` milliseconds (CMP-010…015). */
257
+ function record (key, ms, error) {
258
+ if (!state.on || key === null || key === undefined) return
259
+ const t = Date.now()
260
+ const minute = t - (t % MINUTE)
261
+ let rec = key.rec
262
+ if (key.minute !== minute || key.epoch !== epoch) {
263
+ rec = recFor(key, minute)
264
+ key.rec = rec
265
+ key.minute = minute
266
+ key.epoch = epoch
267
+ }
268
+ rec.calls++
269
+ if (error) rec.errors++
270
+ if (ms > 0) rec.sum += ms
271
+ else ms = 0
272
+ rec.hist[bucketOf(ms)]++
273
+ }
274
+
275
+ // ------------------------------------------------------------------------------ sending
276
+
277
+ function encode (rec) {
278
+ const o = { kind: rec.kind }
279
+ for (const f of FIELDS[rec.kind]) o[f] = rec.fields[f]
280
+ o.calls = rec.calls
281
+ o.errors = rec.errors
282
+ o.duration_sum_ms = Math.round(rec.sum * 1000) / 1000
283
+ let n = BUCKETS
284
+ while (n > 0 && rec.hist[n - 1] === 0) n-- // CMP-015: trailing zero buckets omitted
285
+ o.hist = Array.from(rec.hist.subarray(0, n))
286
+ return o
287
+ }
288
+
289
+ /** CMP-021/022: the bucket as one or more `service:calls` messages of at most 256 KiB. */
290
+ function sendBucket (b) {
291
+ const all = Array.from(b.recs.values())
292
+ for (const k of Object.keys(b.overflow)) all.push(b.overflow[k])
293
+ if (all.length === 0 || !send) return
294
+ let calls = []
295
+ let bytes = ENVELOPE_BYTES
296
+ const out = () => {
297
+ if (calls.length === 0) return
298
+ try {
299
+ send({ v: 1, minute: b.minute, calls })
300
+ } catch (_) {}
301
+ calls = []
302
+ bytes = ENVELOPE_BYTES
303
+ }
304
+ for (const rec of all) {
305
+ if (rec.calls === 0) continue
306
+ const o = encode(rec)
307
+ const size = Buffer.byteLength(JSON.stringify(o)) + 1
308
+ if (calls.length && bytes + size > limits.maxBytes) out()
309
+ calls.push(o)
310
+ bytes += size
311
+ }
312
+ out()
313
+ }
314
+
315
+ /** Send and drop the finished minutes (all minutes when `all`, CMP-020 shutdown flush). */
316
+ function flush (all) {
317
+ const t = Date.now()
318
+ const current = t - (t % MINUTE)
319
+ for (const [minute, b] of Array.from(buckets)) {
320
+ if (!all && minute >= current) continue
321
+ buckets.delete(minute)
322
+ sendBucket(b)
323
+ }
324
+ if (all) epoch++
325
+ if (buckets.size === 0) clearTimer()
326
+ else if (timer === null) schedule(Math.min.apply(null, Array.from(buckets.keys())))
327
+ }
328
+
329
+ function clearTimer () {
330
+ if (timer !== null) clearTimeout(timer)
331
+ timer = null
332
+ timerMinute = -1
333
+ }
334
+
335
+ /** CMP-020: shortly after `minute` ends, with a random delay of 0–5 s. */
336
+ function schedule (minute) {
337
+ if (timer !== null && timerMinute <= minute) return
338
+ clearTimer()
339
+ const delay = Math.max(0, minute + MINUTE - Date.now()) + Math.floor(Math.random() * MAX_DELAY_MS)
340
+ timerMinute = minute
341
+ timer = setTimeout(() => {
342
+ timer = null
343
+ timerMinute = -1
344
+ flush(false)
345
+ }, delay)
346
+ if (timer.unref) timer.unref()
347
+ }
348
+
349
+ function onExit () {
350
+ try {
351
+ flush(true)
352
+ } catch (_) {}
353
+ }
354
+
355
+ // ------------------------------------------------------------------------------ switch
356
+
357
+ /**
358
+ * Switch the call map on or off (CMP-001: at once). `sender(data)` sends one message's data. On
359
+ * switch-off what was recorded is sent now.
360
+ */
361
+ function setEnabled (on, sender) {
362
+ if (sender !== undefined) send = sender
363
+ if (on) {
364
+ state.on = true
365
+ if (!exitHooked) {
366
+ exitHooked = true
367
+ process.on('exit', onExit)
368
+ }
369
+ return
370
+ }
371
+ if (state.on) flush(true)
372
+ state.on = false
373
+ }
374
+
375
+ /** Stop and drop everything (destroy()); what was recorded is sent first. */
376
+ function stop () {
377
+ if (state.on) flush(true)
378
+ state.on = false
379
+ send = null
380
+ buckets.clear()
381
+ clearTimer()
382
+ if (exitHooked) {
383
+ exitHooked = false
384
+ process.removeListener('exit', onExit)
385
+ }
386
+ }
387
+
388
+ /** CMP-001: PMX_IO_SERVICE_MAP as a boolean, or undefined (unset or not recognised). */
389
+ function envValue (env) {
390
+ const raw = (env || process.env).PMX_IO_SERVICE_MAP
391
+ if (typeof raw !== 'string') return undefined
392
+ const v = raw.trim().toLowerCase()
393
+ if (v === '0' || v === 'false' || v === 'off') return false
394
+ if (v === '1' || v === 'true' || v === 'on') return true
395
+ return undefined
396
+ }
397
+
398
+ /** CMP-001: { value, source } — prodman layer > init({ serviceMap }) > env > default true. */
399
+ function resolve (layer, appValue, env) {
400
+ if (layer && typeof layer.service_map === 'boolean') return { value: layer.service_map, source: 'prodman' }
401
+ if (typeof appValue === 'boolean') return { value: appValue, source: 'config' }
402
+ const e = envValue(env)
403
+ if (e !== undefined) return { value: e, source: 'env' }
404
+ return { value: true, source: 'default' }
405
+ }
406
+
407
+ module.exports = {
408
+ state,
409
+ now,
410
+ record,
411
+ bucketOf,
412
+ httpOutKey,
413
+ originKey,
414
+ urlKey,
415
+ httpInKey,
416
+ httpInKeyOf,
417
+ dbKey,
418
+ dbKeyFromTags,
419
+ flush,
420
+ setEnabled,
421
+ stop,
422
+ resolve,
423
+ envValue,
424
+ limits,
425
+ MAX_KEYS,
426
+ MAX_BYTES
427
+ }
package/lib/esm-hooks.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
2
 
3
- // Module customization hooks (80 NIO-085, NIO-210), registered by preload.mjs only when tracing is
4
- // enabled at spawn: `module.registerHooks` (in-thread, Node ≥ 22.15 / ≥ 23.5) or `module.register`
5
- // (off-thread, Node ≥ 18.19 / ≥ 20.6). For an ES-module file of a target library the `load` hook
3
+ // Module customization hooks (80 NIO-085, NIO-210), registered by preload.mjs only when tracing or
4
+ // the call map (83 CMP-003) is enabled at spawn: `module.registerHooks` (in-thread, Node ≥ 22.15 /
5
+ // ≥ 23.5) or `module.register` (off-thread, Node ≥ 18.19 / ≥ 20.6). For an ES-module file of a target library the `load` hook
6
6
  // returns a wrapper module instead:
7
7
  //
8
8
  // import * as ns from '<url>?pmx-io-orig'
package/lib/inbound.js CHANGED
@@ -5,7 +5,9 @@
5
5
  // the framework exception reports (NIO-223). One process-wide interception (hooks.js) calls the
6
6
  // active consumers; a per-request state object travels on the request and the response.
7
7
  //
8
- // The state: { protocol, req, res, span, store, route, routeFn, error, info, start, done }.
8
+ // The call map (83 CMP-011, consumer `callmap`) counts every request through the same state.
9
+ //
10
+ // The state: { protocol, req, res, span, store, route, routeFn, error, info, key, t0, done }.
9
11
  // Framework integrations find it with stateOf(req|res) and set `routeFn` (read when the
10
12
  // response starts, NIO-221) and `error`/`info` (NIO-223).
11
13
 
@@ -14,7 +16,7 @@ const context = require('./tracing/context')
14
16
 
15
17
  const STATE = Symbol.for('@pmx/io:request-state')
16
18
 
17
- const consumers = { metrics: null, tracing: null, errors: null }
19
+ const consumers = { metrics: null, tracing: null, errors: null, callmap: null }
18
20
  let installedWriteHead = false
19
21
 
20
22
  function stateOf (obj) {
@@ -29,7 +31,7 @@ function attach (obj, st) {
29
31
  }
30
32
 
31
33
  function anyConsumer () {
32
- return Boolean(consumers.metrics || consumers.tracing || consumers.errors)
34
+ return Boolean(consumers.metrics || consumers.tracing || consumers.errors || consumers.callmap)
33
35
  }
34
36
 
35
37
  /** Read the framework route when the response starts (NIO-221). */
@@ -121,6 +123,11 @@ function finish (st, aborted) {
121
123
  consumers.tracing.endServer(st, status, aborted)
122
124
  } catch (_) {}
123
125
  }
126
+ const cm = consumers.callmap
127
+ if (cm !== null && st.key) {
128
+ // CMP-011: an error is a status ≥ 500 or an exception that ended the request
129
+ cm.record(st.key, cm.now() - st.t0, status >= 500 || Boolean(st.error))
130
+ }
124
131
  }
125
132
 
126
133
  /** The process-wide request handler (hooks.js). Returns the store to run listeners in. */
@@ -131,11 +138,18 @@ function onRequest (protocol, req, res) {
131
138
  m.request(protocol, req, res)
132
139
  } catch (_) {}
133
140
  }
134
- if (!consumers.tracing && !consumers.errors) return undefined
141
+ const cm = consumers.callmap
142
+ if (!consumers.tracing && !consumers.errors && cm === null) return undefined
135
143
  if (!res || typeof res.once !== 'function') return undefined
136
144
  const prev = req[STATE]
137
145
  if (prev && !prev.done) return prev.store // the same request seen twice (nested servers)
138
- const st = { protocol, req, res, span: null, store: undefined, route: undefined, routeFn: null, error: null, info: null, done: false }
146
+ let key = null
147
+ if (cm !== null) {
148
+ try {
149
+ key = cm.httpInKey(req.socket)
150
+ } catch (_) {}
151
+ }
152
+ const st = { protocol, req, res, span: null, store: undefined, route: undefined, routeFn: null, error: null, info: null, key, t0: key !== null ? cm.now() : 0, done: false }
139
153
  attach(req, st)
140
154
  attach(res, st)
141
155
  if (consumers.tracing) {
package/lib/itg/common.js CHANGED
@@ -4,15 +4,18 @@
4
4
  // Common rules of the database and broker integrations (82 §1, §2, §4; the Node counterpart of
5
5
  // pmx-io's _msg.py / _db.py helpers): switches, CLIENT/PRODUCER/CONSUMER/receive span creation
6
6
  // and parenting, error marking, promise/callback completion, header carriers, links for batches,
7
- // iterator-style consumer spans and the exit flush of open iterator spans. The per-library modules
7
+ // iterator-style consumer spans and the exit flush of open iterator spans, and the call map's
8
+ // records of database operations (83 CMP-003, CMP-012). The per-library modules
8
9
  // (lib/itg/<library>.js) only map library calls onto these helpers.
9
10
  //
10
11
  // Everything here is defensive: a failure in pmx code is logged under `axm:tracing` and never
11
- // reaches the app (ITG-003). Loaded only when an instrumented library is loaded with tracing on.
12
+ // reaches the app (ITG-003). Loaded only when an instrumented library is loaded with tracing or the
13
+ // call map on.
12
14
 
13
15
  const ctx = require('../tracing/context')
14
16
  const w3c = require('../tracing/w3c')
15
17
  const debug = require('../debug')('axm:tracing')
18
+ const callmap = require('../callmap')
16
19
 
17
20
  const MODULES = Symbol.for('@pmx/io:modules')
18
21
  const ERROR_MAX = 500
@@ -45,6 +48,86 @@ function msgTracer () {
45
48
  return t && t.messaging ? t : null
46
49
  }
47
50
 
51
+ // ---------------------------------------------------------------------------------------------
52
+ // call map (83 CMP-003, CMP-012)
53
+
54
+ const mapState = callmap.state
55
+
56
+ /** True while the call map is on (a database wrapper then records every operation). */
57
+ function mapOn () {
58
+ return mapState.on
59
+ }
60
+
61
+ const NO_ATTRS = Object.freeze({})
62
+
63
+ /**
64
+ * One database operation for the call map, used in place of its span (or of no span: tracing
65
+ * off, its switch off, or no parent span, ITG-010): the span's methods are forwarded, an ERROR
66
+ * status marks the call failed (CMP-012: the operation raised or rejected) and end() records it.
67
+ */
68
+ class Call {
69
+ constructor (span, key) {
70
+ this.span = span
71
+ this.key = key
72
+ this.t0 = callmap.now()
73
+ this.failed = false
74
+ this.ended = false
75
+ }
76
+
77
+ get attributes () {
78
+ return this.span ? this.span.attributes : NO_ATTRS
79
+ }
80
+
81
+ setAttribute (k, v) {
82
+ if (this.span) this.span.setAttribute(k, v)
83
+ return this
84
+ }
85
+
86
+ setStatus (code, message) {
87
+ if (code === 'ERROR') this.failed = true
88
+ if (this.span) this.span.setStatus(code, message)
89
+ return this
90
+ }
91
+
92
+ updateName (name) {
93
+ if (this.span) this.span.updateName(name)
94
+ return this
95
+ }
96
+
97
+ end () {
98
+ if (this.ended) return
99
+ this.ended = true
100
+ try {
101
+ callmap.record(this.key, callmap.now() - this.t0, this.failed)
102
+ } catch (_) {}
103
+ if (this.span) this.span.end()
104
+ }
105
+ }
106
+
107
+ /** The span to finish for an operation: `span` itself with the call map off, else a Call (or null). */
108
+ function dbCall (span, key) {
109
+ if (!mapState.on || key === null || key === undefined) return span
110
+ return new Call(span, key)
111
+ }
112
+
113
+ const KEYS = new WeakMap()
114
+
115
+ /** The call map key of an operation on `owner` (a connection or client), cached on it; null when off. */
116
+ function dbKeyOf (owner, tagsOf) {
117
+ if (!mapState.on) return null
118
+ const obj = owner !== null && (typeof owner === 'object' || typeof owner === 'function')
119
+ let k = obj ? KEYS.get(owner) : undefined
120
+ if (k !== undefined) return k
121
+ try {
122
+ k = callmap.dbKeyFromTags(tagsOf(owner))
123
+ } catch (err) {
124
+ debug('call map key failed: %s', err && err.message)
125
+ return null
126
+ }
127
+ if (obj) KEYS.set(owner, k)
128
+ return k
129
+ }
130
+
48
131
  /** The current span (a span of this process or, in bridge mode, of the app's SDK), or undefined. */
49
132
  function currentSpan () {
50
133
  const s = ctx.current()
@@ -614,6 +697,10 @@ function versionOf (dir) {
614
697
  }
615
698
 
616
699
  module.exports = {
700
+ mapOn,
701
+ dbCall,
702
+ dbKeyOf,
703
+ Call,
617
704
  tracer,
618
705
  dbTracer,
619
706
  msgTracer,