@illuma-ai/observability-core 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/dist/__tests__/core-client.test.d.ts +8 -0
  2. package/dist/__tests__/core-client.test.d.ts.map +1 -0
  3. package/dist/__tests__/core-client.test.js +844 -0
  4. package/dist/__tests__/core-client.test.js.map +1 -0
  5. package/dist/__tests__/generation-client.test.d.ts +8 -0
  6. package/dist/__tests__/generation-client.test.d.ts.map +1 -0
  7. package/dist/__tests__/generation-client.test.js +117 -0
  8. package/dist/__tests__/generation-client.test.js.map +1 -0
  9. package/dist/__tests__/span-client.test.d.ts +9 -0
  10. package/dist/__tests__/span-client.test.d.ts.map +1 -0
  11. package/dist/__tests__/span-client.test.js +272 -0
  12. package/dist/__tests__/span-client.test.js.map +1 -0
  13. package/dist/__tests__/trace-client.test.d.ts +9 -0
  14. package/dist/__tests__/trace-client.test.d.ts.map +1 -0
  15. package/dist/__tests__/trace-client.test.js +260 -0
  16. package/dist/__tests__/trace-client.test.js.map +1 -0
  17. package/dist/__tests__/types.test.d.ts +9 -0
  18. package/dist/__tests__/types.test.d.ts.map +1 -0
  19. package/dist/__tests__/types.test.js +453 -0
  20. package/dist/__tests__/types.test.js.map +1 -0
  21. package/dist/__tests__/utils.test.d.ts +7 -0
  22. package/dist/__tests__/utils.test.d.ts.map +1 -0
  23. package/dist/__tests__/utils.test.js +164 -0
  24. package/dist/__tests__/utils.test.js.map +1 -0
  25. package/dist/core-client.d.ts +54 -1
  26. package/dist/core-client.d.ts.map +1 -1
  27. package/dist/core-client.js +187 -1
  28. package/dist/core-client.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/span-client.d.ts +49 -0
  34. package/dist/span-client.d.ts.map +1 -1
  35. package/dist/span-client.js +161 -0
  36. package/dist/span-client.js.map +1 -1
  37. package/dist/trace-client.d.ts +49 -0
  38. package/dist/trace-client.d.ts.map +1 -1
  39. package/dist/trace-client.js +154 -0
  40. package/dist/trace-client.js.map +1 -1
  41. package/dist/types.d.ts +249 -7
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +40 -2
  44. package/dist/types.js.map +1 -1
  45. package/package.json +6 -3
@@ -0,0 +1,844 @@
1
+ /**
2
+ * Integration tests for ObservabilityCoreClient.
3
+ *
4
+ * Validates event batching, auto-flush, manual flush, shutdown behavior,
5
+ * and the TraceClient -> SpanClient -> GenerationClient chaining API.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { ObservabilityCoreClient, IngestionEventType, } from '../index.js';
9
+ // ---------------------------------------------------------------------------
10
+ // Concrete test implementation of the abstract core client
11
+ // ---------------------------------------------------------------------------
12
+ class TestClient extends ObservabilityCoreClient {
13
+ /** Captured fetch calls for assertions. */
14
+ fetchCalls = [];
15
+ /** Configurable mock response returned by fetchWithRetry. */
16
+ mockResponse = {
17
+ status: 200,
18
+ statusText: 'OK',
19
+ ok: true,
20
+ json: async () => ({ successes: [], errors: [] }),
21
+ text: async () => '',
22
+ };
23
+ async fetchWithRetry(url, options) {
24
+ this.fetchCalls.push({ url, options });
25
+ return this.mockResponse;
26
+ }
27
+ /** Helper to inspect the last request body sent. */
28
+ getLastRequestBody() {
29
+ const last = this.fetchCalls.at(-1);
30
+ if (!last?.options.body)
31
+ return undefined;
32
+ return JSON.parse(last.options.body);
33
+ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+ function createClient(overrides = {}) {
39
+ return new TestClient({
40
+ publicKey: 'pk-test',
41
+ secretKey: 'sk-test',
42
+ baseUrl: 'https://observe.test',
43
+ flushAt: 5,
44
+ flushInterval: 0, // disable periodic flush timer in tests
45
+ ...overrides,
46
+ });
47
+ }
48
+ // ---------------------------------------------------------------------------
49
+ // Tests
50
+ // ---------------------------------------------------------------------------
51
+ describe('ObservabilityCoreClient', () => {
52
+ beforeEach(() => {
53
+ vi.useFakeTimers();
54
+ });
55
+ afterEach(() => {
56
+ vi.useRealTimers();
57
+ });
58
+ // -------------------------------------------------------------------------
59
+ // Event batching
60
+ // -------------------------------------------------------------------------
61
+ describe('event batching', () => {
62
+ it('should enqueue events into the internal queue', () => {
63
+ const client = createClient();
64
+ client.enqueue({
65
+ id: 'evt-1',
66
+ type: IngestionEventType.TRACE_CREATE,
67
+ timestamp: '2024-01-01T00:00:00.000Z',
68
+ body: { id: 'trace-1' },
69
+ });
70
+ client.enqueue({
71
+ id: 'evt-2',
72
+ type: IngestionEventType.GENERATION_CREATE,
73
+ timestamp: '2024-01-01T00:00:01.000Z',
74
+ body: { id: 'gen-1', traceId: 'trace-1' },
75
+ });
76
+ const queue = client.getQueue();
77
+ expect(queue).toHaveLength(2);
78
+ expect(queue[0].id).toBe('evt-1');
79
+ expect(queue[1].id).toBe('evt-2');
80
+ });
81
+ it('should include all event fields in the batch payload', async () => {
82
+ const client = createClient({ flushAt: 100 });
83
+ client.enqueue({
84
+ id: 'evt-1',
85
+ type: IngestionEventType.TRACE_CREATE,
86
+ timestamp: '2024-01-01T00:00:00.000Z',
87
+ body: { id: 'trace-1', name: 'test-trace', userId: 'user-42' },
88
+ });
89
+ await client.flush();
90
+ const body = client.getLastRequestBody();
91
+ expect(body).toBeDefined();
92
+ expect(body.batch).toHaveLength(1);
93
+ expect(body.batch[0].type).toBe('trace-create');
94
+ expect(body.batch[0].body).toMatchObject({
95
+ id: 'trace-1',
96
+ name: 'test-trace',
97
+ userId: 'user-42',
98
+ });
99
+ });
100
+ });
101
+ // -------------------------------------------------------------------------
102
+ // Auto-flush at threshold
103
+ // -------------------------------------------------------------------------
104
+ describe('auto-flush at threshold', () => {
105
+ it('should auto-flush when queue reaches flushAt', async () => {
106
+ const client = createClient({ flushAt: 3 });
107
+ // Enqueue 3 events to hit the threshold
108
+ for (let i = 0; i < 3; i++) {
109
+ client.enqueue({
110
+ id: `evt-${i}`,
111
+ type: IngestionEventType.TRACE_CREATE,
112
+ timestamp: '2024-01-01T00:00:00.000Z',
113
+ body: { id: `trace-${i}` },
114
+ });
115
+ }
116
+ // flush() is called asynchronously — allow microtasks to settle
117
+ await vi.advanceTimersByTimeAsync(0);
118
+ expect(client.fetchCalls).toHaveLength(1);
119
+ const body = client.getLastRequestBody();
120
+ expect(body.batch).toHaveLength(3);
121
+ // Queue should be empty after flush
122
+ expect(client.getQueue()).toHaveLength(0);
123
+ });
124
+ it('should not flush before reaching the threshold', () => {
125
+ const client = createClient({ flushAt: 5 });
126
+ for (let i = 0; i < 4; i++) {
127
+ client.enqueue({
128
+ id: `evt-${i}`,
129
+ type: IngestionEventType.TRACE_CREATE,
130
+ timestamp: '2024-01-01T00:00:00.000Z',
131
+ body: { id: `trace-${i}` },
132
+ });
133
+ }
134
+ expect(client.fetchCalls).toHaveLength(0);
135
+ expect(client.getQueue()).toHaveLength(4);
136
+ });
137
+ });
138
+ // -------------------------------------------------------------------------
139
+ // Manual flush
140
+ // -------------------------------------------------------------------------
141
+ describe('manual flush', () => {
142
+ it('should flush all queued events when called explicitly', async () => {
143
+ const client = createClient({ flushAt: 100 });
144
+ client.enqueue({
145
+ id: 'evt-1',
146
+ type: IngestionEventType.TRACE_CREATE,
147
+ timestamp: '2024-01-01T00:00:00.000Z',
148
+ body: { id: 'trace-1' },
149
+ });
150
+ client.enqueue({
151
+ id: 'evt-2',
152
+ type: IngestionEventType.SPAN_CREATE,
153
+ timestamp: '2024-01-01T00:00:00.000Z',
154
+ body: { id: 'span-1', traceId: 'trace-1' },
155
+ });
156
+ await client.flush();
157
+ expect(client.fetchCalls).toHaveLength(1);
158
+ expect(client.getQueue()).toHaveLength(0);
159
+ });
160
+ it('should be a no-op when the queue is empty', async () => {
161
+ const client = createClient();
162
+ await client.flush();
163
+ expect(client.fetchCalls).toHaveLength(0);
164
+ });
165
+ it('should send to the correct ingestion endpoint', async () => {
166
+ const client = createClient({ baseUrl: 'https://my-server.example.com' });
167
+ client.enqueue({
168
+ id: 'evt-1',
169
+ type: IngestionEventType.TRACE_CREATE,
170
+ timestamp: '2024-01-01T00:00:00.000Z',
171
+ body: { id: 'trace-1' },
172
+ });
173
+ await client.flush();
174
+ expect(client.fetchCalls[0].url).toBe('https://my-server.example.com/api/public/ingestion');
175
+ });
176
+ it('should include Authorization header with Basic auth', async () => {
177
+ const client = createClient({
178
+ publicKey: 'pk-abc',
179
+ secretKey: 'sk-xyz',
180
+ });
181
+ client.enqueue({
182
+ id: 'evt-1',
183
+ type: IngestionEventType.TRACE_CREATE,
184
+ timestamp: '2024-01-01T00:00:00.000Z',
185
+ body: { id: 'trace-1' },
186
+ });
187
+ await client.flush();
188
+ const authHeader = client.fetchCalls[0].options.headers['Authorization'];
189
+ // Basic base64("pk-abc:sk-xyz")
190
+ const expected = `Basic ${Buffer.from('pk-abc:sk-xyz').toString('base64')}`;
191
+ expect(authHeader).toBe(expected);
192
+ });
193
+ it('should include SDK metadata in the request body', async () => {
194
+ const client = createClient();
195
+ client.enqueue({
196
+ id: 'evt-1',
197
+ type: IngestionEventType.TRACE_CREATE,
198
+ timestamp: '2024-01-01T00:00:00.000Z',
199
+ body: { id: 'trace-1' },
200
+ });
201
+ await client.flush();
202
+ const body = client.getLastRequestBody();
203
+ expect(body.metadata).toBeDefined();
204
+ expect(body.metadata.publicKey).toBe('pk-test');
205
+ expect(body.metadata.sdkName).toBe('@illuma-ai/observability-core');
206
+ });
207
+ });
208
+ // -------------------------------------------------------------------------
209
+ // Shutdown flushes remaining events
210
+ // -------------------------------------------------------------------------
211
+ describe('shutdown', () => {
212
+ it('should flush remaining events on shutdown', async () => {
213
+ const client = createClient({ flushAt: 100 });
214
+ client.enqueue({
215
+ id: 'evt-1',
216
+ type: IngestionEventType.TRACE_CREATE,
217
+ timestamp: '2024-01-01T00:00:00.000Z',
218
+ body: { id: 'trace-1' },
219
+ });
220
+ client.enqueue({
221
+ id: 'evt-2',
222
+ type: IngestionEventType.GENERATION_CREATE,
223
+ timestamp: '2024-01-01T00:00:00.000Z',
224
+ body: { id: 'gen-1' },
225
+ });
226
+ await client.shutdown();
227
+ expect(client.fetchCalls).toHaveLength(1);
228
+ const body = client.getLastRequestBody();
229
+ expect(body.batch).toHaveLength(2);
230
+ expect(client.getQueue()).toHaveLength(0);
231
+ });
232
+ it('should be safe to call shutdown with an empty queue', async () => {
233
+ const client = createClient();
234
+ await client.shutdown();
235
+ expect(client.fetchCalls).toHaveLength(0);
236
+ });
237
+ it('should clear the flush timer on shutdown', async () => {
238
+ // Enable periodic flush to test timer clearing
239
+ const client = createClient({ flushInterval: 5000 });
240
+ await client.shutdown();
241
+ // Advancing time should not trigger any additional flushes
242
+ client.enqueue({
243
+ id: 'evt-late',
244
+ type: IngestionEventType.TRACE_CREATE,
245
+ timestamp: '2024-01-01T00:00:00.000Z',
246
+ body: { id: 'trace-late' },
247
+ });
248
+ vi.advanceTimersByTime(10_000);
249
+ // Give any pending promises time to resolve
250
+ await vi.advanceTimersByTimeAsync(0);
251
+ // The event should still be in the queue (timer was cleared)
252
+ expect(client.getQueue()).toHaveLength(1);
253
+ });
254
+ });
255
+ // -------------------------------------------------------------------------
256
+ // Disabled client
257
+ // -------------------------------------------------------------------------
258
+ describe('disabled client', () => {
259
+ it('should not enqueue events when disabled', () => {
260
+ const client = createClient({ enabled: false });
261
+ client.enqueue({
262
+ id: 'evt-1',
263
+ type: IngestionEventType.TRACE_CREATE,
264
+ timestamp: '2024-01-01T00:00:00.000Z',
265
+ body: { id: 'trace-1' },
266
+ });
267
+ expect(client.getQueue()).toHaveLength(0);
268
+ });
269
+ it('should return a TraceClient even when disabled', () => {
270
+ const client = createClient({ enabled: false });
271
+ const trace = client.trace({ name: 'noop-trace' });
272
+ expect(trace).toBeDefined();
273
+ expect(trace.id).toBeDefined();
274
+ });
275
+ });
276
+ // -------------------------------------------------------------------------
277
+ // TraceClient chaining: trace -> generation -> score
278
+ // -------------------------------------------------------------------------
279
+ describe('TraceClient chaining', () => {
280
+ it('should create trace, generation, and score events via chaining', async () => {
281
+ const client = createClient({ flushAt: 100 });
282
+ const trace = client.trace({ name: 'chat-workflow' });
283
+ const gen = trace.generation({
284
+ name: 'llm-call',
285
+ model: 'gpt-4o',
286
+ input: [{ role: 'user', content: 'Hello' }],
287
+ });
288
+ gen.end({
289
+ output: { role: 'assistant', content: 'Hi!' },
290
+ usage: { input: 10, output: 5, total: 15 },
291
+ });
292
+ trace.score({ name: 'quality', value: 0.95 });
293
+ await client.flush();
294
+ const body = client.getLastRequestBody();
295
+ expect(body).toBeDefined();
296
+ const types = body.batch.map((e) => e.type);
297
+ expect(types).toContain('trace-create');
298
+ expect(types).toContain('generation-create');
299
+ expect(types).toContain('generation-update');
300
+ expect(types).toContain('score-create');
301
+ // Verify trace event
302
+ const traceEvent = body.batch.find((e) => e.type === 'trace-create');
303
+ expect(traceEvent.body.name).toBe('chat-workflow');
304
+ // Verify generation-create has correct model and traceId
305
+ const genCreate = body.batch.find((e) => e.type === 'generation-create');
306
+ expect(genCreate.body.model).toBe('gpt-4o');
307
+ expect(genCreate.body.traceId).toBe(trace.id);
308
+ // Verify generation-update (from .end()) has usage
309
+ const genUpdate = body.batch.find((e) => e.type === 'generation-update');
310
+ expect(genUpdate.body.usage).toMatchObject({
311
+ input: 10,
312
+ output: 5,
313
+ total: 15,
314
+ });
315
+ // Verify score is attached to the trace
316
+ const scoreEvent = body.batch.find((e) => e.type === 'score-create');
317
+ expect(scoreEvent.body.name).toBe('quality');
318
+ expect(scoreEvent.body.value).toBe(0.95);
319
+ expect(scoreEvent.body.traceId).toBe(trace.id);
320
+ });
321
+ it('should support trace.update() for adding output after the fact', async () => {
322
+ const client = createClient({ flushAt: 100 });
323
+ const trace = client.trace({ name: 'workflow' });
324
+ trace.update({ output: 'final result', tags: ['test'] });
325
+ await client.flush();
326
+ const body = client.getLastRequestBody();
327
+ // Should have 2 trace-create events (initial + update/upsert)
328
+ const traceEvents = body.batch.filter((e) => e.type === 'trace-create');
329
+ expect(traceEvents).toHaveLength(2);
330
+ const updateEvent = traceEvents[1];
331
+ expect(updateEvent.body.output).toBe('final result');
332
+ expect(updateEvent.body.tags).toEqual(['test']);
333
+ });
334
+ });
335
+ // -------------------------------------------------------------------------
336
+ // SpanClient nesting
337
+ // -------------------------------------------------------------------------
338
+ describe('SpanClient nesting', () => {
339
+ it('should support nested spans with correct parentObservationId', async () => {
340
+ const client = createClient({ flushAt: 100 });
341
+ const trace = client.trace({ name: 'pipeline' });
342
+ const outerSpan = trace.span({ name: 'retrieval' });
343
+ const innerSpan = outerSpan.span({ name: 'vector-search' });
344
+ innerSpan.end({ output: { results: 5 } });
345
+ outerSpan.end();
346
+ await client.flush();
347
+ const body = client.getLastRequestBody();
348
+ const spanCreates = body.batch.filter((e) => e.type === 'span-create');
349
+ expect(spanCreates).toHaveLength(2);
350
+ // Outer span: parent is the trace (no parentObservationId, just traceId)
351
+ const outer = spanCreates.find((e) => e.body.name === 'retrieval');
352
+ expect(outer.body.traceId).toBe(trace.id);
353
+ expect(outer.body.parentObservationId).toBeUndefined();
354
+ // Inner span: parentObservationId should be the outer span's ID
355
+ const inner = spanCreates.find((e) => e.body.name === 'vector-search');
356
+ expect(inner.body.traceId).toBe(trace.id);
357
+ expect(inner.body.parentObservationId).toBe(outerSpan.id);
358
+ });
359
+ it('should support creating generations under a span', async () => {
360
+ const client = createClient({ flushAt: 100 });
361
+ const trace = client.trace({ name: 'agent' });
362
+ const span = trace.span({ name: 'tool-call' });
363
+ const gen = span.generation({
364
+ name: 'llm',
365
+ model: 'claude-3-opus',
366
+ });
367
+ gen.end({ output: 'response text' });
368
+ await client.flush();
369
+ const body = client.getLastRequestBody();
370
+ const genCreate = body.batch.find((e) => e.type === 'generation-create');
371
+ expect(genCreate.body.traceId).toBe(trace.id);
372
+ expect(genCreate.body.parentObservationId).toBe(span.id);
373
+ expect(genCreate.body.model).toBe('claude-3-opus');
374
+ });
375
+ it('should support events under a span', async () => {
376
+ const client = createClient({ flushAt: 100 });
377
+ const trace = client.trace({ name: 'workflow' });
378
+ const span = trace.span({ name: 'processing' });
379
+ span.event({ name: 'cache-hit', metadata: { key: 'doc-123' } });
380
+ await client.flush();
381
+ const body = client.getLastRequestBody();
382
+ const eventCreate = body.batch.find((e) => e.type === 'event-create');
383
+ expect(eventCreate.body.traceId).toBe(trace.id);
384
+ expect(eventCreate.body.parentObservationId).toBe(span.id);
385
+ expect(eventCreate.body.name).toBe('cache-hit');
386
+ });
387
+ it('should support scoring a span', async () => {
388
+ const client = createClient({ flushAt: 100 });
389
+ const trace = client.trace({ name: 'workflow' });
390
+ const span = trace.span({ name: 'retrieval' });
391
+ span.score({ name: 'relevance', value: 0.8 });
392
+ await client.flush();
393
+ const body = client.getLastRequestBody();
394
+ const scoreEvent = body.batch.find((e) => e.type === 'score-create');
395
+ expect(scoreEvent.body.traceId).toBe(trace.id);
396
+ expect(scoreEvent.body.observationId).toBe(span.id);
397
+ });
398
+ });
399
+ // -------------------------------------------------------------------------
400
+ // GenerationClient
401
+ // -------------------------------------------------------------------------
402
+ describe('GenerationClient', () => {
403
+ it('should create a generation with model and usage params', async () => {
404
+ const client = createClient({ flushAt: 100 });
405
+ const trace = client.trace({ name: 'llm-call' });
406
+ trace.generation({
407
+ name: 'completion',
408
+ model: 'gpt-4o-mini',
409
+ input: { messages: [{ role: 'user', content: 'Hi' }] },
410
+ output: { role: 'assistant', content: 'Hello!' },
411
+ usage: { input: 5, output: 3, total: 8, unit: 'TOKENS' },
412
+ modelParameters: { temperature: 0.7, max_tokens: 100 },
413
+ });
414
+ await client.flush();
415
+ const body = client.getLastRequestBody();
416
+ const gen = body.batch.find((e) => e.type === 'generation-create');
417
+ expect(gen.body.model).toBe('gpt-4o-mini');
418
+ expect(gen.body.usage).toMatchObject({
419
+ input: 5,
420
+ output: 3,
421
+ total: 8,
422
+ unit: 'TOKENS',
423
+ });
424
+ expect(gen.body.modelParameters).toMatchObject({
425
+ temperature: 0.7,
426
+ max_tokens: 100,
427
+ });
428
+ });
429
+ it('should support update() for streaming completions', async () => {
430
+ const client = createClient({ flushAt: 100 });
431
+ const trace = client.trace({ name: 'stream' });
432
+ const gen = trace.generation({
433
+ name: 'streaming-completion',
434
+ model: 'gpt-4o',
435
+ input: 'prompt',
436
+ });
437
+ // Simulate streaming: update with output and usage after completion
438
+ gen.update({
439
+ output: 'streamed response',
440
+ usage: { input: 20, output: 50, total: 70 },
441
+ });
442
+ await client.flush();
443
+ const body = client.getLastRequestBody();
444
+ const genUpdate = body.batch.find((e) => e.type === 'generation-update');
445
+ expect(genUpdate.body.output).toBe('streamed response');
446
+ expect(genUpdate.body.id).toBe(gen.id);
447
+ });
448
+ it('should support scoring a generation', async () => {
449
+ const client = createClient({ flushAt: 100 });
450
+ const trace = client.trace({ name: 'eval' });
451
+ const gen = trace.generation({ model: 'gpt-4o' });
452
+ gen.score({ name: 'accuracy', value: 1.0, comment: 'Correct answer' });
453
+ await client.flush();
454
+ const body = client.getLastRequestBody();
455
+ const score = body.batch.find((e) => e.type === 'score-create');
456
+ expect(score.body.observationId).toBe(gen.id);
457
+ expect(score.body.traceId).toBe(trace.id);
458
+ expect(score.body.name).toBe('accuracy');
459
+ expect(score.body.value).toBe(1.0);
460
+ expect(score.body.comment).toBe('Correct answer');
461
+ });
462
+ it('should support end() which sets endTime and merges params', async () => {
463
+ const client = createClient({ flushAt: 100 });
464
+ const trace = client.trace({ name: 'test' });
465
+ const gen = trace.generation({ model: 'claude-3-sonnet' });
466
+ gen.end({
467
+ output: 'done',
468
+ usage: { input: 100, output: 200 },
469
+ });
470
+ await client.flush();
471
+ const body = client.getLastRequestBody();
472
+ const genUpdate = body.batch.find((e) => e.type === 'generation-update');
473
+ expect(genUpdate.body.endTime).toBeDefined();
474
+ expect(genUpdate.body.output).toBe('done');
475
+ });
476
+ });
477
+ // -------------------------------------------------------------------------
478
+ // Constructor validation
479
+ // -------------------------------------------------------------------------
480
+ describe('constructor', () => {
481
+ it('should throw when publicKey is missing', () => {
482
+ expect(() => new TestClient({
483
+ publicKey: '',
484
+ secretKey: 'sk-test',
485
+ })).toThrow('publicKey and secretKey are required');
486
+ });
487
+ it('should throw when secretKey is missing', () => {
488
+ expect(() => new TestClient({
489
+ publicKey: 'pk-test',
490
+ secretKey: '',
491
+ })).toThrow('publicKey and secretKey are required');
492
+ });
493
+ it('should strip trailing slashes from baseUrl', async () => {
494
+ const client = createClient({
495
+ baseUrl: 'https://observe.test///',
496
+ });
497
+ client.enqueue({
498
+ id: 'evt-1',
499
+ type: IngestionEventType.TRACE_CREATE,
500
+ timestamp: '2024-01-01T00:00:00.000Z',
501
+ body: { id: 'trace-1' },
502
+ });
503
+ await client.flush();
504
+ expect(client.fetchCalls[0].url).toBe('https://observe.test/api/public/ingestion');
505
+ });
506
+ });
507
+ // -------------------------------------------------------------------------
508
+ // Constructor config defaults
509
+ // -------------------------------------------------------------------------
510
+ describe('config defaults', () => {
511
+ it('should use default baseUrl when not provided', async () => {
512
+ const client = new TestClient({
513
+ publicKey: 'pk-test',
514
+ secretKey: 'sk-test',
515
+ flushInterval: 0,
516
+ });
517
+ client.enqueue({
518
+ id: 'evt-1',
519
+ type: IngestionEventType.TRACE_CREATE,
520
+ timestamp: '2024-01-01T00:00:00.000Z',
521
+ body: { id: 'trace-1' },
522
+ });
523
+ await client.flush();
524
+ expect(client.fetchCalls[0].url).toBe('http://localhost:3000/api/public/ingestion');
525
+ });
526
+ it('should set all config fields correctly', () => {
527
+ // The client doesn't expose config fields publicly, so we verify
528
+ // behavior that depends on them
529
+ const client = createClient({
530
+ publicKey: 'pk-custom',
531
+ secretKey: 'sk-custom',
532
+ baseUrl: 'https://custom.example.com',
533
+ flushAt: 2,
534
+ enabled: true,
535
+ });
536
+ // flushAt = 2 — enqueue 2 events should trigger auto-flush
537
+ client.enqueue({
538
+ id: 'evt-1',
539
+ type: IngestionEventType.TRACE_CREATE,
540
+ timestamp: '2024-01-01T00:00:00.000Z',
541
+ body: { id: 'trace-1' },
542
+ });
543
+ client.enqueue({
544
+ id: 'evt-2',
545
+ type: IngestionEventType.TRACE_CREATE,
546
+ timestamp: '2024-01-01T00:00:00.000Z',
547
+ body: { id: 'trace-2' },
548
+ });
549
+ // Auto-flush was triggered (async), verify URL has custom base
550
+ // The fetch is async, so just verify the queue was drained
551
+ // We can verify the URL after advancing timers
552
+ return vi.advanceTimersByTimeAsync(0).then(() => {
553
+ expect(client.fetchCalls[0].url).toBe('https://custom.example.com/api/public/ingestion');
554
+ });
555
+ });
556
+ });
557
+ // -------------------------------------------------------------------------
558
+ // getQueue() behavior
559
+ // -------------------------------------------------------------------------
560
+ describe('getQueue()', () => {
561
+ it('should return the current events in the queue', () => {
562
+ const client = createClient();
563
+ client.enqueue({
564
+ id: 'evt-1',
565
+ type: IngestionEventType.TRACE_CREATE,
566
+ timestamp: '2024-01-01T00:00:00.000Z',
567
+ body: { id: 'trace-1' },
568
+ });
569
+ const queue = client.getQueue();
570
+ expect(queue).toHaveLength(1);
571
+ expect(queue[0].id).toBe('evt-1');
572
+ });
573
+ it('should return a read-only array', () => {
574
+ const client = createClient();
575
+ const queue = client.getQueue();
576
+ // ReadonlyArray does not have push at runtime, but the reference
577
+ // should reflect the internal array state
578
+ expect(Array.isArray(queue)).toBe(true);
579
+ });
580
+ });
581
+ // -------------------------------------------------------------------------
582
+ // trace() with config environment/release/version
583
+ // -------------------------------------------------------------------------
584
+ describe('trace() config propagation', () => {
585
+ it('should include environment from config when not in params', async () => {
586
+ const client = createClient({ environment: 'production', flushAt: 100 });
587
+ client.trace({ name: 'test-trace' });
588
+ await client.flush();
589
+ const body = client.getLastRequestBody();
590
+ const traceEvt = body.batch.find((e) => e.type === 'trace-create');
591
+ expect(traceEvt.body.environment).toBe('production');
592
+ });
593
+ it('should include release from config when not in params', async () => {
594
+ const client = createClient({ release: 'v1.2.3', flushAt: 100 });
595
+ client.trace({ name: 'test-trace' });
596
+ await client.flush();
597
+ const body = client.getLastRequestBody();
598
+ const traceEvt = body.batch.find((e) => e.type === 'trace-create');
599
+ expect(traceEvt.body.release).toBe('v1.2.3');
600
+ });
601
+ it('should include version from release config when not in params', async () => {
602
+ const client = createClient({ release: 'v2.0.0', flushAt: 100 });
603
+ client.trace({ name: 'test-trace' });
604
+ await client.flush();
605
+ const body = client.getLastRequestBody();
606
+ const traceEvt = body.batch.find((e) => e.type === 'trace-create');
607
+ expect(traceEvt.body.version).toBe('v2.0.0');
608
+ });
609
+ it('should not override params.environment with config.environment', async () => {
610
+ const client = createClient({
611
+ environment: 'production',
612
+ flushAt: 100,
613
+ });
614
+ client.trace({ name: 'test', environment: 'staging' });
615
+ await client.flush();
616
+ const body = client.getLastRequestBody();
617
+ const traceEvt = body.batch.find((e) => e.type === 'trace-create');
618
+ expect(traceEvt.body.environment).toBe('staging');
619
+ });
620
+ it('should not override params.release with config.release', async () => {
621
+ const client = createClient({ release: 'v1.0.0', flushAt: 100 });
622
+ client.trace({ name: 'test', release: 'v2.0.0' });
623
+ await client.flush();
624
+ const body = client.getLastRequestBody();
625
+ const traceEvt = body.batch.find((e) => e.type === 'trace-create');
626
+ expect(traceEvt.body.release).toBe('v2.0.0');
627
+ });
628
+ it('should not override params.version with config.release', async () => {
629
+ const client = createClient({ release: 'v1.0.0', flushAt: 100 });
630
+ client.trace({ name: 'test', version: 'custom-version' });
631
+ await client.flush();
632
+ const body = client.getLastRequestBody();
633
+ const traceEvt = body.batch.find((e) => e.type === 'trace-create');
634
+ expect(traceEvt.body.version).toBe('custom-version');
635
+ });
636
+ });
637
+ // -------------------------------------------------------------------------
638
+ // 207 Multi-Status handling
639
+ // -------------------------------------------------------------------------
640
+ describe('207 Multi-Status response', () => {
641
+ it('should handle 207 response with errors by logging and accepting', async () => {
642
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
643
+ const client = createClient({ flushAt: 100 });
644
+ client.mockResponse = {
645
+ status: 207,
646
+ statusText: 'Multi-Status',
647
+ ok: true,
648
+ json: async () => ({
649
+ successes: [{ id: 'evt-1', status: 200 }],
650
+ errors: [{ id: 'evt-2', status: 400, message: 'Invalid event' }],
651
+ }),
652
+ text: async () => '',
653
+ };
654
+ client.enqueue({
655
+ id: 'evt-1',
656
+ type: IngestionEventType.TRACE_CREATE,
657
+ timestamp: '2024-01-01T00:00:00.000Z',
658
+ body: { id: 'trace-1' },
659
+ });
660
+ client.enqueue({
661
+ id: 'evt-2',
662
+ type: IngestionEventType.TRACE_CREATE,
663
+ timestamp: '2024-01-01T00:00:00.000Z',
664
+ body: { id: 'trace-2' },
665
+ });
666
+ await client.flush();
667
+ // Batch accepted — queue should be empty
668
+ expect(client.getQueue()).toHaveLength(0);
669
+ consoleSpy.mockRestore();
670
+ });
671
+ it('should handle 207 response with no errors', async () => {
672
+ const client = createClient({ flushAt: 100 });
673
+ client.mockResponse = {
674
+ status: 207,
675
+ statusText: 'Multi-Status',
676
+ ok: true,
677
+ json: async () => ({
678
+ successes: [{ id: 'evt-1', status: 200 }],
679
+ errors: [],
680
+ }),
681
+ text: async () => '',
682
+ };
683
+ client.enqueue({
684
+ id: 'evt-1',
685
+ type: IngestionEventType.TRACE_CREATE,
686
+ timestamp: '2024-01-01T00:00:00.000Z',
687
+ body: { id: 'trace-1' },
688
+ });
689
+ await client.flush();
690
+ expect(client.getQueue()).toHaveLength(0);
691
+ });
692
+ });
693
+ // -------------------------------------------------------------------------
694
+ // Flush clears queue after successful send
695
+ // -------------------------------------------------------------------------
696
+ describe('flush queue clearing', () => {
697
+ it('should clear queue after successful send', async () => {
698
+ const client = createClient({ flushAt: 100 });
699
+ client.enqueue({
700
+ id: 'evt-1',
701
+ type: IngestionEventType.TRACE_CREATE,
702
+ timestamp: '2024-01-01T00:00:00.000Z',
703
+ body: { id: 'trace-1' },
704
+ });
705
+ client.enqueue({
706
+ id: 'evt-2',
707
+ type: IngestionEventType.SPAN_CREATE,
708
+ timestamp: '2024-01-01T00:00:00.000Z',
709
+ body: { id: 'span-1' },
710
+ });
711
+ expect(client.getQueue()).toHaveLength(2);
712
+ await client.flush();
713
+ expect(client.getQueue()).toHaveLength(0);
714
+ });
715
+ });
716
+ // -------------------------------------------------------------------------
717
+ // Concurrent flush guard
718
+ // -------------------------------------------------------------------------
719
+ describe('concurrent flush guard', () => {
720
+ it('should skip flush if one is already in progress', async () => {
721
+ const client = createClient({ flushAt: 100 });
722
+ // Make fetchWithRetry slow
723
+ let resolveFirst;
724
+ const originalMockResponse = client.mockResponse;
725
+ let callCount = 0;
726
+ // Override to add delay on first call
727
+ const origFetchCalls = client.fetchCalls;
728
+ client.enqueue({
729
+ id: 'evt-1',
730
+ type: IngestionEventType.TRACE_CREATE,
731
+ timestamp: '2024-01-01T00:00:00.000Z',
732
+ body: { id: 'trace-1' },
733
+ });
734
+ // Start first flush (won't resolve immediately)
735
+ const firstFlush = client.flush();
736
+ // Enqueue more and try second flush while first is in progress
737
+ client.enqueue({
738
+ id: 'evt-2',
739
+ type: IngestionEventType.TRACE_CREATE,
740
+ timestamp: '2024-01-01T00:00:00.000Z',
741
+ body: { id: 'trace-2' },
742
+ });
743
+ // This should be a no-op since flush is already in progress
744
+ const secondFlush = client.flush();
745
+ await firstFlush;
746
+ await secondFlush;
747
+ // First flush sent 1 event, second was skipped
748
+ // evt-2 should still be in the queue
749
+ expect(client.getQueue()).toHaveLength(1);
750
+ expect(client.getQueue()[0].id).toBe('evt-2');
751
+ });
752
+ });
753
+ // -------------------------------------------------------------------------
754
+ // 4xx error handling (drop batch)
755
+ // -------------------------------------------------------------------------
756
+ describe('4xx error handling', () => {
757
+ it('should drop batch on 4xx client errors (not 429)', async () => {
758
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
759
+ const client = createClient({ flushAt: 100, maxRetries: 0 });
760
+ client.mockResponse = {
761
+ status: 401,
762
+ statusText: 'Unauthorized',
763
+ ok: false,
764
+ json: async () => ({}),
765
+ text: async () => 'Invalid API key',
766
+ };
767
+ client.enqueue({
768
+ id: 'evt-1',
769
+ type: IngestionEventType.TRACE_CREATE,
770
+ timestamp: '2024-01-01T00:00:00.000Z',
771
+ body: { id: 'trace-1' },
772
+ });
773
+ await client.flush();
774
+ // 4xx errors drop the batch, not re-queue
775
+ expect(client.getQueue()).toHaveLength(0);
776
+ consoleSpy.mockRestore();
777
+ });
778
+ });
779
+ // -------------------------------------------------------------------------
780
+ // Disabled client: score and flush
781
+ // -------------------------------------------------------------------------
782
+ describe('disabled client edge cases', () => {
783
+ it('should not enqueue scores when disabled', () => {
784
+ const client = createClient({ enabled: false });
785
+ client.score({ traceId: 'trace-1', name: 'test', value: 1 });
786
+ expect(client.getQueue()).toHaveLength(0);
787
+ });
788
+ it('should not send on flush when disabled', async () => {
789
+ const client = createClient({ enabled: false });
790
+ await client.flush();
791
+ expect(client.fetchCalls).toHaveLength(0);
792
+ });
793
+ });
794
+ // -------------------------------------------------------------------------
795
+ // Flush error handling
796
+ // -------------------------------------------------------------------------
797
+ describe('flush error handling', () => {
798
+ it('should re-queue events when fetch throws an error', async () => {
799
+ const client = createClient({ flushAt: 100, maxRetries: 0 });
800
+ // Make fetch throw
801
+ client.mockResponse = {
802
+ status: 500,
803
+ statusText: 'Internal Server Error',
804
+ ok: false,
805
+ json: async () => ({}),
806
+ text: async () => 'server error',
807
+ };
808
+ // Suppress console.error from the logger
809
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
810
+ client.enqueue({
811
+ id: 'evt-1',
812
+ type: IngestionEventType.TRACE_CREATE,
813
+ timestamp: '2024-01-01T00:00:00.000Z',
814
+ body: { id: 'trace-1' },
815
+ });
816
+ await client.flush();
817
+ // Events should be re-queued after failure
818
+ expect(client.getQueue()).toHaveLength(1);
819
+ expect(client.getQueue()[0].id).toBe('evt-1');
820
+ consoleSpy.mockRestore();
821
+ });
822
+ });
823
+ // -------------------------------------------------------------------------
824
+ // Standalone score
825
+ // -------------------------------------------------------------------------
826
+ describe('standalone score', () => {
827
+ it('should enqueue a score event via client.score()', async () => {
828
+ const client = createClient({ flushAt: 100 });
829
+ client.score({
830
+ traceId: 'trace-external',
831
+ name: 'user-feedback',
832
+ value: 5,
833
+ comment: 'Great response',
834
+ });
835
+ await client.flush();
836
+ const body = client.getLastRequestBody();
837
+ const score = body.batch.find((e) => e.type === 'score-create');
838
+ expect(score.body.traceId).toBe('trace-external');
839
+ expect(score.body.name).toBe('user-feedback');
840
+ expect(score.body.value).toBe(5);
841
+ });
842
+ });
843
+ });
844
+ //# sourceMappingURL=core-client.test.js.map