@aidra/pmx-io 0.0.0-stage → 9.13.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.
Files changed (67) hide show
  1. package/README.md +69 -2
  2. package/bin/pmx-io-node.js +143 -0
  3. package/index.d.ts +381 -0
  4. package/index.js +27 -0
  5. package/index.mjs +47 -0
  6. package/lib/actions.js +120 -0
  7. package/lib/activate.js +402 -0
  8. package/lib/application.js +106 -0
  9. package/lib/bun.js +155 -0
  10. package/lib/config.js +67 -0
  11. package/lib/constants.js +17 -0
  12. package/lib/debug.js +48 -0
  13. package/lib/esm-hooks.mjs +111 -0
  14. package/lib/exit.js +79 -0
  15. package/lib/features/metrics.js +409 -0
  16. package/lib/features/notify.js +358 -0
  17. package/lib/features/profiling.js +208 -0
  18. package/lib/hooks.js +151 -0
  19. package/lib/inbound.js +174 -0
  20. package/lib/integrations.js +372 -0
  21. package/lib/itg/amqplib.js +169 -0
  22. package/lib/itg/bullmq.js +141 -0
  23. package/lib/itg/common.js +657 -0
  24. package/lib/itg/dbcomment.js +114 -0
  25. package/lib/itg/index.js +103 -0
  26. package/lib/itg/ioredis.js +212 -0
  27. package/lib/itg/kafkajs.js +205 -0
  28. package/lib/itg/mongodb.js +322 -0
  29. package/lib/itg/mysql2.js +205 -0
  30. package/lib/itg/nats.js +531 -0
  31. package/lib/itg/noderedis.js +236 -0
  32. package/lib/itg/pg.js +253 -0
  33. package/lib/itg/pgqueue.js +305 -0
  34. package/lib/itg/postgres.js +252 -0
  35. package/lib/itg/redis-common.js +265 -0
  36. package/lib/itg/sql.js +302 -0
  37. package/lib/metrics/service.js +286 -0
  38. package/lib/metrics/types.js +342 -0
  39. package/lib/modules.js +260 -0
  40. package/lib/node-options.js +62 -0
  41. package/lib/otel/bridge.js +797 -0
  42. package/lib/otel/env.js +114 -0
  43. package/lib/outbound.js +362 -0
  44. package/lib/pmx.js +757 -0
  45. package/lib/relay/hub.js +279 -0
  46. package/lib/relay/transport.js +253 -0
  47. package/lib/standalone/activate.js +307 -0
  48. package/lib/standalone/capture.js +55 -0
  49. package/lib/standalone/container.js +909 -0
  50. package/lib/standalone/transport.js +1023 -0
  51. package/lib/tracing/context.js +90 -0
  52. package/lib/tracing/control.js +124 -0
  53. package/lib/tracing/exporter.js +115 -0
  54. package/lib/tracing/index.js +242 -0
  55. package/lib/tracing/logs.js +176 -0
  56. package/lib/tracing/options.js +105 -0
  57. package/lib/tracing/paths.js +34 -0
  58. package/lib/tracing/tracer.js +850 -0
  59. package/lib/tracing/w3c.js +415 -0
  60. package/lib/transport/ipc.js +97 -0
  61. package/lib/transport/ws.js +511 -0
  62. package/lib/util.js +109 -0
  63. package/package.json +48 -4
  64. package/preload.cjs +26 -0
  65. package/preload.mjs +25 -0
  66. package/standalone.cjs +16 -0
  67. package/standalone.mjs +17 -0
@@ -0,0 +1,114 @@
1
+ // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
+ 'use strict'
3
+
4
+ // Database trace tag (96 §10 SVM-500…505, 82 ITG-015/ITG-016, 02 D-47/D-49; same results as
5
+ // pmx-io). `tracing.db.comment` = `on` (default) | `off` | `force`: the W3C context
6
+ // of the database CLIENT span is prepended to SQL statements as a sqlcommenter-encoded block
7
+ // comment (`/*traceparent='00-<trace>-<span>-01',application='<app>'*/ `), and set as the `comment`
8
+ // of MongoDB commands that have none, so database-side records (pmx service modules) join the
9
+ // trace. Only for sampled traces; never for named/cached prepared statements unless `force`.
10
+ // `tracing.db.applicationName` (default true) fills PostgreSQL `application_name` / MongoDB
11
+ // `appName` with the pmx app name when the app set none.
12
+
13
+ const APP_NAME_MAX = 63 // PostgreSQL NAMEDATALEN - 1 (bytes)
14
+
15
+ /** sqlcommenter value encoding: URL-encoded, `!'()*` too (same as Python's quote(v, safe='')). */
16
+ function encode (v) {
17
+ return encodeURIComponent(String(v)).replace(/[!'()*]/g, (ch) => '%' + ch.charCodeAt(0).toString(16).toUpperCase())
18
+ }
19
+
20
+ /** The pmx app name (env `name`, set by pmx), else the tracer's service name; '' when unknown. */
21
+ function appName (t) {
22
+ const n = process.env.name || (t && t.serviceName) || ''
23
+ return typeof n === 'string' ? n : ''
24
+ }
25
+
26
+ /** `s` cut to at most `max` UTF-8 bytes, never inside a character. */
27
+ function cutBytes (s, max) {
28
+ if (Buffer.byteLength(s) <= max) return s
29
+ let out = ''
30
+ let len = 0
31
+ for (const ch of s) {
32
+ const b = Buffer.byteLength(ch)
33
+ if (len + b > max) break
34
+ out += ch
35
+ len += b
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** The application_name / appName to set, or '' (option off or no name). */
41
+ function connectionAppName (t) {
42
+ if (!t || !t.db || t.db.applicationName === false) return ''
43
+ return cutBytes(appName(t), APP_NAME_MAX)
44
+ }
45
+
46
+ const TRACE_ID = /^[0-9a-f]{32}$/
47
+ const SPAN_ID = /^[0-9a-f]{16}$/
48
+
49
+ /** W3C ids only: 32 / 16 lowercase hex digits, not all zero (anything else is never tagged). */
50
+ function validIds (traceId, spanId) {
51
+ return typeof traceId === 'string' && typeof spanId === 'string' &&
52
+ TRACE_ID.test(traceId) && SPAN_ID.test(spanId) &&
53
+ traceId !== '00000000000000000000000000000000' && spanId !== '0000000000000000'
54
+ }
55
+
56
+ function spanIds (span) {
57
+ if (!span) return null
58
+ try {
59
+ const sc = typeof span.spanContext === 'function' ? span.spanContext() : span
60
+ if (!sc || !validIds(sc.traceId, sc.spanId)) return null
61
+ const sampled = typeof sc.traceFlags === 'number' ? (sc.traceFlags & 1) === 1 : Boolean(span.sampled)
62
+ return sampled ? sc : null
63
+ } catch (_) {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /** The comment body (`traceparent='…'[,application='…']`) for `traceId`/`spanId`. */
69
+ function body (t, traceId, spanId) {
70
+ let s = "traceparent='" + encode('00-' + traceId + '-' + spanId + '-01') + "'"
71
+ const app = appName(t)
72
+ if (app) s += ",application='" + encode(app) + "'"
73
+ return s
74
+ }
75
+
76
+ /**
77
+ * SVM-501: true when the statement `text` of `span` gets the comment. `prepared` = the driver
78
+ * uses a named/cached prepared statement for it (skipped unless `force`).
79
+ */
80
+ function wants (t, span, text, prepared) {
81
+ const mode = t && t.db ? t.db.comment : 'off'
82
+ if (mode !== 'on' && mode !== 'force') return false
83
+ if (typeof text !== 'string' || text.includes('traceparent=')) return false
84
+ if (prepared && mode !== 'force') return false
85
+ return spanIds(span) !== null
86
+ }
87
+
88
+ /** SVM-502: the SQL prefix for `span` (`/*…*\/ `). Call only after wants() returned true. */
89
+ function sqlPrefix (t, span) {
90
+ const sc = spanIds(span)
91
+ return '/*' + body(t, sc.traceId, sc.spanId) + '*/ '
92
+ }
93
+
94
+ /** SVM-503: the MongoDB `comment` value for `traceId`/`spanId`. */
95
+ function mongoComment (t, traceId, spanId) {
96
+ return body(t, traceId, spanId)
97
+ }
98
+
99
+ /** True when mode is `force`. */
100
+ function forced (t) {
101
+ return Boolean(t && t.db && t.db.comment === 'force')
102
+ }
103
+
104
+ /** `tracing.db.comment` normalised: 'on' | 'off' | 'force' (true → on, false → off). */
105
+ function normMode (v) {
106
+ if (v === false) return 'off'
107
+ if (v === true || v === undefined || v === null) return 'on'
108
+ const s = String(v).trim().toLowerCase()
109
+ if (s === 'off' || s === 'false' || s === '0' || s === 'no') return 'off'
110
+ if (s === 'force') return 'force'
111
+ return 'on'
112
+ }
113
+
114
+ module.exports = { validIds, encode, appName, connectionAppName, cutBytes, wants, sqlPrefix, mongoComment, forced, normMode, spanIds, APP_NAME_MAX }
@@ -0,0 +1,103 @@
1
+ // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
+ 'use strict'
3
+
4
+ // The database and broker integrations of 82-integrations.md (80 NIO-250), declared for the
5
+ // module-loading interception of modules.js: { pkg, for, files: { '<file in package>': patch } }.
6
+ // `for` is 'db', 'messaging' or 'db+messaging' (82 ITG-001: a library is wrapped only while tracing
7
+ // and its switch are on). Each library's module is required only when one of its files is loaded
8
+ // by the app (ITG-002); a patch that does not find its entry points disables itself (ITG-004).
9
+
10
+ const debug = require('../debug')('axm:tracing')
11
+
12
+ function lazy (mod, fn) {
13
+ return (exports, info) => {
14
+ try {
15
+ require('./common').setLib(info && info.pkg)
16
+ return require('./' + mod)[fn](exports, info || {})
17
+ } catch (err) {
18
+ let version = '?'
19
+ try {
20
+ version = require(require('path').join(info.dir, 'package.json')).version
21
+ } catch (_) {}
22
+ debug('%s %s: instrumentation disabled (%s)', info && info.pkg, version, err && err.message)
23
+ return undefined
24
+ }
25
+ }
26
+ }
27
+
28
+ function list () {
29
+ const pgClient = lazy('pg', 'patchClient')
30
+ const pgjsQuery = lazy('postgres', 'patchQuery')
31
+ const pgjsIndex = lazy('postgres', 'patchIndex')
32
+ const ioRedis = lazy('ioredis', 'patchRedis')
33
+ const ioPipeline = lazy('ioredis', 'patchPipeline')
34
+ const myConn = lazy('mysql2', 'patchConnection')
35
+ const myPool = lazy('mysql2', 'patchPool')
36
+ const natsConn = lazy('nats', 'patchConnection')
37
+ const natsIter = lazy('nats', 'patchIterator')
38
+ const natsJs = lazy('nats', 'patchJsClient')
39
+ const natsCons = lazy('nats', 'patchConsumer')
40
+ const amqp = lazy('amqplib', 'patchModel')
41
+ const bullQueue = lazy('bullmq', 'patchQueue')
42
+ const bullWorker = lazy('bullmq', 'patchWorker')
43
+ const boss = lazy('pgqueue', 'patchBoss')
44
+ return [
45
+ { pkg: 'pg', for: 'db', files: { 'lib/client.js': pgClient, 'lib/native/client.js': pgClient } },
46
+ { pkg: 'pg-pool', for: 'db', files: { 'index.js': lazy('pg', 'patchPool') } },
47
+ {
48
+ pkg: 'postgres',
49
+ for: 'db',
50
+ files: { 'cjs/src/query.js': pgjsQuery, 'cjs/src/index.js': pgjsIndex, 'src/query.js': pgjsQuery, 'src/index.js': pgjsIndex }
51
+ },
52
+ {
53
+ pkg: 'ioredis',
54
+ for: 'db+messaging',
55
+ files: {
56
+ 'built/Redis.js': ioRedis,
57
+ 'built/redis/index.js': ioRedis,
58
+ 'built/cluster/index.js': lazy('ioredis', 'patchCluster'),
59
+ 'built/Pipeline.js': ioPipeline,
60
+ 'built/pipeline.js': ioPipeline
61
+ }
62
+ },
63
+ { pkg: '@redis/client', for: 'db+messaging', files: { 'dist/lib/client/index.js': lazy('noderedis', 'patchClient') } },
64
+ {
65
+ pkg: 'mysql2',
66
+ for: 'db',
67
+ files: { 'lib/base/connection.js': myConn, 'lib/connection.js': myConn, 'lib/base/pool.js': myPool, 'lib/pool.js': myPool }
68
+ },
69
+ { pkg: 'mongodb', for: 'db', files: { 'lib/mongo_client.js': lazy('mongodb', 'patchClient') } },
70
+ {
71
+ pkg: 'nats',
72
+ for: 'messaging',
73
+ files: {
74
+ 'lib/nats-base-client/nats.js': natsConn,
75
+ 'lib/nats-base-client/queued_iterator.js': natsIter,
76
+ 'lib/jetstream/jsclient.js': natsJs,
77
+ 'lib/jetstream/consumer.js': natsCons
78
+ }
79
+ },
80
+ { pkg: '@nats-io/nats-core', for: 'messaging', files: { 'lib/nats.js': natsConn, 'lib/queued_iterator.js': natsIter } },
81
+ { pkg: '@nats-io/jetstream', for: 'messaging', files: { 'lib/jsclient.js': natsJs, 'lib/consumer.js': natsCons } },
82
+ { pkg: 'kafkajs', for: 'messaging', files: { 'src/index.js': lazy('kafkajs', 'patchClient') } },
83
+ { pkg: 'amqplib', for: 'messaging', files: { 'lib/channel_model.js': amqp, 'lib/callback_model.js': amqp } },
84
+ {
85
+ pkg: 'bullmq',
86
+ for: 'messaging',
87
+ files: {
88
+ 'dist/cjs/classes/queue.js': bullQueue,
89
+ 'dist/cjs/classes/worker.js': bullWorker,
90
+ 'dist/esm/classes/queue.js': bullQueue,
91
+ 'dist/esm/classes/worker.js': bullWorker
92
+ }
93
+ },
94
+ { pkg: 'pg-boss', for: 'messaging', files: { 'src/manager.js': boss, 'dist/manager.js': boss } },
95
+ {
96
+ pkg: 'graphile-worker',
97
+ for: 'messaging',
98
+ files: { 'dist/helpers.js': lazy('pgqueue', 'patchHelpers'), 'dist/worker.js': lazy('pgqueue', 'patchWorker') }
99
+ }
100
+ ]
101
+ }
102
+
103
+ module.exports = { list }
@@ -0,0 +1,212 @@
1
+ // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
+ 'use strict'
3
+
4
+ // ioredis (82 §6 ITG-121, ITG-123, ITG-032; streams §10 ITG-210). `Redis.prototype.sendCommand`
5
+ // (every typed command, `call`, `send_command`) is one CLIENT span per command, ended when the
6
+ // command's own resolve/reject runs (the returned promise is not touched); `Pipeline.prototype.exec`
7
+ // (from `pipeline()` and `multi()`) is one PIPELINE/MULTI span and its commands make none;
8
+ // `Cluster.prototype.sendCommand` makes the span for cluster clients (the node-level call it
9
+ // leads to makes none, ITG-005). A command is marked once it has a span (or belongs to a batch),
10
+ // so offline-queue replays, retries and redirections never create a second one; a command queued
11
+ // before `ready` is timed from the app's call. Commands sent while the connection is in status
12
+ // `connect` (its handshake and ready check) make no span.
13
+
14
+ const c = require('./common')
15
+ const r = require('./redis-common')
16
+
17
+ const MARK = Symbol('pmx-ioredis')
18
+
19
+ function peerTags (client) {
20
+ const tags = {}
21
+ try {
22
+ if (client && client.isCluster) {
23
+ const n = Array.isArray(client.startupNodes) ? client.startupNodes[0] : undefined
24
+ if (n && typeof n === 'object') {
25
+ if (n.host) tags['net.peer.name'] = String(n.host)
26
+ if (n.port) tags['net.peer.port'] = String(n.port)
27
+ } else if (typeof n === 'string') {
28
+ const m = /^(?:redis:\/\/)?([^:/]+)(?::(\d+))?/.exec(n)
29
+ if (m) {
30
+ tags['net.peer.name'] = m[1]
31
+ if (m[2]) tags['net.peer.port'] = m[2]
32
+ }
33
+ } else if (typeof n === 'number') {
34
+ tags['net.peer.name'] = 'localhost'
35
+ tags['net.peer.port'] = String(n)
36
+ }
37
+ return tags
38
+ }
39
+ const o = (client && client.options) || {}
40
+ if (o.path) tags['net.peer.name'] = String(o.path)
41
+ else {
42
+ tags['net.peer.name'] = String(o.host || 'localhost')
43
+ if (o.port) tags['net.peer.port'] = String(o.port)
44
+ }
45
+ } catch (_) {}
46
+ return tags
47
+ }
48
+
49
+ function dbTags (client) {
50
+ const tags = Object.assign({ 'db.system': 'redis' }, peerTags(client))
51
+ try {
52
+ if (!client.isCluster) {
53
+ const sel = client.condition && client.condition.select !== undefined ? client.condition.select : client.options && client.options.db
54
+ if (sel !== undefined && sel !== null) tags['db.redis.database_index'] = String(sel)
55
+ }
56
+ } catch (_) {}
57
+ return tags
58
+ }
59
+
60
+ /** Hook a command's own resolve/reject (instance properties) to run `done(result, err)` once. */
61
+ function onSettle (command, done) {
62
+ const res = command.resolve
63
+ const rej = command.reject
64
+ if (typeof res !== 'function' || typeof rej !== 'function') {
65
+ if (command.promise && typeof command.promise.then === 'function') {
66
+ // fallback: observe the promise (marks it handled; ioredis also does through its callback helper)
67
+ command.promise.then((v) => done(v), (e) => done(undefined, e))
68
+ return
69
+ }
70
+ done()
71
+ return
72
+ }
73
+ let settled = false
74
+ command.resolve = function (v) {
75
+ if (!settled) {
76
+ settled = true
77
+ try { done(v) } catch (_) {}
78
+ }
79
+ return res.apply(this, arguments)
80
+ }
81
+ command.reject = function (err) {
82
+ if (!settled) {
83
+ settled = true
84
+ try { done(undefined, err) } catch (_) {}
85
+ }
86
+ return rej.apply(this, arguments)
87
+ }
88
+ }
89
+
90
+ function startCommand (client, command) {
91
+ const name = String(command.name || '').toUpperCase()
92
+ const args = Array.isArray(command.args) ? command.args : []
93
+ if (r.STREAM_COMMANDS.has(name) && c.msgTracer()) {
94
+ const call = r.streamCall(name, args, peerTags(client))
95
+ if (call && call.span) {
96
+ if (call.extra.length) command.args = args.concat(call.extra)
97
+ onSettle(command, (v, err) => call.done(v, err))
98
+ }
99
+ return
100
+ }
101
+ const t = c.dbTracer()
102
+ if (!t) return
103
+ const tags = dbTags(client)
104
+ tags['db.operation'] = name
105
+ const st = r.statement(name, args, t.db.statement)
106
+ if (st !== undefined) tags['db.statement'] = st
107
+ const span = c.clientSpan(t, name, tags)
108
+ if (!span) return
109
+ onSettle(command, (v, err) => c.end(span, err, err ? r.errorCode(err) : undefined))
110
+ }
111
+
112
+ function mark (command) {
113
+ Object.defineProperty(command, MARK, { value: true, configurable: true })
114
+ }
115
+
116
+ /** True when `command` is the last entry of the client's offline queue (a Denque or an array). */
117
+ function queuedOffline (client, command) {
118
+ const q = client.offlineQueue
119
+ if (!q) return false
120
+ const last = typeof q.peekBack === 'function' ? q.peekBack() : (Array.isArray(q) ? q[q.length - 1] : undefined)
121
+ return Boolean(last && last.command === command)
122
+ }
123
+
124
+ function wrapSendCommand (orig) {
125
+ return function sendCommand (command, stream) {
126
+ if (!command || typeof command !== 'object' || command[MARK] || stream || !c.tracer()) return orig.apply(this, arguments)
127
+ if (this.status === 'connect') {
128
+ // status 'connect': the connection's own handshake (HELLO, AUTH, SELECT, CLIENT SETINFO)
129
+ // and ready check (INFO) are written now and make no span; a command of the app is queued
130
+ // (offline queue) and replayed at `ready` — in the context of the connection, not the app's:
131
+ // its span is made now, in the app's context, timed from the call
132
+ const r = orig.apply(this, arguments)
133
+ try {
134
+ if (!command[MARK] && queuedOffline(this, command)) {
135
+ mark(command)
136
+ startCommand(this, command)
137
+ }
138
+ } catch (_) {}
139
+ return r
140
+ }
141
+ try {
142
+ mark(command)
143
+ startCommand(this, command)
144
+ } catch (_) {}
145
+ return orig.apply(this, arguments)
146
+ }
147
+ }
148
+
149
+ function wrapExec (orig) {
150
+ return function exec () {
151
+ const t = c.dbTracer()
152
+ const queue = this._queue
153
+ if (!t || this[MARK] || !Array.isArray(queue) || queue.length === 0 || !c.currentSpan()) return orig.apply(this, arguments)
154
+ let span = null
155
+ try {
156
+ Object.defineProperty(this, MARK, { value: true, configurable: true })
157
+ const multi = queue[0] && queue[0].name === 'multi'
158
+ const cmds = []
159
+ for (const cmd of queue) {
160
+ try {
161
+ Object.defineProperty(cmd, MARK, { value: true, configurable: true })
162
+ } catch (_) {}
163
+ if (multi && (cmd.name === 'multi' || cmd.name === 'exec')) continue
164
+ cmds.push([String(cmd.name || '').toUpperCase(), Array.isArray(cmd.args) ? cmd.args : []])
165
+ }
166
+ const name = multi ? 'MULTI' : 'PIPELINE'
167
+ const tags = dbTags(this.redis)
168
+ tags['db.operation'] = name
169
+ const st = r.pipelineStatement(cmds, t.db.statement)
170
+ if (st !== undefined) tags['db.statement'] = st
171
+ tags['db.batch_size'] = String(cmds.length)
172
+ span = c.clientSpan(t, name, tags)
173
+ } catch (_) {
174
+ span = null
175
+ }
176
+ let p
177
+ try {
178
+ p = orig.apply(this, arguments)
179
+ } catch (err) {
180
+ c.end(span, err)
181
+ throw err
182
+ }
183
+ if (span && c.isThenable(p)) {
184
+ p.then(() => c.end(span), (err) => c.end(span, err, r.errorCode(err)))
185
+ } else c.end(span)
186
+ return p
187
+ }
188
+ }
189
+
190
+ /** built/Redis.js (5.x, 6.x) and built/redis/index.js (4.x): Redis.prototype.sendCommand. */
191
+ function patchRedis (exports) {
192
+ const Redis = exports && (exports.default || exports)
193
+ if (typeof Redis !== 'function' || !Redis.prototype || typeof Redis.prototype.sendCommand !== 'function') {
194
+ c.disabled('ioredis', 'Redis.prototype.sendCommand not found')
195
+ return
196
+ }
197
+ c.wrap(Redis.prototype, 'sendCommand', wrapSendCommand)
198
+ }
199
+
200
+ function patchCluster (exports) {
201
+ const Cluster = exports && (exports.default || exports)
202
+ if (typeof Cluster !== 'function' || !Cluster.prototype || typeof Cluster.prototype.sendCommand !== 'function') return
203
+ c.wrap(Cluster.prototype, 'sendCommand', wrapSendCommand)
204
+ }
205
+
206
+ function patchPipeline (exports) {
207
+ const Pipeline = exports && (exports.default || exports)
208
+ if (typeof Pipeline !== 'function' || !Pipeline.prototype || typeof Pipeline.prototype.exec !== 'function') return
209
+ c.wrap(Pipeline.prototype, 'exec', wrapExec)
210
+ }
211
+
212
+ module.exports = { patchRedis, patchCluster, patchPipeline }
@@ -0,0 +1,205 @@
1
+ // Copyright (c) 2026 Mike Warriner. All rights reserved. Proprietary — see LICENSE.
2
+ 'use strict'
3
+
4
+ // kafkajs (82 §12 ITG-232, ITG-233; links ITG-024). The client class (`Kafka`, src/index.js) is
5
+ // wrapped in a Proxy that remembers the bootstrap brokers of each instance; its `producer()` and
6
+ // `consumer()` return the library's objects with these methods wrapped:
7
+ // * producer.send / sendBatch (and the same methods of `transaction()` objects) → one PRODUCER
8
+ // `publish <topic>` span per call (`publish multiple` for several topics), the span's trace
9
+ // headers set in copies of the messages (and of their `headers` objects);
10
+ // * consumer.run({ eachMessage }) → callback style per message, parent extracted from the
11
+ // message headers; run({ eachBatch }) → one CONSUMER `process <topic>` span per batch call with
12
+ // one link per message (at most `messaging.maxLinks`).
13
+ // Header values may be strings, Buffers or arrays of them: extraction takes the first value.
14
+
15
+ const c = require('./common')
16
+
17
+ const SYSTEM = 'kafka'
18
+ const BROKERS = new WeakMap()
19
+ // producer/consumer objects are wrapped when created: not tracked per library (85 OTX-020 looks at
20
+ // Kafka.prototype.producer/consumer, which OpenTelemetry's kafkajs instrumentation wraps)
21
+ const RUNTIME = { lib: 'kafkajs', track: false }
22
+
23
+ function peer (client) {
24
+ const tags = {}
25
+ const b = client && BROKERS.get(client)
26
+ const first = Array.isArray(b) ? b[0] : undefined
27
+ if (typeof first === 'string') {
28
+ const i = first.lastIndexOf(':')
29
+ if (i > 0) {
30
+ tags['net.peer.name'] = first.slice(0, i)
31
+ tags['net.peer.port'] = first.slice(i + 1)
32
+ } else tags['net.peer.name'] = first
33
+ }
34
+ return tags
35
+ }
36
+
37
+ function withHeaders (messages, span) {
38
+ if (!Array.isArray(messages)) return messages
39
+ const trace = c.traceHeaders(span)
40
+ return messages.map((m) => {
41
+ if (!m || typeof m !== 'object') return m
42
+ const h = Object.assign({}, m.headers || {})
43
+ const present = new Set(Object.keys(h).map((k) => k.toLowerCase()))
44
+ for (const [k, v] of trace) if (!present.has(k)) h[k] = v
45
+ return Object.assign({}, m, { headers: h })
46
+ })
47
+ }
48
+
49
+ function sizeOf (messages) {
50
+ if (!Array.isArray(messages) || messages.length !== 1) return undefined
51
+ return c.bodySize(messages[0] && messages[0].value)
52
+ }
53
+
54
+ function wrapSend (client) {
55
+ return (orig) => function send (record) {
56
+ if (!c.msgTracer() || !record || typeof record !== 'object') return orig.apply(this, arguments)
57
+ const messages = Array.isArray(record.messages) ? record.messages : []
58
+ const tags = peer(client)
59
+ if (messages.length > 1) tags['messaging.batch.message_count'] = String(messages.length)
60
+ const size = sizeOf(messages)
61
+ if (size !== undefined) tags['messaging.message.body.size'] = String(size)
62
+ const span = c.producer(SYSTEM, record.topic, tags)
63
+ if (!span) return orig.apply(this, arguments)
64
+ let r
65
+ try {
66
+ r = orig.call(this, Object.assign({}, record, { messages: withHeaders(messages, span) }))
67
+ } catch (err) {
68
+ c.end(span, err)
69
+ throw err
70
+ }
71
+ if (!c.isThenable(r)) {
72
+ c.end(span)
73
+ return r
74
+ }
75
+ return c.settle(r, span, (res) => partitionOf(span, res))
76
+ }
77
+ }
78
+
79
+ function partitionOf (span, res) {
80
+ if (Array.isArray(res) && res.length === 1 && res[0] && res[0].partition !== undefined) {
81
+ span.setAttribute('messaging.kafka.destination.partition', String(res[0].partition))
82
+ }
83
+ }
84
+
85
+ function wrapSendBatch (client) {
86
+ return (orig) => function sendBatch (batch) {
87
+ if (!c.msgTracer() || !batch || typeof batch !== 'object' || !Array.isArray(batch.topicMessages)) return orig.apply(this, arguments)
88
+ const topics = new Set(batch.topicMessages.map((t) => t && t.topic))
89
+ const count = batch.topicMessages.reduce((n, t) => n + (t && Array.isArray(t.messages) ? t.messages.length : 0), 0)
90
+ const tags = peer(client)
91
+ if (count > 1) tags['messaging.batch.message_count'] = String(count)
92
+ const span = c.producer(SYSTEM, topics.size === 1 ? Array.from(topics)[0] : undefined, tags)
93
+ if (!span) return orig.apply(this, arguments)
94
+ let r
95
+ try {
96
+ const tm = batch.topicMessages.map((t) => (t && typeof t === 'object' ? Object.assign({}, t, { messages: withHeaders(t.messages, span) }) : t))
97
+ r = orig.call(this, Object.assign({}, batch, { topicMessages: tm }))
98
+ } catch (err) {
99
+ c.end(span, err)
100
+ throw err
101
+ }
102
+ if (!c.isThenable(r)) {
103
+ c.end(span)
104
+ return r
105
+ }
106
+ return c.settle(r, span)
107
+ }
108
+ }
109
+
110
+ function wrapProducerObject (p, client) {
111
+ if (!p || typeof p !== 'object') return p
112
+ try {
113
+ if (typeof p.send === 'function') c.wrap(p, 'send', wrapSend(client), RUNTIME)
114
+ if (typeof p.sendBatch === 'function') c.wrap(p, 'sendBatch', wrapSendBatch(client), RUNTIME)
115
+ if (typeof p.transaction === 'function') {
116
+ c.wrap(p, 'transaction', (orig) => function transaction () {
117
+ const r = orig.apply(this, arguments)
118
+ if (c.isThenable(r)) return r.then((tx) => wrapProducerObject(tx, client))
119
+ return r
120
+ }, RUNTIME)
121
+ }
122
+ } catch (_) {}
123
+ return p
124
+ }
125
+
126
+ function consumerTags (client, group, topic, partition) {
127
+ const tags = peer(client)
128
+ if (group) tags['messaging.consumer.group.name'] = String(group)
129
+ if (partition !== undefined && partition !== null) tags['messaging.kafka.destination.partition'] = String(partition)
130
+ return tags
131
+ }
132
+
133
+ function wrapRun (client, group) {
134
+ return (orig) => function run (config) {
135
+ if (!config || typeof config !== 'object') return orig.apply(this, arguments)
136
+ const copy = Object.assign({}, config)
137
+ if (typeof config.eachMessage === 'function') {
138
+ const fn = config.eachMessage
139
+ copy.eachMessage = function (payload) {
140
+ if (!c.msgTracer() || !payload || !payload.message) return fn.apply(this, arguments)
141
+ const m = payload.message
142
+ const tags = consumerTags(client, group, payload.topic, payload.partition)
143
+ if (m.offset !== undefined) tags['messaging.kafka.message.offset'] = String(m.offset)
144
+ const size = c.bodySize(m.value)
145
+ if (size !== undefined) tags['messaging.message.body.size'] = String(size)
146
+ const span = c.consumer(SYSTEM, payload.topic, c.extract(m.headers), tags)
147
+ return c.runConsumer(span, fn, this, arguments)
148
+ }
149
+ }
150
+ if (typeof config.eachBatch === 'function') {
151
+ const fn = config.eachBatch
152
+ copy.eachBatch = function (payload) {
153
+ const batch = payload && payload.batch
154
+ if (!c.msgTracer() || !batch) return fn.apply(this, arguments)
155
+ const messages = Array.isArray(batch.messages) ? batch.messages : []
156
+ const tags = consumerTags(client, group, batch.topic, batch.partition)
157
+ if (messages.length > 1) tags['messaging.batch.message_count'] = String(messages.length)
158
+ const contexts = messages.map((m) => c.extract(m && m.headers))
159
+ const span = c.batchConsumer(SYSTEM, batch.topic, contexts, tags, 'process')
160
+ return c.runConsumer(span, fn, this, arguments)
161
+ }
162
+ }
163
+ return orig.call(this, copy)
164
+ }
165
+ }
166
+
167
+ function wrapConsumerObject (cons, client, group) {
168
+ if (cons && typeof cons === 'object' && typeof cons.run === 'function') {
169
+ try {
170
+ c.wrap(cons, 'run', wrapRun(client, group), RUNTIME)
171
+ } catch (_) {}
172
+ }
173
+ return cons
174
+ }
175
+
176
+ /** src/index.js: module.exports = class Client. */
177
+ function patchClient (Client) {
178
+ if (typeof Client !== 'function' || !Client.prototype || typeof Client.prototype.producer !== 'function') {
179
+ c.disabled('kafkajs', 'Kafka.prototype.producer not found')
180
+ return
181
+ }
182
+ const proto = Client.prototype
183
+ c.wrap(proto, 'producer', (orig) => function producer () {
184
+ const p = orig.apply(this, arguments)
185
+ return c.tracer() ? wrapProducerObject(p, this) : p
186
+ })
187
+ if (typeof proto.consumer === 'function') {
188
+ c.wrap(proto, 'consumer', (orig) => function consumer (opts) {
189
+ const cons = orig.apply(this, arguments)
190
+ return c.tracer() ? wrapConsumerObject(cons, this, opts && opts.groupId) : cons
191
+ })
192
+ }
193
+ return new Proxy(Client, {
194
+ construct (target, args, newTarget) {
195
+ const client = Reflect.construct(target, args, newTarget)
196
+ try {
197
+ const o = args[0]
198
+ if (o && Array.isArray(o.brokers)) BROKERS.set(client, o.brokers.slice())
199
+ } catch (_) {}
200
+ return client
201
+ }
202
+ })
203
+ }
204
+
205
+ module.exports = { patchClient }