@ciphyrshq/sdk 2.6.0 → 3.0.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 +131 -0
- package/package.json +4 -2
- package/src/client.js +326 -20
- package/src/client.test.js +99 -0
- package/src/context.js +82 -0
- package/src/fail-posture.js +100 -0
- package/src/fail-posture.test.js +385 -0
- package/src/index.js +14 -0
- package/src/no-network.test-helper.js +108 -0
- package/src/propagation.js +388 -0
- package/src/propagation.test.js +429 -0
- package/src/protect-tool.js +362 -0
- package/src/protect-tool.test.js +446 -0
- package/src/secret-detector.js +21 -3
- package/src/secret-detector.test.js +155 -0
- package/src/tracer.js +278 -9
- package/src/tracer.test.js +194 -0
- package/types.d.ts +265 -5
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process trace propagation — the mechanism the topology graph runs on.
|
|
3
|
+
* Run with: npm test
|
|
4
|
+
*
|
|
5
|
+
* WHAT WENT WRONG. The server derives the graph from parent links: an edge
|
|
6
|
+
* A → B exists because a span of agent B names a span of agent A as its
|
|
7
|
+
* parent. This SDK never wrote `traceparent` on an outbound call and never
|
|
8
|
+
* read it on an inbound one, and `parentSpanId` was a hand-threaded option
|
|
9
|
+
* nobody threaded — so every span arrived parentless and a fleet of agents
|
|
10
|
+
* that talked constantly rendered as disconnected dots. Assertions here are
|
|
11
|
+
* about the wire: the exact headers a caller emits, and the exact trace_id /
|
|
12
|
+
* parent_span_id the callee then ships.
|
|
13
|
+
*
|
|
14
|
+
* W3C SHAPE IS LOAD-BEARING. `traceparent` accepts only 32-hex trace ids and
|
|
15
|
+
* 16-hex span ids; randomUUID's dashed form is illegal there. The tracer now
|
|
16
|
+
* generates ids in that shape so the header carries the REAL ids and an
|
|
17
|
+
* OpenTelemetry-instrumented peer joins this exact trace rather than a hash
|
|
18
|
+
* of it.
|
|
19
|
+
*/
|
|
20
|
+
// No test in this package may open a socket — see no-network.test-helper.js.
|
|
21
|
+
// Imported per file because node --test runs each file in its own process.
|
|
22
|
+
import './no-network.test-helper.js'
|
|
23
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
import { CiphyrsTracer } from './tracer.js'
|
|
26
|
+
import {
|
|
27
|
+
inject, extract, withRemoteContext, currentContext,
|
|
28
|
+
instrumentFetch, w3cTraceId, w3cSpanId, _parseBaggage,
|
|
29
|
+
registerInternalOrigin, expressMiddleware, withPropagation,
|
|
30
|
+
} from './propagation.js'
|
|
31
|
+
import { activeIds, activeAgent } from './context.js'
|
|
32
|
+
|
|
33
|
+
const HEX32 = /^[0-9a-f]{32}$/
|
|
34
|
+
const HEX16 = /^[0-9a-f]{16}$/
|
|
35
|
+
|
|
36
|
+
function fakeClient() {
|
|
37
|
+
const calls = { ingest: [], heartbeats: [] }
|
|
38
|
+
return {
|
|
39
|
+
calls,
|
|
40
|
+
_baseUrl: 'https://gw.test',
|
|
41
|
+
trace: {
|
|
42
|
+
ingest: async (project, trace, spans) => { calls.ingest.push({ project, trace, spans }); return { ok: true } },
|
|
43
|
+
},
|
|
44
|
+
_request: async () => ({ ok: true }),
|
|
45
|
+
reportHeartbeat: async (args) => { calls.heartbeats.push(args); return { ok: true } },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// heartbeatIntervalMs 0 keeps the beat timer out of tests that don't want it.
|
|
50
|
+
const tracerFor = (client, opts = {}) => new CiphyrsTracer(client, {
|
|
51
|
+
projectName: 'billing', flushIntervalMs: 3_600_000, heartbeatIntervalMs: 0, ...opts,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('id shapes', () => {
|
|
55
|
+
it('generates W3C-legal trace and span ids', async () => {
|
|
56
|
+
const client = fakeClient()
|
|
57
|
+
const t = tracerFor(client)
|
|
58
|
+
const tr = t.trace('run')
|
|
59
|
+
const s = tr.span('A')
|
|
60
|
+
assert.match(tr.traceId, HEX32, 'trace id must be 32 hex for traceparent')
|
|
61
|
+
assert.match(s.spanId, HEX16, 'span id must be 16 hex for traceparent')
|
|
62
|
+
s.end()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('hashes a non-hex id into the W3C fields instead of emitting an illegal header', () => {
|
|
66
|
+
assert.match(w3cTraceId('tr_legacy'), HEX32)
|
|
67
|
+
assert.match(w3cSpanId('sp_legacy'), HEX16)
|
|
68
|
+
assert.equal(w3cTraceId('a'.repeat(32)), 'a'.repeat(32), 'already-hex passes through')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('inject', () => {
|
|
73
|
+
it('adds nothing outside a trace', () => {
|
|
74
|
+
assert.deepEqual(inject({ 'content-type': 'application/json' }), { 'content-type': 'application/json' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('carries traceparent plus the exact ids and the caller name', async () => {
|
|
78
|
+
const t = tracerFor(fakeClient())
|
|
79
|
+
const tr = t.trace('order')
|
|
80
|
+
await tr.span('RouterAgent', { agentName: 'RouterAgent' }).run(async (s) => {
|
|
81
|
+
const h = inject({})
|
|
82
|
+
const tp = h.traceparent.split('-')
|
|
83
|
+
assert.equal(tp[0], '00')
|
|
84
|
+
assert.equal(tp[1], tr.traceId)
|
|
85
|
+
assert.equal(tp[2], s.spanId)
|
|
86
|
+
assert.equal(tp[3], '01')
|
|
87
|
+
const bag = _parseBaggage(h.baggage)
|
|
88
|
+
assert.equal(bag['ciphyrs.trace_id'], tr.traceId)
|
|
89
|
+
assert.equal(bag['ciphyrs.span_id'], s.spanId)
|
|
90
|
+
assert.equal(bag['ciphyrs.agent'], 'RouterAgent', 'the callee needs the caller NAME for the edge')
|
|
91
|
+
assert.equal(bag['ciphyrs.project'], 'billing')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('preserves another system\'s baggage and replaces only its own members', async () => {
|
|
96
|
+
const t = tracerFor(fakeClient())
|
|
97
|
+
await t.trace('x').span('A', { agentName: 'A' }).run(async () => {
|
|
98
|
+
const h = inject({ baggage: 'userId=42,ciphyrs.trace_id=stale' })
|
|
99
|
+
const bag = _parseBaggage(h.baggage)
|
|
100
|
+
assert.equal(bag.userId, '42')
|
|
101
|
+
assert.notEqual(bag['ciphyrs.trace_id'], 'stale')
|
|
102
|
+
assert.equal(h.baggage.match(/ciphyrs\.trace_id=/g).length, 1, 'no duplicate members')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('works with a Headers object as well as a plain object', async () => {
|
|
107
|
+
const t = tracerFor(fakeClient())
|
|
108
|
+
await t.trace('x').span('A', { agentName: 'A' }).run(async () => {
|
|
109
|
+
const h = new Headers({ 'content-type': 'application/json' })
|
|
110
|
+
inject(h)
|
|
111
|
+
assert.ok(h.get('traceparent'))
|
|
112
|
+
assert.ok(h.get('baggage').includes('ciphyrs.trace_id='))
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('extract', () => {
|
|
118
|
+
it('prefers Ciphyrs baggage (exact ids) over traceparent (hex ids)', () => {
|
|
119
|
+
const ctx = extract({
|
|
120
|
+
traceparent: `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01`,
|
|
121
|
+
baggage: 'ciphyrs.trace_id=T1,ciphyrs.span_id=S1,ciphyrs.agent=Caller,ciphyrs.project=billing',
|
|
122
|
+
})
|
|
123
|
+
assert.equal(ctx.trace_id, 'T1')
|
|
124
|
+
assert.equal(ctx.span_id, 'S1')
|
|
125
|
+
assert.equal(ctx.agent_name, 'Caller')
|
|
126
|
+
assert.equal(ctx.project, 'billing')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('accepts a plain traceparent from a non-Ciphyrs OpenTelemetry peer', () => {
|
|
130
|
+
const ctx = extract({ traceparent: `00-${'c'.repeat(32)}-${'d'.repeat(16)}-01` })
|
|
131
|
+
assert.equal(ctx.trace_id, 'c'.repeat(32))
|
|
132
|
+
assert.equal(ctx.span_id, 'd'.repeat(16))
|
|
133
|
+
assert.equal(ctx.agent_name, undefined)
|
|
134
|
+
assert.equal(ctx.sampled, true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('reads node req.headers, Headers, entry arrays and CGI-style keys', () => {
|
|
138
|
+
assert.equal(extract({ traceparent: `00-${'1'.repeat(32)}-${'2'.repeat(16)}-01` }).trace_id, '1'.repeat(32))
|
|
139
|
+
assert.equal(extract(new Headers({ traceparent: `00-${'3'.repeat(32)}-${'4'.repeat(16)}-01` })).trace_id, '3'.repeat(32))
|
|
140
|
+
assert.equal(extract([['traceparent', `00-${'5'.repeat(32)}-${'6'.repeat(16)}-01`]]).trace_id, '5'.repeat(32))
|
|
141
|
+
assert.equal(extract({ HTTP_TRACEPARENT: `00-${'7'.repeat(32)}-${'8'.repeat(16)}-01` }).trace_id, '7'.repeat(32))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('honours the sampled flag and rejects unusable headers', () => {
|
|
145
|
+
assert.equal(extract({ traceparent: `00-${'9'.repeat(32)}-${'a'.repeat(16)}-00` }).sampled, false)
|
|
146
|
+
for (const h of [undefined, {}, { traceparent: 'nonsense' }, { traceparent: `00-${'0'.repeat(32)}-${'0'.repeat(16)}-01` }]) {
|
|
147
|
+
assert.equal(extract(h), undefined, JSON.stringify(h))
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('continuation — the callee joins the caller\'s trace', () => {
|
|
153
|
+
it('reuses the trace id and parents the first span to the caller\'s span', async () => {
|
|
154
|
+
const callerClient = fakeClient()
|
|
155
|
+
const caller = tracerFor(callerClient)
|
|
156
|
+
const callerTrace = caller.trace('order')
|
|
157
|
+
let headers
|
|
158
|
+
const callerSpan = callerTrace.span('RouterAgent', { agentName: 'RouterAgent' })
|
|
159
|
+
await callerSpan.run(async () => { headers = inject({}) })
|
|
160
|
+
|
|
161
|
+
const calleeClient = fakeClient()
|
|
162
|
+
const callee = tracerFor(calleeClient)
|
|
163
|
+
await withRemoteContext(headers, async () => {
|
|
164
|
+
const t = callee.trace('handle')
|
|
165
|
+
assert.equal(t.isContinuation, true)
|
|
166
|
+
assert.equal(t.traceId, callerTrace.traceId, 'same trace, not a new one')
|
|
167
|
+
assert.equal(t.callerAgent, 'RouterAgent')
|
|
168
|
+
t.span('BillingAgent', { agentName: 'BillingAgent' }).end()
|
|
169
|
+
})
|
|
170
|
+
await callee.flush()
|
|
171
|
+
const { trace, spans } = calleeClient.calls.ingest[0]
|
|
172
|
+
assert.equal(trace.trace_id, callerTrace.traceId)
|
|
173
|
+
assert.equal(spans[0].agent_name, 'BillingAgent')
|
|
174
|
+
assert.equal(spans[0].parent_span_id, callerSpan.spanId,
|
|
175
|
+
'the callee\'s top span must point at the caller\'s span — this IS the edge')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('accepts headers passed directly, for a queue consumer with no middleware', async () => {
|
|
179
|
+
const caller = tracerFor(fakeClient())
|
|
180
|
+
const ct = caller.trace('produce')
|
|
181
|
+
let headers
|
|
182
|
+
await ct.span('Producer', { agentName: 'Producer' }).run(async () => { headers = inject({}) })
|
|
183
|
+
|
|
184
|
+
const client = fakeClient()
|
|
185
|
+
const consumer = tracerFor(client)
|
|
186
|
+
const t = consumer.trace('consume', { headers })
|
|
187
|
+
assert.equal(t.traceId, ct.traceId)
|
|
188
|
+
t.span('Worker', { agentName: 'Worker' }).end()
|
|
189
|
+
await consumer.flush()
|
|
190
|
+
assert.equal(client.calls.ingest[0].spans[0].parent_span_id.length, 16)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does not leak the remote context past the request', async () => {
|
|
194
|
+
const client = fakeClient()
|
|
195
|
+
const t = tracerFor(client)
|
|
196
|
+
await withRemoteContext({ baggage: 'ciphyrs.trace_id=T9,ciphyrs.span_id=S9' }, async () => {})
|
|
197
|
+
const after = t.trace('unrelated')
|
|
198
|
+
assert.equal(after.isContinuation, false)
|
|
199
|
+
assert.notEqual(after.traceId, 'T9')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('gives a guard call in a bare request handler the distributed ids', async () => {
|
|
203
|
+
await withRemoteContext({ baggage: 'ciphyrs.trace_id=T7,ciphyrs.span_id=S7,ciphyrs.agent=Up' }, async () => {
|
|
204
|
+
assert.deepEqual(activeIds(), { trace_id: 'T7', span_id: 'S7' })
|
|
205
|
+
assert.equal(activeAgent(), 'Up')
|
|
206
|
+
})
|
|
207
|
+
assert.deepEqual(activeIds(), { trace_id: undefined, span_id: undefined })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('forwards the trace it is serving when it calls onward (pass-through)', async () => {
|
|
211
|
+
await withRemoteContext({ baggage: 'ciphyrs.trace_id=T5,ciphyrs.span_id=S5,ciphyrs.agent=Up' }, async () => {
|
|
212
|
+
const h = inject({})
|
|
213
|
+
assert.equal(_parseBaggage(h.baggage)['ciphyrs.trace_id'], 'T5')
|
|
214
|
+
assert.equal(currentContext().trace_id, 'T5')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('in-process nesting', () => {
|
|
220
|
+
it('parents a span to the enclosing span with no bookkeeping', async () => {
|
|
221
|
+
const client = fakeClient()
|
|
222
|
+
const t = tracerFor(client)
|
|
223
|
+
const tr = t.trace('run')
|
|
224
|
+
const outer = tr.span('Router', { agentName: 'Router' })
|
|
225
|
+
const inner = tr.span('Billing', { agentName: 'Billing' })
|
|
226
|
+
inner.end()
|
|
227
|
+
const sibling = tr.span('Shipping', { agentName: 'Shipping' })
|
|
228
|
+
sibling.end()
|
|
229
|
+
outer.end()
|
|
230
|
+
const afterOuter = tr.span('Audit', { agentName: 'Audit' })
|
|
231
|
+
afterOuter.end()
|
|
232
|
+
await t.flush()
|
|
233
|
+
const spans = client.calls.ingest.flatMap(c => c.spans)
|
|
234
|
+
const by = Object.fromEntries(spans.map(s => [s.agent_name, s]))
|
|
235
|
+
assert.equal(by.Billing.parent_span_id, outer.spanId)
|
|
236
|
+
assert.equal(by.Shipping.parent_span_id, outer.spanId, 'sibling of Billing, child of Router')
|
|
237
|
+
assert.equal(by.Router.parent_span_id, null)
|
|
238
|
+
assert.equal(by.Audit.parent_span_id, null, 'after outer ended, context is unwound')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('honours an explicit parentSpanId', async () => {
|
|
242
|
+
const client = fakeClient()
|
|
243
|
+
const t = tracerFor(client)
|
|
244
|
+
const tr = t.trace('run')
|
|
245
|
+
const a = tr.span('A', { agentName: 'A' })
|
|
246
|
+
const b = tr.span('B', { agentName: 'B' })
|
|
247
|
+
const c = tr.span('C', { agentName: 'C', parentSpanId: a.spanId })
|
|
248
|
+
c.end(); b.end(); a.end()
|
|
249
|
+
await t.flush()
|
|
250
|
+
const by = Object.fromEntries(client.calls.ingest.flatMap(x => x.spans).map(s => [s.agent_name, s]))
|
|
251
|
+
assert.equal(by.C.parent_span_id, a.spanId, 'explicit parent beats the ambient span (which was B)')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('run() ends the span and records the error when the body throws', async () => {
|
|
255
|
+
const client = fakeClient()
|
|
256
|
+
const t = tracerFor(client)
|
|
257
|
+
const tr = t.trace('run')
|
|
258
|
+
await assert.rejects(() => tr.span('A', { agentName: 'A' }).run(async () => { throw new Error('boom') }), /boom/)
|
|
259
|
+
await t.flush()
|
|
260
|
+
const s = client.calls.ingest[0].spans[0]
|
|
261
|
+
assert.equal(s.status, 'error')
|
|
262
|
+
assert.equal(s.error_message, 'boom')
|
|
263
|
+
assert.ok(s.ended_at, 'span was ended despite the throw')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('end() twice does not enqueue the span twice', async () => {
|
|
267
|
+
const client = fakeClient()
|
|
268
|
+
const t = tracerFor(client)
|
|
269
|
+
const s = t.trace('run').span('A', { agentName: 'A' })
|
|
270
|
+
s.end(); s.end()
|
|
271
|
+
await t.flush()
|
|
272
|
+
assert.equal(client.calls.ingest.flatMap(c => c.spans).length, 1)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('fetch instrumentation', () => {
|
|
277
|
+
let original
|
|
278
|
+
beforeEach(() => { original = globalThis.fetch })
|
|
279
|
+
|
|
280
|
+
it('decorates a call made inside a span and leaves others alone', async () => {
|
|
281
|
+
instrumentFetch()
|
|
282
|
+
const seen = []
|
|
283
|
+
const patched = globalThis.fetch
|
|
284
|
+
// Swap the *underlying* fetch the patch delegates to, so this test does
|
|
285
|
+
// no network I/O while still exercising the real wrapper.
|
|
286
|
+
globalThis.fetch = patched.__ciphyrsOriginal
|
|
287
|
+
const stub = (input, init) => { seen.push({ url: String(input?.url ?? input), headers: new Headers(init?.headers || {}) }); return Promise.resolve(new Response('{}')) }
|
|
288
|
+
globalThis.fetch = stub
|
|
289
|
+
instrumentFetchAgain()
|
|
290
|
+
|
|
291
|
+
const t = tracerFor(fakeClient())
|
|
292
|
+
await globalThis.fetch('https://callee.test/work')
|
|
293
|
+
const tr = t.trace('run')
|
|
294
|
+
const s = tr.span('Caller', { agentName: 'Caller' })
|
|
295
|
+
await s.run(async () => { await globalThis.fetch('https://callee.test/work') })
|
|
296
|
+
|
|
297
|
+
assert.equal(seen.length, 2)
|
|
298
|
+
assert.equal(seen[0].headers.get('traceparent'), null, 'outside a trace: untouched')
|
|
299
|
+
assert.equal(seen[1].headers.get('traceparent').split('-')[1], tr.traceId)
|
|
300
|
+
assert.equal(seen[1].headers.get('traceparent').split('-')[2], s.spanId)
|
|
301
|
+
globalThis.fetch = original
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('never decorates calls to the Ciphyrs API itself', async () => {
|
|
305
|
+
registerInternalOrigin('https://gw.test')
|
|
306
|
+
const seen = []
|
|
307
|
+
globalThis.fetch = (input, init) => { seen.push(new Headers(init?.headers || {})); return Promise.resolve(new Response('{}')) }
|
|
308
|
+
instrumentFetchAgain()
|
|
309
|
+
const t = tracerFor(fakeClient())
|
|
310
|
+
const tr = t.trace('run')
|
|
311
|
+
await tr.span('Caller', { agentName: 'Caller' }).run(async () => {
|
|
312
|
+
await globalThis.fetch('https://gw.test/v1/trace/ingest', { method: 'POST' })
|
|
313
|
+
await globalThis.fetch('https://other.test/x', { ciphyrsInternal: true })
|
|
314
|
+
})
|
|
315
|
+
assert.equal(seen[0].get('traceparent'), null, 'gateway origin is exempt')
|
|
316
|
+
assert.equal(seen[1].get('traceparent'), null, 'explicit ciphyrsInternal is exempt')
|
|
317
|
+
globalThis.fetch = original
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// instrumentFetch() is idempotent by design (module-level guard), so tests
|
|
322
|
+
// that need a fresh wrapper over a fresh stub re-apply it explicitly.
|
|
323
|
+
function instrumentFetchAgain() {
|
|
324
|
+
const original = globalThis.fetch
|
|
325
|
+
globalThis.fetch = function reFetch(input, init = {}) {
|
|
326
|
+
try {
|
|
327
|
+
const url = typeof input === 'string' ? input : (input?.url ?? String(input))
|
|
328
|
+
if (!init?.ciphyrsInternal && !isInternalTestOrigin(url) && currentContext()) {
|
|
329
|
+
const headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers || {})
|
|
330
|
+
inject(headers)
|
|
331
|
+
return original.call(this, input, { ...init, headers })
|
|
332
|
+
}
|
|
333
|
+
} catch { /* ignore */ }
|
|
334
|
+
return original.call(this, input, init)
|
|
335
|
+
}
|
|
336
|
+
globalThis.fetch.__ciphyrsOriginal = original
|
|
337
|
+
}
|
|
338
|
+
function isInternalTestOrigin(url) {
|
|
339
|
+
try { return new URL(url).origin === 'https://gw.test' } catch { return false }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
describe('server integrations', () => {
|
|
343
|
+
it('express middleware activates the caller\'s trace for the handler', async () => {
|
|
344
|
+
const mw = expressMiddleware()
|
|
345
|
+
const headers = { traceparent: `00-${'e'.repeat(32)}-${'f'.repeat(16)}-01` }
|
|
346
|
+
let seen
|
|
347
|
+
await new Promise((resolve) => {
|
|
348
|
+
mw({ headers }, {}, () => { seen = activeIds(); resolve() })
|
|
349
|
+
})
|
|
350
|
+
assert.equal(seen.trace_id, 'e'.repeat(32))
|
|
351
|
+
assert.equal(activeIds().trace_id, undefined, 'and does not leak after the request')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('withPropagation wraps a raw node handler', async () => {
|
|
355
|
+
let seen
|
|
356
|
+
const handler = withPropagation((req) => { seen = activeIds(); return req })
|
|
357
|
+
handler({ headers: { baggage: 'ciphyrs.trace_id=TT' } }, {})
|
|
358
|
+
assert.equal(seen.trace_id, 'TT')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('a request without context simply runs the handler', async () => {
|
|
362
|
+
const mw = expressMiddleware()
|
|
363
|
+
let called = false
|
|
364
|
+
mw({ headers: {} }, {}, () => { called = true })
|
|
365
|
+
assert.equal(called, true)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('heartbeats', () => {
|
|
370
|
+
it('beats for every agent seen, reporting its interval and metrics', async () => {
|
|
371
|
+
const client = fakeClient()
|
|
372
|
+
const t = new CiphyrsTracer(client, {
|
|
373
|
+
projectName: 'billing', flushIntervalMs: 3_600_000,
|
|
374
|
+
agentName: 'planner', heartbeatIntervalMs: 60_000,
|
|
375
|
+
})
|
|
376
|
+
t.trace('run').span('Router', { agentName: 'Router' }).end()
|
|
377
|
+
await new Promise(r => setTimeout(r, 20)) // the immediate first beat
|
|
378
|
+
const names = client.calls.heartbeats.map(h => h.agentName)
|
|
379
|
+
assert.ok(names.includes('planner'), 'the configured agent is visible before any traffic')
|
|
380
|
+
const beat = client.calls.heartbeats[0]
|
|
381
|
+
assert.equal(beat.heartbeatIntervalS, 60, 'the server sizes the down window from this')
|
|
382
|
+
assert.equal(beat.projectName, 'billing')
|
|
383
|
+
assert.equal(beat.status, 'up')
|
|
384
|
+
assert.ok(beat.metrics.rss_mb > 0)
|
|
385
|
+
assert.ok(beat.metrics.runtime.startsWith('node'))
|
|
386
|
+
await t.shutdown()
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('reports the error rate over the spans since the last beat', async () => {
|
|
390
|
+
const client = fakeClient()
|
|
391
|
+
const t = tracerFor(client, { agentName: 'planner' })
|
|
392
|
+
const tr = t.trace('run')
|
|
393
|
+
tr.span('A', { agentName: 'A' }).end()
|
|
394
|
+
const bad = tr.span('B', { agentName: 'B' })
|
|
395
|
+
bad.setError('nope'); bad.end()
|
|
396
|
+
const m = t.collectMetrics()
|
|
397
|
+
assert.equal(m.spans_since_last, 2)
|
|
398
|
+
assert.equal(m.errors_since_last, 1)
|
|
399
|
+
assert.equal(m.error_rate, 0.5)
|
|
400
|
+
assert.equal(t.collectMetrics().spans_since_last, 0, 'counters reset each beat')
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('reports no cpu_pct until it has a baseline and a real window', () => {
|
|
404
|
+
// The first beat fires at start-up, where the elapsed window is ~0ms and
|
|
405
|
+
// cumulative CPU is not: dividing them reported 1371% and marked a
|
|
406
|
+
// healthy agent degraded the instant it booted (measured, two processes).
|
|
407
|
+
const t = tracerFor(fakeClient(), { agentName: 'planner' })
|
|
408
|
+
const first = t.collectMetrics()
|
|
409
|
+
assert.equal('cpu_pct' in first, false, 'no baseline on the first beat')
|
|
410
|
+
const second = t.collectMetrics()
|
|
411
|
+
assert.equal('cpu_pct' in second, false, 'window under 500ms is not measurable either')
|
|
412
|
+
if ('cpu_pct' in second) assert.ok(second.cpu_pct <= 100)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('records agents discovered from spans, not just the configured one', async () => {
|
|
416
|
+
const t = tracerFor(fakeClient(), { agentName: 'configured' })
|
|
417
|
+
t.trace('run').span('Discovered', { agentName: 'Discovered' }).end()
|
|
418
|
+
assert.deepEqual(new Set(t.knownAgents), new Set(['configured', 'Discovered']))
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('does not start a beat timer when the client cannot report one', async () => {
|
|
422
|
+
const client = fakeClient()
|
|
423
|
+
delete client.reportHeartbeat
|
|
424
|
+
const t = new CiphyrsTracer(client, { projectName: 'p', flushIntervalMs: 3_600_000 })
|
|
425
|
+
await new Promise(r => setTimeout(r, 20))
|
|
426
|
+
assert.equal(client.calls.heartbeats.length, 0)
|
|
427
|
+
await t.shutdown()
|
|
428
|
+
})
|
|
429
|
+
})
|