@unrdf/observability 26.4.2
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/.eslintrc.cjs +10 -0
- package/IMPLEMENTATION-SUMMARY.md +478 -0
- package/LICENSE +21 -0
- package/README.md +482 -0
- package/capability-map.md +90 -0
- package/config/alert-rules.yml +269 -0
- package/config/prometheus.yml +136 -0
- package/dashboards/grafana-unrdf.json +798 -0
- package/dashboards/unrdf-workflow-dashboard.json +295 -0
- package/docs/OBSERVABILITY-PATTERNS.md +681 -0
- package/docs/OBSERVABILITY-RUNBOOK.md +554 -0
- package/examples/observability-demo.mjs +334 -0
- package/package.json +46 -0
- package/src/advanced-metrics.mjs +413 -0
- package/src/alerts/alert-manager.mjs +436 -0
- package/src/custom-events.mjs +558 -0
- package/src/distributed-tracing.mjs +352 -0
- package/src/exporters/grafana-exporter.mjs +415 -0
- package/src/index.mjs +61 -0
- package/src/metrics/workflow-metrics.mjs +346 -0
- package/src/receipts/anchor.mjs +155 -0
- package/src/receipts/index.mjs +62 -0
- package/src/receipts/merkle-tree.mjs +188 -0
- package/src/receipts/receipt-chain.mjs +209 -0
- package/src/receipts/receipt-schema.mjs +128 -0
- package/src/receipts/tamper-detection.mjs +219 -0
- package/test/advanced-metrics.test.mjs +302 -0
- package/test/custom-events.test.mjs +387 -0
- package/test/distributed-tracing.test.mjs +314 -0
- package/validation/observability-validation.mjs +366 -0
- package/vitest.config.mjs +25 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Custom Events Tests
|
|
3
|
+
* @module observability/test/custom-events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { createCustomEvents, EventType, EventSeverity } from '../src/custom-events.mjs';
|
|
8
|
+
|
|
9
|
+
describe('CustomEvents', () => {
|
|
10
|
+
let events;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
events = createCustomEvents({
|
|
14
|
+
serviceName: 'test-service',
|
|
15
|
+
enabled: true,
|
|
16
|
+
});
|
|
17
|
+
events.clearEvents();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('Security Events', () => {
|
|
21
|
+
it('should emit authentication failure event', () => {
|
|
22
|
+
const event = events.emitAuthFailure({
|
|
23
|
+
userId: 'user@example.com',
|
|
24
|
+
reason: 'invalid_password',
|
|
25
|
+
ip: '192.168.1.100',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(event).toBeDefined();
|
|
29
|
+
expect(event.type).toBe(EventType.SECURITY_AUTH_FAILURE);
|
|
30
|
+
expect(event.severity).toBe(EventSeverity.WARNING);
|
|
31
|
+
expect(event.attributes['auth.user_id']).toBe('user@example.com');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should emit injection attempt event', () => {
|
|
35
|
+
const event = events.emitInjectionAttempt({
|
|
36
|
+
attackType: 'SPARQL',
|
|
37
|
+
payload: 'DROP ALL; --',
|
|
38
|
+
userId: 'attacker@evil.com',
|
|
39
|
+
ip: '1.2.3.4',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(event).toBeDefined();
|
|
43
|
+
expect(event.type).toBe(EventType.SECURITY_INJECTION_ATTEMPT);
|
|
44
|
+
expect(event.severity).toBe(EventSeverity.CRITICAL);
|
|
45
|
+
expect(event.attributes['injection.type']).toBe('SPARQL');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should hash sensitive payloads', () => {
|
|
49
|
+
const event = events.emitInjectionAttempt({
|
|
50
|
+
attackType: 'SQL',
|
|
51
|
+
payload: 'SELECT * FROM users WHERE password="secret123"',
|
|
52
|
+
ip: '1.2.3.4',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(event.attributes['injection.payload_hash']).toBeDefined();
|
|
56
|
+
expect(event.attributes['injection.payload_hash']).not.toContain('secret123');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Performance Events', () => {
|
|
61
|
+
it('should emit slow query event', () => {
|
|
62
|
+
const event = events.emitSlowQuery({
|
|
63
|
+
query: 'SELECT * WHERE { ?s ?p ?o }',
|
|
64
|
+
duration: 2500,
|
|
65
|
+
threshold: 1000,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(event).toBeDefined();
|
|
69
|
+
expect(event.type).toBe(EventType.PERFORMANCE_SLOW_QUERY);
|
|
70
|
+
expect(event.severity).toBe(EventSeverity.WARNING);
|
|
71
|
+
expect(event.attributes['query.duration_ms']).toBe(2500);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should emit timeout warning event', () => {
|
|
75
|
+
const event = events.emitTimeoutWarning({
|
|
76
|
+
operation: 'federation-query',
|
|
77
|
+
elapsed: 8500,
|
|
78
|
+
timeout: 10000,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(event).toBeDefined();
|
|
82
|
+
expect(event.type).toBe(EventType.PERFORMANCE_TIMEOUT_WARNING);
|
|
83
|
+
expect(event.attributes['operation.remaining_ms']).toBe(1500);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should mark near-timeout as critical', () => {
|
|
87
|
+
const event = events.emitTimeoutWarning({
|
|
88
|
+
operation: 'critical-op',
|
|
89
|
+
elapsed: 9500,
|
|
90
|
+
timeout: 10000,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(event.severity).toBe(EventSeverity.CRITICAL);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should emit high memory event', () => {
|
|
97
|
+
const event = events.emitHighMemory({
|
|
98
|
+
heapUsed: 850 * 1024 * 1024,
|
|
99
|
+
heapTotal: 1000 * 1024 * 1024,
|
|
100
|
+
threshold: 0.85,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(event).toBeDefined();
|
|
104
|
+
expect(event.type).toBe(EventType.PERFORMANCE_MEMORY_HIGH);
|
|
105
|
+
expect(event.attributes['memory.usage_ratio']).toBeCloseTo(0.85, 2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should mark critical memory usage', () => {
|
|
109
|
+
const event = events.emitHighMemory({
|
|
110
|
+
heapUsed: 950 * 1024 * 1024,
|
|
111
|
+
heapTotal: 1000 * 1024 * 1024,
|
|
112
|
+
threshold: 0.85,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(event.severity).toBe(EventSeverity.CRITICAL);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('Business Events', () => {
|
|
120
|
+
it('should emit workflow completion event', () => {
|
|
121
|
+
const event = events.emitWorkflowComplete({
|
|
122
|
+
workflowId: 'workflow-789',
|
|
123
|
+
workflowType: 'data-ingestion',
|
|
124
|
+
duration: 5400,
|
|
125
|
+
success: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(event).toBeDefined();
|
|
129
|
+
expect(event.type).toBe(EventType.BUSINESS_WORKFLOW_COMPLETE);
|
|
130
|
+
expect(event.severity).toBe(EventSeverity.INFO);
|
|
131
|
+
expect(event.correlationId).toBe('workflow-789');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should emit state change event', () => {
|
|
135
|
+
const event = events.emitStateChange({
|
|
136
|
+
entity: 'Dataset',
|
|
137
|
+
entityId: 'dataset-123',
|
|
138
|
+
fromState: 'processing',
|
|
139
|
+
toState: 'complete',
|
|
140
|
+
userId: 'user-456',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(event).toBeDefined();
|
|
144
|
+
expect(event.type).toBe(EventType.BUSINESS_STATE_CHANGE);
|
|
145
|
+
expect(event.attributes['state.from']).toBe('processing');
|
|
146
|
+
expect(event.attributes['state.to']).toBe('complete');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Event Storage', () => {
|
|
151
|
+
it('should store events', () => {
|
|
152
|
+
events.emitAuthFailure({
|
|
153
|
+
userId: 'user1',
|
|
154
|
+
reason: 'test',
|
|
155
|
+
ip: '1.2.3.4',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
events.emitAuthFailure({
|
|
159
|
+
userId: 'user2',
|
|
160
|
+
reason: 'test',
|
|
161
|
+
ip: '1.2.3.5',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(events.events.length).toBe(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should limit stored events to maxEvents', () => {
|
|
168
|
+
// Emit more than max events
|
|
169
|
+
for (let i = 0; i < 1100; i++) {
|
|
170
|
+
events.emitBusinessEvent({
|
|
171
|
+
type: 'test.event',
|
|
172
|
+
message: `Event ${i}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
expect(events.events.length).toBeLessThanOrEqual(1000);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should clear events', () => {
|
|
180
|
+
events.emitAuthFailure({
|
|
181
|
+
userId: 'user',
|
|
182
|
+
reason: 'test',
|
|
183
|
+
ip: '1.2.3.4',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(events.events.length).toBeGreaterThan(0);
|
|
187
|
+
|
|
188
|
+
events.clearEvents();
|
|
189
|
+
|
|
190
|
+
expect(events.events.length).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('Event Querying', () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
// Create test events
|
|
197
|
+
events.emitAuthFailure({
|
|
198
|
+
userId: 'user1',
|
|
199
|
+
reason: 'test',
|
|
200
|
+
ip: '1.2.3.4',
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
events.emitInjectionAttempt({
|
|
204
|
+
attackType: 'SPARQL',
|
|
205
|
+
payload: 'test',
|
|
206
|
+
ip: '1.2.3.5',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
events.emitWorkflowComplete({
|
|
210
|
+
workflowId: 'workflow-1',
|
|
211
|
+
workflowType: 'test',
|
|
212
|
+
duration: 100,
|
|
213
|
+
success: true,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should get events by type', () => {
|
|
218
|
+
const authEvents = events.getEventsByType(EventType.SECURITY_AUTH_FAILURE);
|
|
219
|
+
|
|
220
|
+
expect(authEvents.length).toBe(1);
|
|
221
|
+
expect(authEvents[0].type).toBe(EventType.SECURITY_AUTH_FAILURE);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should limit returned events', () => {
|
|
225
|
+
// Emit many events
|
|
226
|
+
for (let i = 0; i < 20; i++) {
|
|
227
|
+
events.emitBusinessEvent({
|
|
228
|
+
type: 'test.event',
|
|
229
|
+
message: `Event ${i}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const limited = events.getEventsByType('test.event', { limit: 5 });
|
|
234
|
+
|
|
235
|
+
expect(limited.length).toBeLessThanOrEqual(5);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should filter by timestamp', () => {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
|
|
241
|
+
const recent = events.getEventsByType(EventType.SECURITY_AUTH_FAILURE, {
|
|
242
|
+
since: now - 1000,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(recent.length).toBeGreaterThan(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should get events by severity', () => {
|
|
249
|
+
const criticalEvents = events.getEventsBySeverity(EventSeverity.CRITICAL);
|
|
250
|
+
|
|
251
|
+
expect(criticalEvents.length).toBe(1); // Only injection attempt
|
|
252
|
+
expect(criticalEvents[0].type).toBe(EventType.SECURITY_INJECTION_ATTEMPT);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should get events by correlation ID', () => {
|
|
256
|
+
const correlatedEvents = events.getEventsByCorrelationId('workflow-1');
|
|
257
|
+
|
|
258
|
+
expect(correlatedEvents.length).toBe(1);
|
|
259
|
+
expect(correlatedEvents[0].correlationId).toBe('workflow-1');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('Event Statistics', () => {
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
events.emitAuthFailure({
|
|
266
|
+
userId: 'user1',
|
|
267
|
+
reason: 'test',
|
|
268
|
+
ip: '1.2.3.4',
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
events.emitAuthFailure({
|
|
272
|
+
userId: 'user2',
|
|
273
|
+
reason: 'test',
|
|
274
|
+
ip: '1.2.3.5',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
events.emitInjectionAttempt({
|
|
278
|
+
attackType: 'SPARQL',
|
|
279
|
+
payload: 'test',
|
|
280
|
+
ip: '1.2.3.6',
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should compute event statistics', () => {
|
|
285
|
+
const stats = events.getStats();
|
|
286
|
+
|
|
287
|
+
expect(stats.total).toBe(3);
|
|
288
|
+
expect(stats.bySeverity[EventSeverity.WARNING]).toBe(2);
|
|
289
|
+
expect(stats.bySeverity[EventSeverity.CRITICAL]).toBe(1);
|
|
290
|
+
expect(stats.byType[EventType.SECURITY_AUTH_FAILURE]).toBe(2);
|
|
291
|
+
expect(stats.byCategory.security).toBe(3);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('Custom Handler', () => {
|
|
296
|
+
it('should call custom event handler', () => {
|
|
297
|
+
const handlerCalls = [];
|
|
298
|
+
|
|
299
|
+
const eventsWithHandler = createCustomEvents({
|
|
300
|
+
enabled: true,
|
|
301
|
+
eventHandler: event => handlerCalls.push(event),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
eventsWithHandler.emitAuthFailure({
|
|
305
|
+
userId: 'user',
|
|
306
|
+
reason: 'test',
|
|
307
|
+
ip: '1.2.3.4',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(handlerCalls.length).toBe(1);
|
|
311
|
+
expect(handlerCalls[0].type).toBe(EventType.SECURITY_AUTH_FAILURE);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should handle handler errors gracefully', () => {
|
|
315
|
+
const eventsWithBrokenHandler = createCustomEvents({
|
|
316
|
+
enabled: true,
|
|
317
|
+
eventHandler: () => {
|
|
318
|
+
throw new Error('Handler error');
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Should not throw
|
|
323
|
+
expect(() => {
|
|
324
|
+
eventsWithBrokenHandler.emitAuthFailure({
|
|
325
|
+
userId: 'user',
|
|
326
|
+
reason: 'test',
|
|
327
|
+
ip: '1.2.3.4',
|
|
328
|
+
});
|
|
329
|
+
}).not.toThrow();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('Disabled Events', () => {
|
|
334
|
+
it('should not emit when disabled', () => {
|
|
335
|
+
const disabledEvents = createCustomEvents({
|
|
336
|
+
enabled: false,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const event = disabledEvents.emitAuthFailure({
|
|
340
|
+
userId: 'user',
|
|
341
|
+
reason: 'test',
|
|
342
|
+
ip: '1.2.3.4',
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(event).toBeNull();
|
|
346
|
+
expect(disabledEvents.events.length).toBe(0);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('Performance', () => {
|
|
351
|
+
it('should emit events in <0.1ms', () => {
|
|
352
|
+
const start = performance.now();
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < 1000; i++) {
|
|
355
|
+
events.emitBusinessEvent({
|
|
356
|
+
type: 'perf.test',
|
|
357
|
+
message: 'Performance test',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const elapsed = performance.now() - start;
|
|
362
|
+
const avgTime = elapsed / 1000;
|
|
363
|
+
|
|
364
|
+
expect(avgTime).toBeLessThan(0.1); // <0.1ms per event
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should have minimal memory overhead', () => {
|
|
368
|
+
const before = process.memoryUsage().heapUsed;
|
|
369
|
+
|
|
370
|
+
// Emit 1000 events
|
|
371
|
+
for (let i = 0; i < 1000; i++) {
|
|
372
|
+
events.emitBusinessEvent({
|
|
373
|
+
type: 'memory.test',
|
|
374
|
+
message: `Event ${i}`,
|
|
375
|
+
attributes: {
|
|
376
|
+
'test.index': i,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const after = process.memoryUsage().heapUsed;
|
|
382
|
+
const overhead = (after - before) / 1024 / 1024; // MB
|
|
383
|
+
|
|
384
|
+
expect(overhead).toBeLessThan(5); // <5MB for 1000 events
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Distributed Tracing Tests
|
|
3
|
+
* @module observability/test/distributed-tracing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { createDistributedTracing } from '../src/distributed-tracing.mjs';
|
|
8
|
+
import { SpanKind } from '@opentelemetry/api';
|
|
9
|
+
|
|
10
|
+
describe('DistributedTracing', () => {
|
|
11
|
+
let tracing;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tracing = createDistributedTracing({
|
|
15
|
+
serviceName: 'test-service',
|
|
16
|
+
sampling: {
|
|
17
|
+
defaultRate: 1.0, // 100% for testing
|
|
18
|
+
errorRate: 1.0,
|
|
19
|
+
slowThreshold: 1000,
|
|
20
|
+
slowRate: 1.0,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
tracing.shutdown();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('Span Management', () => {
|
|
30
|
+
it('should start a span', () => {
|
|
31
|
+
const spanContext = tracing.startSpan('test-operation');
|
|
32
|
+
|
|
33
|
+
expect(spanContext).toBeDefined();
|
|
34
|
+
expect(spanContext.span).toBeDefined();
|
|
35
|
+
expect(spanContext.spanName).toBe('test-operation');
|
|
36
|
+
expect(spanContext.traceHeaders).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should end a span successfully', () => {
|
|
40
|
+
const spanContext = tracing.startSpan('test-operation');
|
|
41
|
+
|
|
42
|
+
const activeCountBefore = tracing.getActiveSpanCount();
|
|
43
|
+
tracing.endSpan(spanContext);
|
|
44
|
+
const activeCountAfter = tracing.getActiveSpanCount();
|
|
45
|
+
|
|
46
|
+
expect(activeCountAfter).toBe(activeCountBefore - 1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should end a span with error', () => {
|
|
50
|
+
const spanContext = tracing.startSpan('error-operation');
|
|
51
|
+
const error = new Error('Test error');
|
|
52
|
+
|
|
53
|
+
tracing.endSpan(spanContext, { error });
|
|
54
|
+
|
|
55
|
+
// Should not throw
|
|
56
|
+
expect(tracing.getActiveSpanCount()).toBeGreaterThanOrEqual(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should add attributes to span', () => {
|
|
60
|
+
const spanContext = tracing.startSpan('test-operation', {
|
|
61
|
+
attributes: {
|
|
62
|
+
'custom.attr': 'value',
|
|
63
|
+
'custom.number': 42,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(spanContext.span).toBeDefined();
|
|
68
|
+
tracing.endSpan(spanContext);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('Parent-Child Relationships', () => {
|
|
73
|
+
it('should create child span from parent', () => {
|
|
74
|
+
const parentSpan = tracing.startSpan('parent-operation');
|
|
75
|
+
const childSpan = tracing.createChildSpan(parentSpan, 'child-operation');
|
|
76
|
+
|
|
77
|
+
expect(childSpan).toBeDefined();
|
|
78
|
+
expect(childSpan.span).toBeDefined();
|
|
79
|
+
expect(childSpan.spanName).toBe('child-operation');
|
|
80
|
+
|
|
81
|
+
tracing.endSpan(childSpan);
|
|
82
|
+
tracing.endSpan(parentSpan);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should support multiple child spans', () => {
|
|
86
|
+
const parentSpan = tracing.startSpan('parent-operation');
|
|
87
|
+
|
|
88
|
+
const child1 = tracing.createChildSpan(parentSpan, 'child-1');
|
|
89
|
+
const child2 = tracing.createChildSpan(parentSpan, 'child-2');
|
|
90
|
+
const child3 = tracing.createChildSpan(parentSpan, 'child-3');
|
|
91
|
+
|
|
92
|
+
tracing.endSpan(child1);
|
|
93
|
+
tracing.endSpan(child2);
|
|
94
|
+
tracing.endSpan(child3);
|
|
95
|
+
tracing.endSpan(parentSpan);
|
|
96
|
+
|
|
97
|
+
expect(tracing.getActiveSpanCount()).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should support nested child spans', () => {
|
|
101
|
+
const root = tracing.startSpan('root');
|
|
102
|
+
const child1 = tracing.createChildSpan(root, 'child-1');
|
|
103
|
+
const grandchild = tracing.createChildSpan(child1, 'grandchild');
|
|
104
|
+
|
|
105
|
+
tracing.endSpan(grandchild);
|
|
106
|
+
tracing.endSpan(child1);
|
|
107
|
+
tracing.endSpan(root);
|
|
108
|
+
|
|
109
|
+
expect(tracing.getActiveSpanCount()).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('W3C Trace Context', () => {
|
|
114
|
+
it('should generate trace headers', () => {
|
|
115
|
+
const spanContext = tracing.startSpan('test-operation');
|
|
116
|
+
|
|
117
|
+
expect(spanContext.traceHeaders).toBeDefined();
|
|
118
|
+
expect(spanContext.traceHeaders).toHaveProperty('traceparent');
|
|
119
|
+
|
|
120
|
+
tracing.endSpan(spanContext);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should inject trace context into headers', () => {
|
|
124
|
+
const spanContext = tracing.startSpan('test-operation');
|
|
125
|
+
const headers = tracing.injectIntoHeaders(spanContext);
|
|
126
|
+
|
|
127
|
+
expect(headers).toHaveProperty('traceparent');
|
|
128
|
+
expect(typeof headers.traceparent).toBe('string');
|
|
129
|
+
|
|
130
|
+
tracing.endSpan(spanContext);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should extract trace context from headers', () => {
|
|
134
|
+
// Create a span and get headers
|
|
135
|
+
const spanContext = tracing.startSpan('test-operation');
|
|
136
|
+
const headers = tracing.injectIntoHeaders(spanContext);
|
|
137
|
+
|
|
138
|
+
// Extract context from headers
|
|
139
|
+
const extracted = tracing.extractFromHeaders(headers);
|
|
140
|
+
|
|
141
|
+
expect(extracted).toBeDefined();
|
|
142
|
+
expect(extracted.context).toBeDefined();
|
|
143
|
+
|
|
144
|
+
tracing.endSpan(spanContext);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should propagate trace context across services', () => {
|
|
148
|
+
// Service A: Create span and inject headers
|
|
149
|
+
const serviceASpan = tracing.startSpan('service-a-operation');
|
|
150
|
+
const headers = tracing.injectIntoHeaders(serviceASpan);
|
|
151
|
+
|
|
152
|
+
// Service B: Extract headers and create child span
|
|
153
|
+
const extracted = tracing.extractFromHeaders(headers);
|
|
154
|
+
const serviceBSpan = tracing.startSpan('service-b-operation', {
|
|
155
|
+
parentContext: extracted,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
tracing.endSpan(serviceBSpan);
|
|
159
|
+
tracing.endSpan(serviceASpan);
|
|
160
|
+
|
|
161
|
+
expect(tracing.getActiveSpanCount()).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('Correlation', () => {
|
|
166
|
+
it('should correlate by business ID', () => {
|
|
167
|
+
const spanContext = tracing.startSpan('business-operation');
|
|
168
|
+
|
|
169
|
+
tracing.correlateByBusinessId('workflow-123', spanContext);
|
|
170
|
+
|
|
171
|
+
// Correlation should add attributes (verified via span)
|
|
172
|
+
expect(spanContext.span).toBeDefined();
|
|
173
|
+
|
|
174
|
+
tracing.endSpan(spanContext);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should correlate by user ID', () => {
|
|
178
|
+
const spanContext = tracing.startSpan('user-operation');
|
|
179
|
+
|
|
180
|
+
tracing.correlateByUserId('user-456', spanContext);
|
|
181
|
+
|
|
182
|
+
expect(spanContext.span).toBeDefined();
|
|
183
|
+
|
|
184
|
+
tracing.endSpan(spanContext);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('Sampling Strategy', () => {
|
|
189
|
+
it('should sample all operations at 100% rate', () => {
|
|
190
|
+
const spans = [];
|
|
191
|
+
|
|
192
|
+
for (let i = 0; i < 10; i++) {
|
|
193
|
+
spans.push(tracing.startSpan(`op-${i}`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
expect(spans.every(s => s.sampled)).toBe(true);
|
|
197
|
+
|
|
198
|
+
spans.forEach(s => tracing.endSpan(s));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should sample errors at 100%', () => {
|
|
202
|
+
const adaptiveTracing = createDistributedTracing({
|
|
203
|
+
sampling: {
|
|
204
|
+
defaultRate: 0.01,
|
|
205
|
+
errorRate: 1.0,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const errorSpan = adaptiveTracing.startSpan('error-op', {
|
|
210
|
+
attributes: { error: true },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(errorSpan.sampled).toBe(true);
|
|
214
|
+
|
|
215
|
+
adaptiveTracing.endSpan(errorSpan);
|
|
216
|
+
adaptiveTracing.shutdown();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should sample slow operations at configured rate', () => {
|
|
220
|
+
const adaptiveTracing = createDistributedTracing({
|
|
221
|
+
sampling: {
|
|
222
|
+
defaultRate: 0.01,
|
|
223
|
+
slowRate: 0.5,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const slowSpan = adaptiveTracing.startSpan('slow-op', {
|
|
228
|
+
attributes: { slow: true },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Sampling is probabilistic, just verify it doesn't crash
|
|
232
|
+
expect(slowSpan).toBeDefined();
|
|
233
|
+
|
|
234
|
+
adaptiveTracing.endSpan(slowSpan);
|
|
235
|
+
adaptiveTracing.shutdown();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('Async Operations', () => {
|
|
240
|
+
it('should trace async operations with withSpan', async () => {
|
|
241
|
+
const result = await tracing.withSpan('async-operation', async () => {
|
|
242
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
243
|
+
return 'success';
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result).toBe('success');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should handle errors in withSpan', async () => {
|
|
250
|
+
await expect(
|
|
251
|
+
tracing.withSpan('error-operation', async () => {
|
|
252
|
+
throw new Error('Test error');
|
|
253
|
+
})
|
|
254
|
+
).rejects.toThrow('Test error');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('Span Context Management', () => {
|
|
259
|
+
it('should track active spans', () => {
|
|
260
|
+
const span1 = tracing.startSpan('op-1');
|
|
261
|
+
const span2 = tracing.startSpan('op-2');
|
|
262
|
+
|
|
263
|
+
expect(tracing.getActiveSpanCount()).toBe(2);
|
|
264
|
+
|
|
265
|
+
tracing.endSpan(span1);
|
|
266
|
+
expect(tracing.getActiveSpanCount()).toBe(1);
|
|
267
|
+
|
|
268
|
+
tracing.endSpan(span2);
|
|
269
|
+
expect(tracing.getActiveSpanCount()).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should cleanup on shutdown', () => {
|
|
273
|
+
tracing.startSpan('op-1');
|
|
274
|
+
tracing.startSpan('op-2');
|
|
275
|
+
|
|
276
|
+
expect(tracing.getActiveSpanCount()).toBe(2);
|
|
277
|
+
|
|
278
|
+
tracing.shutdown();
|
|
279
|
+
|
|
280
|
+
expect(tracing.getActiveSpanCount()).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('Performance', () => {
|
|
285
|
+
it('should create spans in <0.1ms', () => {
|
|
286
|
+
const start = performance.now();
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < 1000; i++) {
|
|
289
|
+
const span = tracing.startSpan(`perf-test-${i}`);
|
|
290
|
+
tracing.endSpan(span);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const elapsed = performance.now() - start;
|
|
294
|
+
const avgTime = elapsed / 1000;
|
|
295
|
+
|
|
296
|
+
expect(avgTime).toBeLessThan(0.1); // <0.1ms per span
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should have minimal memory overhead', () => {
|
|
300
|
+
const before = process.memoryUsage().heapUsed;
|
|
301
|
+
|
|
302
|
+
// Create and end 1000 spans
|
|
303
|
+
for (let i = 0; i < 1000; i++) {
|
|
304
|
+
const span = tracing.startSpan(`memory-test-${i}`);
|
|
305
|
+
tracing.endSpan(span);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const after = process.memoryUsage().heapUsed;
|
|
309
|
+
const overhead = (after - before) / 1024 / 1024; // MB
|
|
310
|
+
|
|
311
|
+
expect(overhead).toBeLessThan(5); // <5MB overhead
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|