@ciphyrshq/sdk 3.0.0 → 3.0.1

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.
@@ -1,194 +0,0 @@
1
- /**
2
- * CiphyrsTracer — buffered span and metric ingestion.
3
- * Run with: npm test
4
- *
5
- * This is the only route spans, traces and metrics take to the platform, and
6
- * its failure mode is silence: when ingestion breaks nothing errors, data just
7
- * never arrives, and an empty dashboard is indistinguishable from a quiet day.
8
- *
9
- * The buffers are spliced empty BEFORE the send, so a `.catch` that only
10
- * logged meant any failure the client's retries could not absorb destroyed
11
- * that batch permanently. sdk-node's client.js already re-queued; this package
12
- * did not, so the same customer got different durability from two Ciphyrs
13
- * SDKs doing the same job.
14
- */
15
-
16
- // No test in this package may open a socket — see no-network.test-helper.js.
17
- // Imported per file because node --test runs each file in its own process.
18
- import './no-network.test-helper.js'
19
- import { describe, it, beforeEach, afterEach } from 'node:test'
20
- import assert from 'node:assert/strict'
21
- import { CiphyrsTracer } from './tracer.js'
22
-
23
- // console.error is the delivery mechanism for "your telemetry is not arriving",
24
- // so it is asserted on rather than silenced.
25
- let logged
26
- let realError
27
- beforeEach(() => { logged = []; realError = console.error; console.error = (m) => logged.push(String(m)) })
28
- afterEach(() => { console.error = realError })
29
-
30
- function fakeClient({ failIngest = false, failMetrics = false } = {}) {
31
- const calls = { ingest: [], metrics: [] }
32
- return {
33
- calls,
34
- _baseUrl: 'https://api',
35
- trace: {
36
- ingest: async (project, trace, spans) => {
37
- calls.ingest.push({ trace, spans })
38
- if (failIngest) throw new Error('collector unreachable')
39
- return { ok: true }
40
- },
41
- },
42
- _request: async (url, opts) => {
43
- calls.metrics.push(opts.body)
44
- if (failMetrics) throw new Error('collector unreachable')
45
- return { ok: true }
46
- },
47
- }
48
- }
49
-
50
- const tracerFor = (client, opts = {}) =>
51
- // flushIntervalMs is large so the timer never races an assertion; every
52
- // flush below is explicit.
53
- new CiphyrsTracer(client, { projectName: 'billing', flushIntervalMs: 3_600_000, ...opts })
54
-
55
- // Reaches the private #spanBuffer the only way a test legitimately can:
56
- // enqueue, flush, and observe what a subsequent flush still tries to send.
57
- const pendingSpanCount = async (tracer, client) => {
58
- const before = client.calls.ingest.length
59
- await tracer.flush()
60
- return client.calls.ingest.slice(before).reduce((n, c) => n + c.spans.length, 0)
61
- }
62
-
63
- describe('CiphyrsTracer — a successful flush', () => {
64
- it('sends buffered spans grouped into one call per trace', async () => {
65
- const client = fakeClient()
66
- const t = tracerFor(client)
67
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
68
- t._enqueueSpan({ trace_id: 'A', name: 'a2' })
69
- t._enqueueSpan({ trace_id: 'B', name: 'b1' })
70
- await t.flush()
71
- assert.equal(client.calls.ingest.length, 2, 'traces must not be merged')
72
- assert.deepEqual(client.calls.ingest.map(c => c.spans.length), [2, 1])
73
- })
74
-
75
- it('sends buffered metrics', async () => {
76
- const client = fakeClient()
77
- const t = tracerFor(client)
78
- t.emitMetric('token_cost', 0.42, { unit: 'usd' })
79
- await t.flush()
80
- assert.equal(client.calls.metrics[0].metrics[0].name, 'token_cost')
81
- })
82
-
83
- it('empties the buffers, so nothing is sent twice', async () => {
84
- const client = fakeClient()
85
- const t = tracerFor(client)
86
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
87
- t.emitMetric('m', 1)
88
- await t.flush()
89
- await t.flush()
90
- assert.equal(client.calls.ingest.length, 1)
91
- assert.equal(client.calls.metrics.length, 1)
92
- })
93
-
94
- it('does nothing when there is nothing buffered', async () => {
95
- const client = fakeClient()
96
- await tracerFor(client).flush()
97
- assert.equal(client.calls.ingest.length + client.calls.metrics.length, 0)
98
- })
99
- })
100
-
101
- describe('CiphyrsTracer — a failed flush must not destroy the batch', () => {
102
- it('re-queues spans and says so', async () => {
103
- const client = fakeClient({ failIngest: true })
104
- const t = tracerFor(client)
105
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
106
- await t.flush()
107
- assert.match(logged.join('\n'), /re-queued/)
108
- assert.equal(await pendingSpanCount(t, client), 1, 'the span was destroyed')
109
- })
110
-
111
- it('re-queues metrics and says so', async () => {
112
- const client = fakeClient({ failMetrics: true })
113
- const t = tracerFor(client)
114
- t.emitMetric('token_cost', 0.42)
115
- await t.flush()
116
- const before = client.calls.metrics.length
117
- await t.flush()
118
- assert.ok(client.calls.metrics.length > before, 'the metric was destroyed')
119
- assert.equal(client.calls.metrics.at(-1).metrics[0].name, 'token_cost')
120
- })
121
-
122
- it('delivers the batch once the collector comes back', async () => {
123
- // Re-queueing is only worth anything if a later attempt picks it up.
124
- const client = fakeClient({ failIngest: true })
125
- const t = tracerFor(client)
126
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
127
- await t.flush()
128
- client.trace.ingest = async (p, trace, spans) => {
129
- client.calls.ingest.push({ trace, spans }); return { ok: true }
130
- }
131
- await t.flush()
132
- assert.equal(client.calls.ingest.at(-1).spans[0].name, 'a1')
133
- // ...and is then gone, not resent forever.
134
- const n = client.calls.ingest.length
135
- await t.flush()
136
- assert.equal(client.calls.ingest.length, n)
137
- })
138
-
139
- it('keeps two failed traces in the order they were produced', async () => {
140
- // Spans post one trace at a time, so the second failure lands on top of
141
- // the first — the one case where re-queueing at the front would silently
142
- // reverse the timeline.
143
- const client = fakeClient({ failIngest: true })
144
- const t = tracerFor(client)
145
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
146
- t._enqueueSpan({ trace_id: 'B', name: 'b1' })
147
- await t.flush()
148
- const before = client.calls.ingest.length
149
- await t.flush()
150
- const names = client.calls.ingest.slice(before).flatMap(c => c.spans.map(s => s.name))
151
- assert.deepEqual(names, ['a1', 'b1'])
152
- })
153
-
154
- it('bounds the re-queue so an outage cannot exhaust memory', async () => {
155
- // A tracing SDK that OOMs the process it observes is worse than one that
156
- // drops telemetry — but it has to say what it dropped.
157
- const client = fakeClient({ failIngest: true })
158
- const t = tracerFor(client, { batchSize: 5 }) // cap = max(5*20, 1000) = 1000
159
- for (let i = 0; i < 1200; i++) t._enqueueSpan({ trace_id: 'A', name: `s${i}` })
160
- await t.flush()
161
- assert.match(logged.join('\n'), /dropped \d+ oldest/)
162
- assert.ok(await pendingSpanCount(t, client) <= 1000)
163
- })
164
-
165
- it('drops the oldest, keeping the most recent spans', async () => {
166
- const client = fakeClient({ failIngest: true })
167
- const t = tracerFor(client, { batchSize: 5 })
168
- for (let i = 0; i < 1200; i++) t._enqueueSpan({ trace_id: 'A', name: `s${i}` })
169
- await t.flush()
170
- const before = client.calls.ingest.length
171
- await t.flush()
172
- const names = client.calls.ingest.slice(before).flatMap(c => c.spans.map(s => s.name))
173
- assert.equal(names.at(-1), 's1199', 'threw away the newest instead of the oldest')
174
- assert.ok(!names.includes('s0'))
175
- })
176
-
177
- it('a metrics failure does not take the spans down with it', async () => {
178
- const client = fakeClient({ failMetrics: true })
179
- const t = tracerFor(client)
180
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
181
- t.emitMetric('m', 1)
182
- await t.flush()
183
- assert.equal(client.calls.ingest.length, 1, 'spans were sent')
184
- assert.equal(await pendingSpanCount(t, client), 0, 'spans were needlessly re-queued')
185
- })
186
-
187
- it('never rejects — observability must not crash the observed process', async () => {
188
- const client = fakeClient({ failIngest: true, failMetrics: true })
189
- const t = tracerFor(client)
190
- t._enqueueSpan({ trace_id: 'A', name: 'a1' })
191
- t.emitMetric('m', 1)
192
- await t.flush() // must resolve
193
- })
194
- })