@aidra/pmx-io 9.19.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 +6 -0
- package/lib/activate.js +4 -2
- package/lib/bun.js +13 -1
- package/lib/callmap.js +427 -0
- package/lib/esm-hooks.mjs +3 -3
- package/lib/inbound.js +19 -5
- package/lib/itg/common.js +89 -2
- package/lib/itg/ioredis.js +36 -23
- package/lib/itg/mongodb.js +37 -3
- package/lib/itg/mysql2.js +26 -20
- package/lib/itg/noderedis.js +20 -13
- package/lib/itg/pg.js +19 -15
- package/lib/itg/postgres.js +17 -13
- package/lib/modules.js +18 -3
- package/lib/outbound.js +26 -8
- package/lib/pmx.js +55 -2
- package/lib/relay/transport.js +1 -1
- package/lib/standalone/transport.js +17 -0
- package/lib/tracing/control.js +11 -5
- package/package.json +1 -1
- package/preload.mjs +2 -1
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 /
|
|
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
|
|
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
|
|
4
|
-
// enabled at spawn: `module.registerHooks` (in-thread, Node ≥ 22.15 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|