@jr200-labs/xstate-nats 0.6.1 → 0.8.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/README.md +54 -0
- package/dist/actions/connection.d.ts.map +1 -1
- package/dist/actions/connection.js +12 -3
- package/dist/actions/connection.js.map +1 -1
- package/dist/actions/kv.d.ts.map +1 -1
- package/dist/actions/kv.js +26 -10
- package/dist/actions/kv.js.map +1 -1
- package/dist/actions/subject.d.ts.map +1 -1
- package/dist/actions/subject.js +61 -15
- package/dist/actions/subject.js.map +1 -1
- package/dist/telemetry.d.ts +27 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +125 -0
- package/dist/telemetry.js.map +1 -0
- package/package.json +14 -2
- package/src/actions/connection.ts +27 -3
- package/src/actions/kv.ts +40 -12
- package/src/actions/subject.telemetry.test.ts +199 -0
- package/src/actions/subject.test.ts +15 -3
- package/src/actions/subject.ts +94 -16
- package/src/machines/kv.test.ts +7 -3
- package/src/machines/root.test.ts +8 -4
- package/src/machines/subject.test.ts +17 -5
- package/src/telemetry.ts +145 -0
package/README.md
CHANGED
|
@@ -218,6 +218,60 @@ auth: {
|
|
|
218
218
|
- `KV.UNSUBSCRIBE`: Unsubscribe from KV changes
|
|
219
219
|
- `KV.UNSUBSCRIBE_ALL`: Unsubscribe from all KV changes
|
|
220
220
|
|
|
221
|
+
## OpenTelemetry
|
|
222
|
+
|
|
223
|
+
This library emits OpenTelemetry spans for NATS operations and propagates W3C
|
|
224
|
+
trace context across the wire so a single trace can span publisher, subscriber,
|
|
225
|
+
and request/reply replier.
|
|
226
|
+
|
|
227
|
+
### Emitted spans
|
|
228
|
+
|
|
229
|
+
| Span name | Emitted by | Attributes |
|
|
230
|
+
| ----------------------- | -------------------- | ---------------------------------------- |
|
|
231
|
+
| `xstate.nats.subscribe` | `SUBJECT.SUBSCRIBE` | `subject` |
|
|
232
|
+
| `xstate.nats.message` | per received message | `subject`, `payload.bytes` |
|
|
233
|
+
| `xstate.nats.publish` | `SUBJECT.PUBLISH` | `subject`, `payload.bytes` |
|
|
234
|
+
| `xstate.nats.request` | `SUBJECT.REQUEST` | `subject`, `payload.bytes`, `timeout.ms` |
|
|
235
|
+
| `xstate.nats.reconnect` | NATS status loop | `reconnect.type` |
|
|
236
|
+
| `xstate.nats.kv.watch` | `KV.SUBSCRIBE` | `bucket`, `key` |
|
|
237
|
+
| `xstate.nats.kv.entry` | per KV watch entry | `bucket`, `key`, `operation` |
|
|
238
|
+
|
|
239
|
+
All error paths record exceptions on the active span, set span status to
|
|
240
|
+
`ERROR`, and emit a named event (`xstate.nats.error` / `xstate.nats.kv.error`)
|
|
241
|
+
with a truncated stack.
|
|
242
|
+
|
|
243
|
+
### Enabling tracing
|
|
244
|
+
|
|
245
|
+
`@opentelemetry/api` is a peer dependency — the consumer controls the installed
|
|
246
|
+
version and registers the SDK. If no provider is registered all telemetry calls
|
|
247
|
+
become no-ops. Minimal setup:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { trace, propagation, context } from '@opentelemetry/api'
|
|
251
|
+
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'
|
|
252
|
+
import { W3CTraceContextPropagator } from '@opentelemetry/core'
|
|
253
|
+
import { BasicTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
|
|
254
|
+
|
|
255
|
+
const provider = new BasicTracerProvider({
|
|
256
|
+
spanProcessors: [
|
|
257
|
+
/* your exporter */
|
|
258
|
+
],
|
|
259
|
+
})
|
|
260
|
+
trace.setGlobalTracerProvider(provider)
|
|
261
|
+
propagation.setGlobalPropagator(new W3CTraceContextPropagator())
|
|
262
|
+
|
|
263
|
+
const ctxMgr = new AsyncLocalStorageContextManager()
|
|
264
|
+
ctxMgr.enable()
|
|
265
|
+
context.setGlobalContextManager(ctxMgr)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Context propagation
|
|
269
|
+
|
|
270
|
+
Publish and request operations inject `traceparent` into the outgoing NATS
|
|
271
|
+
headers; received messages extract the traceparent and parent their
|
|
272
|
+
`xstate.nats.message` span on it. Downstream services that propagate the header
|
|
273
|
+
appear as children of the originating publisher/requester span.
|
|
274
|
+
|
|
221
275
|
## Examples
|
|
222
276
|
|
|
223
277
|
Check out the [React example](./examples/react-test/) for a complete working implementation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/actions/connection.ts"],"names":[],"mappings":"AACA,OAAO,EACL,iBAAiB,EAEjB,GAAG,EACH,cAAc,EACd,MAAM,EAEP,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AACrC,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/actions/connection.ts"],"names":[],"mappings":"AACA,OAAO,EACL,iBAAiB,EAEjB,GAAG,EACH,cAAc,EACd,MAAM,EAEP,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AACrC,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,SAAS,CAAA;AA8BzC,MAAM,MAAM,oBAAoB,GAC5B;IAAE,IAAI,EAAE,8BAA8B,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IAAE,IAAI,EAAE,2BAA2B,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,uBAAuB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,uBAAuB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,8BAA8B,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5D,eAAO,MAAM,aAAa;UAIP,iBAAiB;WAAS,UAAU;gCA+EtD,CAAA;AAED,eAAO,MAAM,cAAc;gBACgB,cAAc,GAAG,IAAI;gCAM/D,CAAA;AAED,eAAO,MAAM,eAAe,GAAI,KAAK,GAAG,GAAG,OAAO,GAAG,IAAI,GAAG,KAAK,YAiBhE,CAAA"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fromPromise } from 'xstate';
|
|
2
2
|
import { credsAuthenticator, wsconnect, } from '@nats-io/nats-core';
|
|
3
3
|
import { sendParent } from 'xstate';
|
|
4
|
+
import { withSpan } from '../telemetry';
|
|
4
5
|
const makeAuthConfig = (auth) => {
|
|
5
6
|
if (!auth) {
|
|
6
7
|
return {};
|
|
@@ -41,7 +42,11 @@ export const connectToNats = fromPromise(async ({ input, }) => {
|
|
|
41
42
|
sendParent({ type: 'NATS_CONNECTION.DISCONNECTED', status });
|
|
42
43
|
break;
|
|
43
44
|
case 'reconnect':
|
|
44
|
-
|
|
45
|
+
// Per-event span so reconnect attempts become discoverable in
|
|
46
|
+
// the trace backend (searchable, durations, span counts).
|
|
47
|
+
withSpan('xstate.nats.reconnect', 'xstate.nats.error', { 'reconnect.type': type }, () => {
|
|
48
|
+
sendParent({ type: 'NATS_CONNECTION.RECONNECT', status });
|
|
49
|
+
});
|
|
45
50
|
break;
|
|
46
51
|
case 'error':
|
|
47
52
|
sendParent({ type: 'NATS_CONNECTION.ERROR', status });
|
|
@@ -56,10 +61,14 @@ export const connectToNats = fromPromise(async ({ input, }) => {
|
|
|
56
61
|
// console.debug('Received ping, pong sent automatically')
|
|
57
62
|
break;
|
|
58
63
|
case 'forceReconnect':
|
|
59
|
-
|
|
64
|
+
withSpan('xstate.nats.reconnect', 'xstate.nats.error', { 'reconnect.type': type }, () => {
|
|
65
|
+
sendParent({ type: 'NATS_CONNECTION.RECONNECT', status });
|
|
66
|
+
});
|
|
60
67
|
break;
|
|
61
68
|
case 'reconnecting':
|
|
62
|
-
|
|
69
|
+
withSpan('xstate.nats.reconnect', 'xstate.nats.error', { 'reconnect.type': type }, () => {
|
|
70
|
+
sendParent({ type: 'NATS_CONNECTION.RECONNECTING', status });
|
|
71
|
+
});
|
|
63
72
|
break;
|
|
64
73
|
case 'slowConsumer':
|
|
65
74
|
console.debug('SLOW_CONSUMER', status);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/actions/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;AACpC,OAAO,EAEL,kBAAkB,EAIlB,SAAS,GACV,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/actions/connection.ts"],"names":[],"mappings":"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"}
|
package/dist/actions/kv.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kv.d.ts","sourceRoot":"","sources":["../../src/actions/kv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACnE,OAAO,EAAE,GAAG,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"kv.d.ts","sourceRoot":"","sources":["../../src/actions/kv.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACnE,OAAO,EAAE,GAAG,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAA;AAI/B,qBAAa,iBAAkB,SAAQ,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC;CAAG;AAE9D,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7B,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B,CAAA;AAED,eAAO,MAAM,kBAAkB;mBAWZ,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;;SANjD,GAAG,GAAG,IAAI;gBACH,cAAc,GAAG,IAAI;kBACnB,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;iBAC1C,GAAG,CAAC,MAAM,EAAE,oBAAoB,CAAC;gCA+FnD,CAAA"}
|
package/dist/actions/kv.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Pair } from '../utils';
|
|
2
2
|
import { fromPromise } from 'xstate';
|
|
3
|
+
import { recordError, withSpan } from '../telemetry';
|
|
3
4
|
export class KvSubscriptionKey extends Pair {
|
|
4
5
|
}
|
|
5
6
|
export const kvConsolidateState = fromPromise(async ({ input, }) => {
|
|
@@ -28,23 +29,38 @@ export const kvConsolidateState = fromPromise(async ({ input, }) => {
|
|
|
28
29
|
try {
|
|
29
30
|
const kv = await input.kvm.open(config.bucket);
|
|
30
31
|
const watchOptions = config;
|
|
31
|
-
|
|
32
|
+
// Short span around the synchronous-ish watch() setup — the watcher
|
|
33
|
+
// iterator itself is long-lived, so each received entry gets its
|
|
34
|
+
// own span below rather than one indefinite parent.
|
|
35
|
+
const watcher = (await withSpan('xstate.nats.kv.watch', 'xstate.nats.kv.error', { bucket: config.bucket, key: config.key }, () => kv.watch(watchOptions)));
|
|
32
36
|
syncedState.set(kvKey, watcher);
|
|
33
37
|
(async () => {
|
|
34
38
|
try {
|
|
35
39
|
for await (const e of watcher) {
|
|
36
40
|
if (e.operation !== 'DEL') {
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
parsedValue = JSON.parse(e.string());
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
parsedValue = e.string();
|
|
43
|
-
}
|
|
44
|
-
config.callback({
|
|
41
|
+
await withSpan('xstate.nats.kv.entry', 'xstate.nats.kv.error', {
|
|
45
42
|
bucket: config.bucket,
|
|
46
43
|
key: config.key,
|
|
47
|
-
|
|
44
|
+
operation: e.operation,
|
|
45
|
+
}, (span) => {
|
|
46
|
+
let parsedValue;
|
|
47
|
+
try {
|
|
48
|
+
parsedValue = JSON.parse(e.string());
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
parsedValue = e.string();
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
config.callback({
|
|
55
|
+
bucket: config.bucket,
|
|
56
|
+
key: config.key,
|
|
57
|
+
value: parsedValue,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (callbackError) {
|
|
61
|
+
recordError(span, 'xstate.nats.kv.error', callbackError);
|
|
62
|
+
console.error(`KV_SUBSCRIBE (connected): Callback error for ${kvKey}:`, callbackError);
|
|
63
|
+
}
|
|
48
64
|
});
|
|
49
65
|
}
|
|
50
66
|
}
|
package/dist/actions/kv.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kv.js","sourceRoot":"","sources":["../../src/actions/kv.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAA;AAC/B,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;
|
|
1
|
+
{"version":3,"file":"kv.js","sourceRoot":"","sources":["../../src/actions/kv.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subject.d.ts","sourceRoot":"","sources":["../../src/actions/subject.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,cAAc,EACd,cAAc,EACd,cAAc,EACd,YAAY,EACZ,mBAAmB,EACpB,MAAM,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"subject.d.ts","sourceRoot":"","sources":["../../src/actions/subject.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,cAAc,EACd,cAAc,EACd,cAAc,EACd,YAAY,EACZ,mBAAmB,EACpB,MAAM,oBAAoB,CAAA;AAS3B,MAAM,MAAM,yBAAyB,GAAG;IACtC,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7B,IAAI,CAAC,EAAE,mBAAmB,CAAA;CAC3B,CAAA;AAED,eAAO,MAAM,uBAAuB,GAAI,YAErC;IACD,KAAK,EAAE;QACL,UAAU,EAAE,cAAc,GAAG,IAAI,CAAA;QACjC,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;QAC/C,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAA;KAC5D,CAAA;CACF;;CA2EA,CAAA;AAED,eAAO,MAAM,cAAc,GAAI,YAE5B;IACD,KAAK,EAAE;QACL,UAAU,EAAE,cAAc,GAAG,IAAI,CAAA;QACjC,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,GAAG,CAAA;QACZ,IAAI,CAAC,EAAE,cAAc,CAAA;QACrB,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAA;KAC9B,CAAA;CACF,SA4CA,CAAA;AAED,eAAO,MAAM,cAAc,GAAI,YAE5B;IACD,KAAK,EAAE;QACL,UAAU,EAAE,cAAc,GAAG,IAAI,CAAA;QACjC,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,GAAG,CAAA;QACZ,OAAO,CAAC,EAAE,cAAc,CAAA;QACxB,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE;YAAE,EAAE,EAAE,IAAI,CAAA;SAAE,GAAG;YAAE,EAAE,EAAE,KAAK,CAAC;YAAC,KAAK,EAAE,KAAK,CAAA;SAAE,KAAK,IAAI,CAAA;KAC/E,CAAA;CACF,SA+BA,CAAA"}
|
package/dist/actions/subject.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseNatsResult } from './connection';
|
|
2
|
+
import { extractContextFromHeaders, injectContextIntoHeaders, recordError, withSpan, } from '../telemetry';
|
|
2
3
|
export const subjectConsolidateState = ({ input, }) => {
|
|
3
4
|
const { connection, currentSubscriptions, targetSubscriptions } = input;
|
|
4
5
|
if (!connection) {
|
|
@@ -21,16 +22,28 @@ export const subjectConsolidateState = ({ input, }) => {
|
|
|
21
22
|
for (const [subject, subscriptionConfig] of targetSubscriptions) {
|
|
22
23
|
if (!currentSubscriptions.has(subject)) {
|
|
23
24
|
try {
|
|
24
|
-
|
|
25
|
+
// Short span around the synchronous subscribe() call. The iterator
|
|
26
|
+
// below is long-lived; we don't span its whole lifetime (indefinite
|
|
27
|
+
// spans are anti-pattern in most tracing backends).
|
|
28
|
+
const sub = withSpan('xstate.nats.subscribe', 'xstate.nats.error', { subject }, () => connection.subscribe(subject, subscriptionConfig.opts));
|
|
25
29
|
(async () => {
|
|
26
30
|
try {
|
|
27
31
|
for await (const msg of sub) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const parentCtx = extractContextFromHeaders(msg.headers);
|
|
33
|
+
await withSpan('xstate.nats.message', 'xstate.nats.error', {
|
|
34
|
+
subject,
|
|
35
|
+
'payload.bytes': msg.data?.length,
|
|
36
|
+
}, (span) => {
|
|
37
|
+
try {
|
|
38
|
+
subscriptionConfig?.callback(parseNatsResult(msg));
|
|
39
|
+
}
|
|
40
|
+
catch (callbackError) {
|
|
41
|
+
// Record on span AND preserve the existing console.error
|
|
42
|
+
// so consumers without OTel still see the failure.
|
|
43
|
+
recordError(span, 'xstate.nats.error', callbackError);
|
|
44
|
+
console.error(`Callback error for subject "${subject}"`, callbackError);
|
|
45
|
+
}
|
|
46
|
+
}, parentCtx);
|
|
34
47
|
}
|
|
35
48
|
}
|
|
36
49
|
catch (iteratorError) {
|
|
@@ -53,13 +66,35 @@ export const subjectRequest = ({ input, }) => {
|
|
|
53
66
|
if (!connection) {
|
|
54
67
|
throw new Error('NATS connection is not available');
|
|
55
68
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
const payloadBytes = payload instanceof Uint8Array
|
|
70
|
+
? payload.byteLength
|
|
71
|
+
: typeof payload === 'string'
|
|
72
|
+
? payload.length
|
|
73
|
+
: undefined;
|
|
74
|
+
void withSpan('xstate.nats.request', 'xstate.nats.error', {
|
|
75
|
+
subject,
|
|
76
|
+
'payload.bytes': payloadBytes,
|
|
77
|
+
'timeout.ms': opts?.timeout,
|
|
78
|
+
}, (span) => {
|
|
79
|
+
// Inject INSIDE the span so the replying service parents its handler
|
|
80
|
+
// span on this request span (not on an ambient context). RequestOptions
|
|
81
|
+
// declares `timeout` as required but nats-core only enforces it when
|
|
82
|
+
// opts is provided; cast preserves the "no opts = use conn default"
|
|
83
|
+
// contract.
|
|
84
|
+
const headers = injectContextIntoHeaders(opts?.headers);
|
|
85
|
+
const requestOpts = (opts ? { ...opts, headers } : { headers });
|
|
86
|
+
return connection
|
|
87
|
+
.request(subject, payload, requestOpts)
|
|
88
|
+
.then((msg) => {
|
|
89
|
+
callback(parseNatsResult(msg));
|
|
90
|
+
})
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
// Record on span manually so we can swallow the rejection here —
|
|
93
|
+
// the original fire-and-forget API didn't propagate request errors
|
|
94
|
+
// to callers and changing that now would be a breaking behaviour.
|
|
95
|
+
recordError(span, 'xstate.nats.error', err);
|
|
96
|
+
console.error(`RequestReply error for subject "${subject}"`, err);
|
|
97
|
+
});
|
|
63
98
|
});
|
|
64
99
|
};
|
|
65
100
|
export const subjectPublish = ({ input, }) => {
|
|
@@ -67,8 +102,19 @@ export const subjectPublish = ({ input, }) => {
|
|
|
67
102
|
if (!connection) {
|
|
68
103
|
throw new Error('NATS connection is not available');
|
|
69
104
|
}
|
|
105
|
+
const payloadBytes = payload instanceof Uint8Array
|
|
106
|
+
? payload.byteLength
|
|
107
|
+
: typeof payload === 'string'
|
|
108
|
+
? payload.length
|
|
109
|
+
: undefined;
|
|
70
110
|
try {
|
|
71
|
-
|
|
111
|
+
withSpan('xstate.nats.publish', 'xstate.nats.error', { subject, 'payload.bytes': payloadBytes }, () => {
|
|
112
|
+
// Inject INSIDE the span so downstream subscribers see THIS span as
|
|
113
|
+
// parent instead of whatever was ambient before publish.
|
|
114
|
+
const headers = injectContextIntoHeaders(options?.headers);
|
|
115
|
+
const publishOpts = { ...(options ?? {}), headers };
|
|
116
|
+
connection.publish(subject, payload, publishOpts);
|
|
117
|
+
});
|
|
72
118
|
onPublishResult?.({ ok: true });
|
|
73
119
|
}
|
|
74
120
|
catch (callbackError) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subject.js","sourceRoot":"","sources":["../../src/actions/subject.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"subject.js","sourceRoot":"","sources":["../../src/actions/subject.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Context, Span, Tracer } from '@opentelemetry/api';
|
|
2
|
+
import type { MsgHdrs } from '@nats-io/nats-core';
|
|
3
|
+
export declare function getTracer(): Tracer;
|
|
4
|
+
/**
|
|
5
|
+
* Record an error on a span using the OTel canonical pattern:
|
|
6
|
+
* recordException + ERROR status + a named event with the truncated stack.
|
|
7
|
+
*/
|
|
8
|
+
export declare function recordError(span: Span, errorEventName: string, err: unknown): void;
|
|
9
|
+
/**
|
|
10
|
+
* Run `fn` inside an active span. On synchronous throw or async rejection the
|
|
11
|
+
* error is recorded, the span status is set to ERROR, and the error is
|
|
12
|
+
* re-thrown (callers keep their existing error-handling semantics).
|
|
13
|
+
*/
|
|
14
|
+
export declare function withSpan<T>(name: string, errorEventName: string, attributes: Record<string, string | number | boolean | undefined>, fn: (span: Span) => T | Promise<T>, parentContext?: Context): T | Promise<T>;
|
|
15
|
+
/**
|
|
16
|
+
* Inject the active OTel context into a NATS headers object, creating one if
|
|
17
|
+
* none was supplied. Returns the headers object so the caller can pass it to
|
|
18
|
+
* `connection.publish` / `connection.request` via the `opts.headers` field.
|
|
19
|
+
*/
|
|
20
|
+
export declare function injectContextIntoHeaders(existing?: MsgHdrs): MsgHdrs;
|
|
21
|
+
/**
|
|
22
|
+
* Extract a parent OTel context from an incoming NATS message's headers. When
|
|
23
|
+
* no traceparent is present the returned context is whatever was already
|
|
24
|
+
* active — callers can use it as the parent for a new span without a branch.
|
|
25
|
+
*/
|
|
26
|
+
export declare function extractContextFromHeaders(hdrs: MsgHdrs | undefined): Context;
|
|
27
|
+
//# sourceMappingURL=telemetry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAE/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAA;AASjD,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAcD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,IAAI,CAQlF;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACxB,IAAI,EAAE,MAAM,EACZ,cAAc,EAAE,MAAM,EACtB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,EACjE,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,EAClC,aAAa,CAAC,EAAE,OAAO,GACtB,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4BhB;AA8BD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAIpE;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAG5E"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// OpenTelemetry helpers scoped to this package.
|
|
2
|
+
//
|
|
3
|
+
// `@opentelemetry/api` is declared as a peer dependency so the consumer
|
|
4
|
+
// controls the installed version. If the consumer never registers a
|
|
5
|
+
// TracerProvider / TextMapPropagator, every call here becomes a no-op.
|
|
6
|
+
//
|
|
7
|
+
// Design choices:
|
|
8
|
+
// - Single module-level tracer, named and versioned from package.json.
|
|
9
|
+
// - `withSpan` wraps the common try/catch/recordException/setStatus pattern
|
|
10
|
+
// so call sites stay readable.
|
|
11
|
+
// - `injectContextIntoHeaders` / `extractContextFromHeaders` bridge the
|
|
12
|
+
// OTel propagation API onto NATS `MsgHdrs`. Outgoing messages carry the
|
|
13
|
+
// active traceparent; incoming messages become the active context for
|
|
14
|
+
// the per-message span so cross-process traces nest automatically.
|
|
15
|
+
import { context as otelContext, propagation, SpanStatusCode, trace } from '@opentelemetry/api';
|
|
16
|
+
import { headers as natsHeaders } from '@nats-io/nats-core';
|
|
17
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
18
|
+
const TRACER_NAME = '@jr200-labs/xstate-nats';
|
|
19
|
+
// Do not cache — `trace.getTracer` already returns a lightweight ProxyTracer,
|
|
20
|
+
// and caching across global-provider swaps (e.g. test teardown / re-register)
|
|
21
|
+
// would pin the delegate to a retired provider and silently lose spans.
|
|
22
|
+
export function getTracer() {
|
|
23
|
+
return trace.getTracer(TRACER_NAME, pkg.version);
|
|
24
|
+
}
|
|
25
|
+
// Truncate the stack trace before attaching it to a span event. Some backends
|
|
26
|
+
// (and the wire format itself) perform poorly with arbitrarily long strings;
|
|
27
|
+
// the first ~1KB is almost always enough to identify the site.
|
|
28
|
+
const MAX_STACK_LEN = 1024;
|
|
29
|
+
function truncateStack(err) {
|
|
30
|
+
if (!(err instanceof Error) || !err.stack)
|
|
31
|
+
return undefined;
|
|
32
|
+
return err.stack.length > MAX_STACK_LEN
|
|
33
|
+
? err.stack.slice(0, MAX_STACK_LEN) + '...(truncated)'
|
|
34
|
+
: err.stack;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Record an error on a span using the OTel canonical pattern:
|
|
38
|
+
* recordException + ERROR status + a named event with the truncated stack.
|
|
39
|
+
*/
|
|
40
|
+
export function recordError(span, errorEventName, err) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
if (err instanceof Error) {
|
|
43
|
+
span.recordException(err);
|
|
44
|
+
}
|
|
45
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
46
|
+
const stack = truncateStack(err);
|
|
47
|
+
span.addEvent(errorEventName, stack ? { stack } : undefined);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run `fn` inside an active span. On synchronous throw or async rejection the
|
|
51
|
+
* error is recorded, the span status is set to ERROR, and the error is
|
|
52
|
+
* re-thrown (callers keep their existing error-handling semantics).
|
|
53
|
+
*/
|
|
54
|
+
export function withSpan(name, errorEventName, attributes, fn, parentContext) {
|
|
55
|
+
const tracer = getTracer();
|
|
56
|
+
const sanitized = sanitizeAttributes(attributes);
|
|
57
|
+
const ctx = parentContext ?? otelContext.active();
|
|
58
|
+
return tracer.startActiveSpan(name, { attributes: sanitized }, ctx, (span) => {
|
|
59
|
+
try {
|
|
60
|
+
const result = fn(span);
|
|
61
|
+
if (result && typeof result.then === 'function') {
|
|
62
|
+
return result.then((value) => {
|
|
63
|
+
span.end();
|
|
64
|
+
return value;
|
|
65
|
+
}, (err) => {
|
|
66
|
+
recordError(span, errorEventName, err);
|
|
67
|
+
span.end();
|
|
68
|
+
throw err;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
span.end();
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
recordError(span, errorEventName, err);
|
|
76
|
+
span.end();
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// OTel attributes cannot be `undefined`; strip those so call sites can pass
|
|
82
|
+
// optional values without a pre-filter.
|
|
83
|
+
function sanitizeAttributes(attrs) {
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
86
|
+
if (v !== undefined)
|
|
87
|
+
out[k] = v;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
// Carrier adapters: NATS `MsgHdrs` exposes `.set(k, v)`, `.get(k)`, `.keys()`
|
|
92
|
+
// which maps cleanly to the OTel TextMapSetter / TextMapGetter interface.
|
|
93
|
+
const natsHeaderSetter = {
|
|
94
|
+
set: (carrier, key, value) => {
|
|
95
|
+
carrier.set(key, value);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const natsHeaderGetter = {
|
|
99
|
+
get: (carrier, key) => {
|
|
100
|
+
const v = carrier?.get(key);
|
|
101
|
+
return v || undefined;
|
|
102
|
+
},
|
|
103
|
+
keys: (carrier) => carrier?.keys() ?? [],
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Inject the active OTel context into a NATS headers object, creating one if
|
|
107
|
+
* none was supplied. Returns the headers object so the caller can pass it to
|
|
108
|
+
* `connection.publish` / `connection.request` via the `opts.headers` field.
|
|
109
|
+
*/
|
|
110
|
+
export function injectContextIntoHeaders(existing) {
|
|
111
|
+
const hdrs = existing ?? natsHeaders();
|
|
112
|
+
propagation.inject(otelContext.active(), hdrs, natsHeaderSetter);
|
|
113
|
+
return hdrs;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Extract a parent OTel context from an incoming NATS message's headers. When
|
|
117
|
+
* no traceparent is present the returned context is whatever was already
|
|
118
|
+
* active — callers can use it as the parent for a new span without a branch.
|
|
119
|
+
*/
|
|
120
|
+
export function extractContextFromHeaders(hdrs) {
|
|
121
|
+
if (!hdrs)
|
|
122
|
+
return otelContext.active();
|
|
123
|
+
return propagation.extract(otelContext.active(), hdrs, natsHeaderGetter);
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=telemetry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr200-labs/xstate-nats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "XState machine for NATS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jayshan Raghunandan",
|
|
@@ -37,11 +37,23 @@
|
|
|
37
37
|
"@nats-io/obj": "^3.3.1",
|
|
38
38
|
"xstate": "^5.30.0"
|
|
39
39
|
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@opentelemetry/api": "^1.9.0"
|
|
42
|
+
},
|
|
40
43
|
"devDependencies": {
|
|
44
|
+
"@eslint/js": "^10.0.1",
|
|
45
|
+
"@opentelemetry/api": "^1.9.0",
|
|
46
|
+
"@opentelemetry/context-async-hooks": "^2.7.0",
|
|
47
|
+
"@opentelemetry/core": "^2.7.0",
|
|
48
|
+
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
|
41
49
|
"@vitest/coverage-v8": "^4.1.4",
|
|
50
|
+
"eslint": "^10.2.1",
|
|
51
|
+
"globals": "^17.5.0",
|
|
42
52
|
"husky": "^9.1.7",
|
|
43
53
|
"prettier": "^3.8.3",
|
|
54
|
+
"syncpack": "^14.3.0",
|
|
44
55
|
"typescript": "^6.0.2",
|
|
56
|
+
"typescript-eslint": "^8.58.2",
|
|
45
57
|
"vitest": "^4.1.4"
|
|
46
58
|
},
|
|
47
59
|
"scripts": {
|
|
@@ -50,6 +62,6 @@
|
|
|
50
62
|
"test:run": "vitest run",
|
|
51
63
|
"test:watch": "vitest",
|
|
52
64
|
"prettier": "prettier --write \"**/*.{ts,mjs,js,json,md,yml,yaml}\"",
|
|
53
|
-
"lint": "tsc --noEmit"
|
|
65
|
+
"lint": "tsc --noEmit && eslint \"src/**/*.{ts,tsx}\""
|
|
54
66
|
}
|
|
55
67
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { KvEntry } from '@nats-io/kv'
|
|
11
11
|
import { type AuthConfig } from './types'
|
|
12
12
|
import { sendParent } from 'xstate'
|
|
13
|
+
import { withSpan } from '../telemetry'
|
|
13
14
|
|
|
14
15
|
const makeAuthConfig = (auth?: AuthConfig) => {
|
|
15
16
|
if (!auth) {
|
|
@@ -68,7 +69,16 @@ export const connectToNats = fromPromise(
|
|
|
68
69
|
sendParent({ type: 'NATS_CONNECTION.DISCONNECTED', status })
|
|
69
70
|
break
|
|
70
71
|
case 'reconnect':
|
|
71
|
-
|
|
72
|
+
// Per-event span so reconnect attempts become discoverable in
|
|
73
|
+
// the trace backend (searchable, durations, span counts).
|
|
74
|
+
withSpan(
|
|
75
|
+
'xstate.nats.reconnect',
|
|
76
|
+
'xstate.nats.error',
|
|
77
|
+
{ 'reconnect.type': type },
|
|
78
|
+
() => {
|
|
79
|
+
sendParent({ type: 'NATS_CONNECTION.RECONNECT', status })
|
|
80
|
+
},
|
|
81
|
+
)
|
|
72
82
|
break
|
|
73
83
|
case 'error':
|
|
74
84
|
sendParent({ type: 'NATS_CONNECTION.ERROR', status })
|
|
@@ -83,10 +93,24 @@ export const connectToNats = fromPromise(
|
|
|
83
93
|
// console.debug('Received ping, pong sent automatically')
|
|
84
94
|
break
|
|
85
95
|
case 'forceReconnect':
|
|
86
|
-
|
|
96
|
+
withSpan(
|
|
97
|
+
'xstate.nats.reconnect',
|
|
98
|
+
'xstate.nats.error',
|
|
99
|
+
{ 'reconnect.type': type },
|
|
100
|
+
() => {
|
|
101
|
+
sendParent({ type: 'NATS_CONNECTION.RECONNECT', status })
|
|
102
|
+
},
|
|
103
|
+
)
|
|
87
104
|
break
|
|
88
105
|
case 'reconnecting':
|
|
89
|
-
|
|
106
|
+
withSpan(
|
|
107
|
+
'xstate.nats.reconnect',
|
|
108
|
+
'xstate.nats.error',
|
|
109
|
+
{ 'reconnect.type': type },
|
|
110
|
+
() => {
|
|
111
|
+
sendParent({ type: 'NATS_CONNECTION.RECONNECTING', status })
|
|
112
|
+
},
|
|
113
|
+
)
|
|
90
114
|
break
|
|
91
115
|
case 'slowConsumer':
|
|
92
116
|
console.debug('SLOW_CONSUMER', status)
|
package/src/actions/kv.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NatsConnection, QueuedIterator } from '@nats-io/nats-core'
|
|
|
2
2
|
import { Kvm, KvWatchEntry, KvWatchOptions } from '@nats-io/kv'
|
|
3
3
|
import { Pair } from '../utils'
|
|
4
4
|
import { fromPromise } from 'xstate'
|
|
5
|
+
import { recordError, withSpan } from '../telemetry'
|
|
5
6
|
|
|
6
7
|
export class KvSubscriptionKey extends Pair<string, string> {}
|
|
7
8
|
|
|
@@ -54,25 +55,52 @@ export const kvConsolidateState = fromPromise(
|
|
|
54
55
|
const kv = await input.kvm.open(config.bucket)
|
|
55
56
|
|
|
56
57
|
const watchOptions = config as KvWatchOptions
|
|
57
|
-
|
|
58
|
+
// Short span around the synchronous-ish watch() setup — the watcher
|
|
59
|
+
// iterator itself is long-lived, so each received entry gets its
|
|
60
|
+
// own span below rather than one indefinite parent.
|
|
61
|
+
const watcher = (await withSpan(
|
|
62
|
+
'xstate.nats.kv.watch',
|
|
63
|
+
'xstate.nats.kv.error',
|
|
64
|
+
{ bucket: config.bucket, key: config.key },
|
|
65
|
+
() => kv.watch(watchOptions),
|
|
66
|
+
)) as QueuedIterator<KvWatchEntry>
|
|
58
67
|
|
|
59
68
|
syncedState.set(kvKey, watcher)
|
|
60
69
|
;(async () => {
|
|
61
70
|
try {
|
|
62
71
|
for await (const e of watcher) {
|
|
63
72
|
if (e.operation !== 'DEL') {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
await withSpan(
|
|
74
|
+
'xstate.nats.kv.entry',
|
|
75
|
+
'xstate.nats.kv.error',
|
|
76
|
+
{
|
|
77
|
+
bucket: config.bucket,
|
|
78
|
+
key: config.key,
|
|
79
|
+
operation: e.operation,
|
|
80
|
+
},
|
|
81
|
+
(span) => {
|
|
82
|
+
let parsedValue
|
|
83
|
+
try {
|
|
84
|
+
parsedValue = JSON.parse(e.string())
|
|
85
|
+
} catch {
|
|
86
|
+
parsedValue = e.string()
|
|
87
|
+
}
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
89
|
+
try {
|
|
90
|
+
config.callback({
|
|
91
|
+
bucket: config.bucket,
|
|
92
|
+
key: config.key,
|
|
93
|
+
value: parsedValue,
|
|
94
|
+
})
|
|
95
|
+
} catch (callbackError) {
|
|
96
|
+
recordError(span, 'xstate.nats.kv.error', callbackError)
|
|
97
|
+
console.error(
|
|
98
|
+
`KV_SUBSCRIBE (connected): Callback error for ${kvKey}:`,
|
|
99
|
+
callbackError,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
)
|
|
76
104
|
}
|
|
77
105
|
}
|
|
78
106
|
} catch (error) {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { SpanStatusCode } from '@opentelemetry/api'
|
|
3
|
+
import { headers as natsHeaders } from '@nats-io/nats-core'
|
|
4
|
+
import { setupInMemoryTracer, type OtelTestHarness } from '../../test/otel-setup'
|
|
5
|
+
import { subjectConsolidateState, subjectPublish, subjectRequest } from './subject'
|
|
6
|
+
import type { SubjectSubscriptionConfig } from './subject'
|
|
7
|
+
|
|
8
|
+
vi.mock('./connection', () => ({
|
|
9
|
+
parseNatsResult: vi.fn((msg: any) => {
|
|
10
|
+
if (!msg) return null
|
|
11
|
+
try {
|
|
12
|
+
return msg.json()
|
|
13
|
+
} catch {
|
|
14
|
+
return msg.string()
|
|
15
|
+
}
|
|
16
|
+
}),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
function createMockConnection() {
|
|
20
|
+
return {
|
|
21
|
+
subscribe: vi.fn(),
|
|
22
|
+
request: vi.fn(),
|
|
23
|
+
publish: vi.fn(),
|
|
24
|
+
} as any
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('subject telemetry', () => {
|
|
28
|
+
let harness: OtelTestHarness
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
harness = setupInMemoryTracer()
|
|
32
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
harness.teardown()
|
|
37
|
+
vi.restoreAllMocks()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('emits xstate.nats.subscribe span when adding a subscription', () => {
|
|
41
|
+
const connection = createMockConnection()
|
|
42
|
+
connection.subscribe.mockReturnValue({
|
|
43
|
+
unsubscribe: vi.fn(),
|
|
44
|
+
[Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const target = new Map<string, SubjectSubscriptionConfig>([
|
|
48
|
+
['t.sub', { subject: 't.sub', callback: vi.fn() }],
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
subjectConsolidateState({
|
|
52
|
+
input: { connection, currentSubscriptions: new Map(), targetSubscriptions: target },
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const spans = harness.exporter.getFinishedSpans()
|
|
56
|
+
const sub = spans.find((s) => s.name === 'xstate.nats.subscribe')
|
|
57
|
+
expect(sub).toBeDefined()
|
|
58
|
+
expect(sub!.attributes.subject).toBe('t.sub')
|
|
59
|
+
expect(sub!.status.code).not.toBe(SpanStatusCode.ERROR)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('emits xstate.nats.publish span with payload bytes', () => {
|
|
63
|
+
const connection = createMockConnection()
|
|
64
|
+
subjectPublish({
|
|
65
|
+
input: { connection, subject: 't.pub', payload: 'hello' },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const spans = harness.exporter.getFinishedSpans()
|
|
69
|
+
const pub = spans.find((s) => s.name === 'xstate.nats.publish')
|
|
70
|
+
expect(pub).toBeDefined()
|
|
71
|
+
expect(pub!.attributes.subject).toBe('t.pub')
|
|
72
|
+
expect(pub!.attributes['payload.bytes']).toBe(5)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('injects traceparent into publish headers', () => {
|
|
76
|
+
const connection = createMockConnection()
|
|
77
|
+
subjectPublish({
|
|
78
|
+
input: { connection, subject: 't.pub', payload: 'x' },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const call = connection.publish.mock.calls[0]
|
|
82
|
+
const opts = call[2]
|
|
83
|
+
expect(opts.headers).toBeDefined()
|
|
84
|
+
// traceparent header is set by W3CTraceContextPropagator
|
|
85
|
+
expect(opts.headers.get('traceparent')).toBeTruthy()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('records error on publish span when connection.publish throws', () => {
|
|
89
|
+
const connection = createMockConnection()
|
|
90
|
+
connection.publish.mockImplementation(() => {
|
|
91
|
+
throw new Error('boom')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
subjectPublish({
|
|
95
|
+
input: { connection, subject: 't.fail', payload: 'x' },
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const spans = harness.exporter.getFinishedSpans()
|
|
99
|
+
const pub = spans.find((s) => s.name === 'xstate.nats.publish')
|
|
100
|
+
expect(pub).toBeDefined()
|
|
101
|
+
expect(pub!.status.code).toBe(SpanStatusCode.ERROR)
|
|
102
|
+
expect(pub!.events.some((e) => e.name === 'xstate.nats.error')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('emits xstate.nats.request span with request attrs', async () => {
|
|
106
|
+
const connection = createMockConnection()
|
|
107
|
+
connection.request.mockResolvedValue({
|
|
108
|
+
json: () => ({ ok: true }),
|
|
109
|
+
string: () => '{"ok":true}',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
subjectRequest({
|
|
113
|
+
input: {
|
|
114
|
+
connection,
|
|
115
|
+
subject: 't.req',
|
|
116
|
+
payload: 'x',
|
|
117
|
+
opts: { timeout: 2500 } as any,
|
|
118
|
+
callback: vi.fn(),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
await vi.waitFor(() => {
|
|
123
|
+
expect(
|
|
124
|
+
harness.exporter.getFinishedSpans().some((s) => s.name === 'xstate.nats.request'),
|
|
125
|
+
).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const req = harness.exporter.getFinishedSpans().find((s) => s.name === 'xstate.nats.request')!
|
|
129
|
+
expect(req.attributes.subject).toBe('t.req')
|
|
130
|
+
expect(req.attributes['timeout.ms']).toBe(2500)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('records error on request span when connection.request rejects', async () => {
|
|
134
|
+
const connection = createMockConnection()
|
|
135
|
+
connection.request.mockRejectedValue(new Error('nope'))
|
|
136
|
+
|
|
137
|
+
subjectRequest({
|
|
138
|
+
input: { connection, subject: 't.req', payload: 'x', callback: vi.fn() },
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await vi.waitFor(() => {
|
|
142
|
+
const req = harness.exporter.getFinishedSpans().find((s) => s.name === 'xstate.nats.request')
|
|
143
|
+
expect(req?.status.code).toBe(SpanStatusCode.ERROR)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('parents per-message span on extracted traceparent', async () => {
|
|
148
|
+
const connection = createMockConnection()
|
|
149
|
+
// Craft a headers object containing a known traceparent so the extractor
|
|
150
|
+
// has something to parent on.
|
|
151
|
+
const incomingHdrs = natsHeaders()
|
|
152
|
+
incomingHdrs.set('traceparent', '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01')
|
|
153
|
+
|
|
154
|
+
let resolveIter: () => void
|
|
155
|
+
const iterDone = new Promise<void>((r) => (resolveIter = r))
|
|
156
|
+
let delivered = false
|
|
157
|
+
connection.subscribe.mockReturnValue({
|
|
158
|
+
unsubscribe: vi.fn(),
|
|
159
|
+
[Symbol.asyncIterator]: () => ({
|
|
160
|
+
next: () => {
|
|
161
|
+
if (!delivered) {
|
|
162
|
+
delivered = true
|
|
163
|
+
return Promise.resolve({
|
|
164
|
+
value: {
|
|
165
|
+
headers: incomingHdrs,
|
|
166
|
+
json: () => ({ hi: 1 }),
|
|
167
|
+
string: () => '{"hi":1}',
|
|
168
|
+
data: new Uint8Array([1, 2, 3]),
|
|
169
|
+
},
|
|
170
|
+
done: false,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
resolveIter!()
|
|
174
|
+
return new Promise(() => {})
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const target = new Map<string, SubjectSubscriptionConfig>([
|
|
180
|
+
['t.msg', { subject: 't.msg', callback: vi.fn() }],
|
|
181
|
+
])
|
|
182
|
+
subjectConsolidateState({
|
|
183
|
+
input: { connection, currentSubscriptions: new Map(), targetSubscriptions: target },
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await iterDone
|
|
187
|
+
await vi.waitFor(() => {
|
|
188
|
+
expect(
|
|
189
|
+
harness.exporter.getFinishedSpans().some((s) => s.name === 'xstate.nats.message'),
|
|
190
|
+
).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const msgSpan = harness.exporter
|
|
194
|
+
.getFinishedSpans()
|
|
195
|
+
.find((s) => s.name === 'xstate.nats.message')!
|
|
196
|
+
expect(msgSpan.spanContext().traceId).toBe('0af7651916cd43dd8448eb211c80319c')
|
|
197
|
+
expect(msgSpan.attributes.subject).toBe('t.msg')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -332,7 +332,11 @@ describe('subjectRequest', () => {
|
|
|
332
332
|
expect(callback).toHaveBeenCalled()
|
|
333
333
|
})
|
|
334
334
|
|
|
335
|
-
expect(connection.request).toHaveBeenCalledWith(
|
|
335
|
+
expect(connection.request).toHaveBeenCalledWith(
|
|
336
|
+
'test.request',
|
|
337
|
+
{ data: 1 },
|
|
338
|
+
expect.objectContaining({ timeout: 5000 }),
|
|
339
|
+
)
|
|
336
340
|
})
|
|
337
341
|
|
|
338
342
|
it('should handle request errors', async () => {
|
|
@@ -387,7 +391,11 @@ describe('subjectPublish', () => {
|
|
|
387
391
|
},
|
|
388
392
|
})
|
|
389
393
|
|
|
390
|
-
expect(connection.publish).toHaveBeenCalledWith(
|
|
394
|
+
expect(connection.publish).toHaveBeenCalledWith(
|
|
395
|
+
'test.publish',
|
|
396
|
+
{ msg: 'hello' },
|
|
397
|
+
expect.objectContaining({ headers: expect.anything() }),
|
|
398
|
+
)
|
|
391
399
|
})
|
|
392
400
|
|
|
393
401
|
it('should call onPublishResult with ok on success', () => {
|
|
@@ -419,7 +427,11 @@ describe('subjectPublish', () => {
|
|
|
419
427
|
},
|
|
420
428
|
})
|
|
421
429
|
|
|
422
|
-
expect(connection.publish).toHaveBeenCalledWith(
|
|
430
|
+
expect(connection.publish).toHaveBeenCalledWith(
|
|
431
|
+
'test.publish',
|
|
432
|
+
'data',
|
|
433
|
+
expect.objectContaining({ headers: expect.anything() }),
|
|
434
|
+
)
|
|
423
435
|
})
|
|
424
436
|
|
|
425
437
|
it('should call onPublishResult with error on failure', () => {
|
package/src/actions/subject.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
SubscriptionOptions,
|
|
8
8
|
} from '@nats-io/nats-core'
|
|
9
9
|
import { parseNatsResult } from './connection'
|
|
10
|
+
import {
|
|
11
|
+
extractContextFromHeaders,
|
|
12
|
+
injectContextIntoHeaders,
|
|
13
|
+
recordError,
|
|
14
|
+
withSpan,
|
|
15
|
+
} from '../telemetry'
|
|
10
16
|
|
|
11
17
|
export type SubjectSubscriptionConfig = {
|
|
12
18
|
subject: string
|
|
@@ -46,17 +52,41 @@ export const subjectConsolidateState = ({
|
|
|
46
52
|
for (const [subject, subscriptionConfig] of targetSubscriptions) {
|
|
47
53
|
if (!currentSubscriptions.has(subject)) {
|
|
48
54
|
try {
|
|
49
|
-
|
|
55
|
+
// Short span around the synchronous subscribe() call. The iterator
|
|
56
|
+
// below is long-lived; we don't span its whole lifetime (indefinite
|
|
57
|
+
// spans are anti-pattern in most tracing backends).
|
|
58
|
+
const sub = withSpan('xstate.nats.subscribe', 'xstate.nats.error', { subject }, () =>
|
|
59
|
+
connection.subscribe(subject, subscriptionConfig.opts),
|
|
60
|
+
) as Subscription
|
|
50
61
|
|
|
51
|
-
//
|
|
62
|
+
// Message loop: each received message starts its own span, parented
|
|
63
|
+
// on the traceparent extracted from the message headers (OTel
|
|
64
|
+
// messaging semconv). If the publisher did not propagate context the
|
|
65
|
+
// extracted context falls back to the ambient one, so the span
|
|
66
|
+
// simply becomes a root.
|
|
52
67
|
;(async () => {
|
|
53
68
|
try {
|
|
54
69
|
for await (const msg of sub) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
const parentCtx = extractContextFromHeaders((msg as Msg).headers)
|
|
71
|
+
await withSpan(
|
|
72
|
+
'xstate.nats.message',
|
|
73
|
+
'xstate.nats.error',
|
|
74
|
+
{
|
|
75
|
+
subject,
|
|
76
|
+
'payload.bytes': (msg as Msg).data?.length,
|
|
77
|
+
},
|
|
78
|
+
(span) => {
|
|
79
|
+
try {
|
|
80
|
+
subscriptionConfig?.callback(parseNatsResult(msg))
|
|
81
|
+
} catch (callbackError) {
|
|
82
|
+
// Record on span AND preserve the existing console.error
|
|
83
|
+
// so consumers without OTel still see the failure.
|
|
84
|
+
recordError(span, 'xstate.nats.error', callbackError)
|
|
85
|
+
console.error(`Callback error for subject "${subject}"`, callbackError)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
parentCtx,
|
|
89
|
+
)
|
|
60
90
|
}
|
|
61
91
|
} catch (iteratorError) {
|
|
62
92
|
console.error(`Iterator error for subject "${subject}"`, iteratorError)
|
|
@@ -91,14 +121,44 @@ export const subjectRequest = ({
|
|
|
91
121
|
throw new Error('NATS connection is not available')
|
|
92
122
|
}
|
|
93
123
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
const payloadBytes =
|
|
125
|
+
payload instanceof Uint8Array
|
|
126
|
+
? payload.byteLength
|
|
127
|
+
: typeof payload === 'string'
|
|
128
|
+
? payload.length
|
|
129
|
+
: undefined
|
|
130
|
+
|
|
131
|
+
void withSpan(
|
|
132
|
+
'xstate.nats.request',
|
|
133
|
+
'xstate.nats.error',
|
|
134
|
+
{
|
|
135
|
+
subject,
|
|
136
|
+
'payload.bytes': payloadBytes,
|
|
137
|
+
'timeout.ms': opts?.timeout,
|
|
138
|
+
},
|
|
139
|
+
(span) => {
|
|
140
|
+
// Inject INSIDE the span so the replying service parents its handler
|
|
141
|
+
// span on this request span (not on an ambient context). RequestOptions
|
|
142
|
+
// declares `timeout` as required but nats-core only enforces it when
|
|
143
|
+
// opts is provided; cast preserves the "no opts = use conn default"
|
|
144
|
+
// contract.
|
|
145
|
+
const headers = injectContextIntoHeaders(opts?.headers)
|
|
146
|
+
const requestOpts = (opts ? { ...opts, headers } : { headers }) as RequestOptions
|
|
147
|
+
|
|
148
|
+
return connection
|
|
149
|
+
.request(subject, payload, requestOpts)
|
|
150
|
+
.then((msg: Msg) => {
|
|
151
|
+
callback(parseNatsResult(msg))
|
|
152
|
+
})
|
|
153
|
+
.catch((err) => {
|
|
154
|
+
// Record on span manually so we can swallow the rejection here —
|
|
155
|
+
// the original fire-and-forget API didn't propagate request errors
|
|
156
|
+
// to callers and changing that now would be a breaking behaviour.
|
|
157
|
+
recordError(span, 'xstate.nats.error', err)
|
|
158
|
+
console.error(`RequestReply error for subject "${subject}"`, err)
|
|
159
|
+
})
|
|
160
|
+
},
|
|
161
|
+
)
|
|
102
162
|
}
|
|
103
163
|
|
|
104
164
|
export const subjectPublish = ({
|
|
@@ -117,8 +177,26 @@ export const subjectPublish = ({
|
|
|
117
177
|
throw new Error('NATS connection is not available')
|
|
118
178
|
}
|
|
119
179
|
|
|
180
|
+
const payloadBytes =
|
|
181
|
+
payload instanceof Uint8Array
|
|
182
|
+
? payload.byteLength
|
|
183
|
+
: typeof payload === 'string'
|
|
184
|
+
? payload.length
|
|
185
|
+
: undefined
|
|
186
|
+
|
|
120
187
|
try {
|
|
121
|
-
|
|
188
|
+
withSpan(
|
|
189
|
+
'xstate.nats.publish',
|
|
190
|
+
'xstate.nats.error',
|
|
191
|
+
{ subject, 'payload.bytes': payloadBytes },
|
|
192
|
+
() => {
|
|
193
|
+
// Inject INSIDE the span so downstream subscribers see THIS span as
|
|
194
|
+
// parent instead of whatever was ambient before publish.
|
|
195
|
+
const headers = injectContextIntoHeaders(options?.headers)
|
|
196
|
+
const publishOpts: PublishOptions = { ...(options ?? {}), headers }
|
|
197
|
+
connection.publish(subject, payload, publishOpts)
|
|
198
|
+
},
|
|
199
|
+
)
|
|
122
200
|
onPublishResult?.({ ok: true })
|
|
123
201
|
} catch (callbackError) {
|
|
124
202
|
console.error(`Publish callback error for subject "${subject}"`, callbackError)
|
package/src/machines/kv.test.ts
CHANGED
|
@@ -2,9 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
2
2
|
import { createActor, fromPromise, sendTo, setup, assign } from 'xstate'
|
|
3
3
|
import { kvManagerLogic } from './kv'
|
|
4
4
|
|
|
5
|
-
vi.mock('@nats-io/nats-core', () =>
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
vi.mock('@nats-io/nats-core', async () => {
|
|
6
|
+
const actual = await vi.importActual<typeof import('@nats-io/nats-core')>('@nats-io/nats-core')
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
wsconnect: vi.fn(),
|
|
10
|
+
}
|
|
11
|
+
})
|
|
8
12
|
|
|
9
13
|
// We'll set up specific Kvm mock behavior per test
|
|
10
14
|
const mockKvStore = {
|
|
@@ -2,10 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
2
2
|
import { createActor, fromPromise, createMachine, sendParent } from 'xstate'
|
|
3
3
|
import { natsMachine } from './root'
|
|
4
4
|
|
|
5
|
-
vi.mock('@nats-io/nats-core', () =>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
vi.mock('@nats-io/nats-core', async () => {
|
|
6
|
+
const actual = await vi.importActual<typeof import('@nats-io/nats-core')>('@nats-io/nats-core')
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
wsconnect: vi.fn(),
|
|
10
|
+
credsAuthenticator: vi.fn(),
|
|
11
|
+
}
|
|
12
|
+
})
|
|
9
13
|
|
|
10
14
|
vi.mock('@nats-io/kv', () => ({
|
|
11
15
|
Kvm: vi.fn().mockImplementation(() => ({})),
|
|
@@ -2,9 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
2
2
|
import { createActor, createMachine, assign, fromPromise, sendTo, setup } from 'xstate'
|
|
3
3
|
import { subjectManagerLogic } from './subject'
|
|
4
4
|
|
|
5
|
-
vi.mock('@nats-io/nats-core', () =>
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
vi.mock('@nats-io/nats-core', async () => {
|
|
6
|
+
const actual = await vi.importActual<typeof import('@nats-io/nats-core')>('@nats-io/nats-core')
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
wsconnect: vi.fn(),
|
|
10
|
+
}
|
|
11
|
+
})
|
|
8
12
|
|
|
9
13
|
function createMockConnection() {
|
|
10
14
|
return {
|
|
@@ -195,7 +199,11 @@ describe('subjectManagerLogic', () => {
|
|
|
195
199
|
payload: { msg: 'hello' },
|
|
196
200
|
})
|
|
197
201
|
|
|
198
|
-
expect(connection.publish).toHaveBeenCalledWith(
|
|
202
|
+
expect(connection.publish).toHaveBeenCalledWith(
|
|
203
|
+
'test.pub',
|
|
204
|
+
{ msg: 'hello' },
|
|
205
|
+
expect.objectContaining({ headers: expect.anything() }),
|
|
206
|
+
)
|
|
199
207
|
parentActor.stop()
|
|
200
208
|
})
|
|
201
209
|
|
|
@@ -214,7 +222,11 @@ describe('subjectManagerLogic', () => {
|
|
|
214
222
|
callback,
|
|
215
223
|
})
|
|
216
224
|
|
|
217
|
-
expect(connection.request).toHaveBeenCalledWith(
|
|
225
|
+
expect(connection.request).toHaveBeenCalledWith(
|
|
226
|
+
'test.req',
|
|
227
|
+
{ data: 1 },
|
|
228
|
+
expect.objectContaining({ headers: expect.anything() }),
|
|
229
|
+
)
|
|
218
230
|
parentActor.stop()
|
|
219
231
|
})
|
|
220
232
|
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// OpenTelemetry helpers scoped to this package.
|
|
2
|
+
//
|
|
3
|
+
// `@opentelemetry/api` is declared as a peer dependency so the consumer
|
|
4
|
+
// controls the installed version. If the consumer never registers a
|
|
5
|
+
// TracerProvider / TextMapPropagator, every call here becomes a no-op.
|
|
6
|
+
//
|
|
7
|
+
// Design choices:
|
|
8
|
+
// - Single module-level tracer, named and versioned from package.json.
|
|
9
|
+
// - `withSpan` wraps the common try/catch/recordException/setStatus pattern
|
|
10
|
+
// so call sites stay readable.
|
|
11
|
+
// - `injectContextIntoHeaders` / `extractContextFromHeaders` bridge the
|
|
12
|
+
// OTel propagation API onto NATS `MsgHdrs`. Outgoing messages carry the
|
|
13
|
+
// active traceparent; incoming messages become the active context for
|
|
14
|
+
// the per-message span so cross-process traces nest automatically.
|
|
15
|
+
|
|
16
|
+
import type { Context, Span, Tracer } from '@opentelemetry/api'
|
|
17
|
+
import { context as otelContext, propagation, SpanStatusCode, trace } from '@opentelemetry/api'
|
|
18
|
+
import type { MsgHdrs } from '@nats-io/nats-core'
|
|
19
|
+
import { headers as natsHeaders } from '@nats-io/nats-core'
|
|
20
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
21
|
+
|
|
22
|
+
const TRACER_NAME = '@jr200-labs/xstate-nats'
|
|
23
|
+
|
|
24
|
+
// Do not cache — `trace.getTracer` already returns a lightweight ProxyTracer,
|
|
25
|
+
// and caching across global-provider swaps (e.g. test teardown / re-register)
|
|
26
|
+
// would pin the delegate to a retired provider and silently lose spans.
|
|
27
|
+
export function getTracer(): Tracer {
|
|
28
|
+
return trace.getTracer(TRACER_NAME, pkg.version)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Truncate the stack trace before attaching it to a span event. Some backends
|
|
32
|
+
// (and the wire format itself) perform poorly with arbitrarily long strings;
|
|
33
|
+
// the first ~1KB is almost always enough to identify the site.
|
|
34
|
+
const MAX_STACK_LEN = 1024
|
|
35
|
+
|
|
36
|
+
function truncateStack(err: unknown): string | undefined {
|
|
37
|
+
if (!(err instanceof Error) || !err.stack) return undefined
|
|
38
|
+
return err.stack.length > MAX_STACK_LEN
|
|
39
|
+
? err.stack.slice(0, MAX_STACK_LEN) + '...(truncated)'
|
|
40
|
+
: err.stack
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Record an error on a span using the OTel canonical pattern:
|
|
45
|
+
* recordException + ERROR status + a named event with the truncated stack.
|
|
46
|
+
*/
|
|
47
|
+
export function recordError(span: Span, errorEventName: string, err: unknown): void {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
49
|
+
if (err instanceof Error) {
|
|
50
|
+
span.recordException(err)
|
|
51
|
+
}
|
|
52
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message })
|
|
53
|
+
const stack = truncateStack(err)
|
|
54
|
+
span.addEvent(errorEventName, stack ? { stack } : undefined)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run `fn` inside an active span. On synchronous throw or async rejection the
|
|
59
|
+
* error is recorded, the span status is set to ERROR, and the error is
|
|
60
|
+
* re-thrown (callers keep their existing error-handling semantics).
|
|
61
|
+
*/
|
|
62
|
+
export function withSpan<T>(
|
|
63
|
+
name: string,
|
|
64
|
+
errorEventName: string,
|
|
65
|
+
attributes: Record<string, string | number | boolean | undefined>,
|
|
66
|
+
fn: (span: Span) => T | Promise<T>,
|
|
67
|
+
parentContext?: Context,
|
|
68
|
+
): T | Promise<T> {
|
|
69
|
+
const tracer = getTracer()
|
|
70
|
+
const sanitized = sanitizeAttributes(attributes)
|
|
71
|
+
const ctx = parentContext ?? otelContext.active()
|
|
72
|
+
return tracer.startActiveSpan(name, { attributes: sanitized }, ctx, (span) => {
|
|
73
|
+
try {
|
|
74
|
+
const result = fn(span)
|
|
75
|
+
if (result && typeof (result as Promise<T>).then === 'function') {
|
|
76
|
+
return (result as Promise<T>).then(
|
|
77
|
+
(value) => {
|
|
78
|
+
span.end()
|
|
79
|
+
return value
|
|
80
|
+
},
|
|
81
|
+
(err: unknown) => {
|
|
82
|
+
recordError(span, errorEventName, err)
|
|
83
|
+
span.end()
|
|
84
|
+
throw err
|
|
85
|
+
},
|
|
86
|
+
) as unknown as T
|
|
87
|
+
}
|
|
88
|
+
span.end()
|
|
89
|
+
return result
|
|
90
|
+
} catch (err) {
|
|
91
|
+
recordError(span, errorEventName, err)
|
|
92
|
+
span.end()
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// OTel attributes cannot be `undefined`; strip those so call sites can pass
|
|
99
|
+
// optional values without a pre-filter.
|
|
100
|
+
function sanitizeAttributes(
|
|
101
|
+
attrs: Record<string, string | number | boolean | undefined>,
|
|
102
|
+
): Record<string, string | number | boolean> {
|
|
103
|
+
const out: Record<string, string | number | boolean> = {}
|
|
104
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
105
|
+
if (v !== undefined) out[k] = v
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Carrier adapters: NATS `MsgHdrs` exposes `.set(k, v)`, `.get(k)`, `.keys()`
|
|
111
|
+
// which maps cleanly to the OTel TextMapSetter / TextMapGetter interface.
|
|
112
|
+
const natsHeaderSetter = {
|
|
113
|
+
set: (carrier: MsgHdrs, key: string, value: string) => {
|
|
114
|
+
carrier.set(key, value)
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const natsHeaderGetter = {
|
|
119
|
+
get: (carrier: MsgHdrs | undefined, key: string): string | undefined => {
|
|
120
|
+
const v = carrier?.get(key)
|
|
121
|
+
return v || undefined
|
|
122
|
+
},
|
|
123
|
+
keys: (carrier: MsgHdrs | undefined): string[] => carrier?.keys() ?? [],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Inject the active OTel context into a NATS headers object, creating one if
|
|
128
|
+
* none was supplied. Returns the headers object so the caller can pass it to
|
|
129
|
+
* `connection.publish` / `connection.request` via the `opts.headers` field.
|
|
130
|
+
*/
|
|
131
|
+
export function injectContextIntoHeaders(existing?: MsgHdrs): MsgHdrs {
|
|
132
|
+
const hdrs = existing ?? natsHeaders()
|
|
133
|
+
propagation.inject(otelContext.active(), hdrs, natsHeaderSetter)
|
|
134
|
+
return hdrs
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract a parent OTel context from an incoming NATS message's headers. When
|
|
139
|
+
* no traceparent is present the returned context is whatever was already
|
|
140
|
+
* active — callers can use it as the parent for a new span without a branch.
|
|
141
|
+
*/
|
|
142
|
+
export function extractContextFromHeaders(hdrs: MsgHdrs | undefined): Context {
|
|
143
|
+
if (!hdrs) return otelContext.active()
|
|
144
|
+
return propagation.extract(otelContext.active(), hdrs, natsHeaderGetter)
|
|
145
|
+
}
|