@comprehend/telemetry-node 0.1.2 → 0.1.4

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.
@@ -0,0 +1,822 @@
1
+ import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
2
+ import { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
+ import { ComprehendDevSpanProcessor } from './ComprehendDevSpanProcessor';
4
+ import { WebSocketConnection } from './WebSocketConnection';
5
+ import {
6
+ NewObservedServiceMessage,
7
+ NewObservedHttpRouteMessage,
8
+ NewObservedDatabaseMessage,
9
+ NewObservedHttpServiceMessage,
10
+ NewObservedHttpRequestMessage,
11
+ NewObservedDatabaseConnectionMessage,
12
+ NewObservedDatabaseQueryMessage,
13
+ ObservationMessage,
14
+ HttpServerObservation,
15
+ HttpClientObservation,
16
+ DatabaseQueryObservation,
17
+ ObservationInputMessage
18
+ } from './wire-protocol';
19
+
20
+ // Mock the WebSocketConnection
21
+ jest.mock('./WebSocketConnection');
22
+
23
+ // Mock the SQL analyzer
24
+ jest.mock('./sql-analyzer', () => ({
25
+ analyzeSQL: jest.fn()
26
+ }));
27
+
28
+ import { analyzeSQL } from './sql-analyzer';
29
+
30
+ const mockedAnalyzeSQL = analyzeSQL as jest.MockedFunction<typeof analyzeSQL>;
31
+ const MockedWebSocketConnection = WebSocketConnection as jest.MockedClass<typeof WebSocketConnection>;
32
+
33
+ describe('ComprehendDevSpanProcessor', () => {
34
+ let processor: ComprehendDevSpanProcessor;
35
+ let mockConnection: jest.Mocked<WebSocketConnection>;
36
+ let sentMessages: any[];
37
+
38
+ beforeEach(() => {
39
+ // Reset mocks
40
+ MockedWebSocketConnection.mockClear();
41
+ mockedAnalyzeSQL.mockClear();
42
+ sentMessages = [];
43
+
44
+ // Create mock connection instance
45
+ mockConnection = {
46
+ sendMessage: jest.fn((message: any) => {
47
+ sentMessages.push(message);
48
+ }),
49
+ close: jest.fn()
50
+ } as any;
51
+
52
+ MockedWebSocketConnection.mockImplementation(() => mockConnection);
53
+
54
+ // Create processor instance
55
+ processor = new ComprehendDevSpanProcessor({
56
+ organization: 'test-org',
57
+ token: 'test-token'
58
+ });
59
+ });
60
+
61
+ afterEach(() => {
62
+ processor.shutdown();
63
+ });
64
+
65
+ // Helper function to create a mock span
66
+ function createMockSpan(options: {
67
+ name?: string;
68
+ kind?: SpanKind;
69
+ attributes?: Record<string, any>;
70
+ resourceAttributes?: Record<string, any>;
71
+ status?: { code: SpanStatusCode; message?: string };
72
+ events?: Array<{ name: string; attributes?: Record<string, any> }>;
73
+ startTime?: [number, number];
74
+ duration?: [number, number];
75
+ } = {}): ReadableSpan {
76
+ const {
77
+ name = 'test-span',
78
+ kind = SpanKind.INTERNAL,
79
+ attributes = {},
80
+ resourceAttributes = { 'service.name': 'test-service' },
81
+ status = { code: SpanStatusCode.OK },
82
+ events = [],
83
+ startTime = [1700000000, 123456789],
84
+ duration = [0, 100000000] // 100ms
85
+ } = options;
86
+
87
+ return {
88
+ name,
89
+ kind,
90
+ attributes,
91
+ resource: {
92
+ attributes: resourceAttributes
93
+ } as any,
94
+ status,
95
+ events: events.map(e => ({
96
+ name: e.name,
97
+ attributes: e.attributes || {},
98
+ time: startTime
99
+ })),
100
+ startTime,
101
+ duration
102
+ } as ReadableSpan;
103
+ }
104
+
105
+ describe('Service Discovery', () => {
106
+ it('should register a new service and send NewObservedServiceMessage', () => {
107
+ const span = createMockSpan({
108
+ resourceAttributes: {
109
+ 'service.name': 'my-api',
110
+ 'service.namespace': 'production',
111
+ 'deployment.environment': 'prod'
112
+ }
113
+ });
114
+
115
+ processor.onEnd(span);
116
+
117
+ expect(sentMessages).toHaveLength(1);
118
+ expect(sentMessages[0]).toMatchObject({
119
+ event: 'new-entity',
120
+ type: 'service',
121
+ name: 'my-api',
122
+ namespace: 'production',
123
+ environment: 'prod'
124
+ } as NewObservedServiceMessage);
125
+ expect(sentMessages[0].hash).toBeDefined();
126
+ });
127
+
128
+ it('should not duplicate service registrations', () => {
129
+ const span1 = createMockSpan({
130
+ resourceAttributes: { 'service.name': 'my-api' }
131
+ });
132
+ const span2 = createMockSpan({
133
+ resourceAttributes: { 'service.name': 'my-api' }
134
+ });
135
+
136
+ processor.onEnd(span1);
137
+ processor.onEnd(span2);
138
+
139
+ // Should only send one service registration
140
+ const serviceMessages = sentMessages.filter(m => m.event === 'new-entity' && m.type === 'service');
141
+ expect(serviceMessages).toHaveLength(1);
142
+ });
143
+
144
+ it('should handle service without namespace and environment', () => {
145
+ const span = createMockSpan({
146
+ resourceAttributes: { 'service.name': 'simple-service' }
147
+ });
148
+
149
+ processor.onEnd(span);
150
+
151
+ expect(sentMessages[0]).toMatchObject({
152
+ event: 'new-entity',
153
+ type: 'service',
154
+ name: 'simple-service'
155
+ });
156
+ expect(sentMessages[0]).not.toHaveProperty('namespace');
157
+ expect(sentMessages[0]).not.toHaveProperty('environment');
158
+ });
159
+
160
+ it('should ignore spans without service.name', () => {
161
+ const span = createMockSpan({
162
+ resourceAttributes: {}
163
+ });
164
+
165
+ processor.onEnd(span);
166
+
167
+ expect(sentMessages).toHaveLength(0);
168
+ });
169
+ });
170
+
171
+ describe('HTTP Route Processing', () => {
172
+ it('should process HTTP server spans and create route entities', () => {
173
+ const span = createMockSpan({
174
+ kind: SpanKind.SERVER,
175
+ attributes: {
176
+ 'http.route': '/api/users/{id}',
177
+ 'http.method': 'GET',
178
+ 'http.target': '/api/users/123',
179
+ 'http.status_code': 200
180
+ }
181
+ });
182
+
183
+ processor.onEnd(span);
184
+
185
+ // Should have service + route messages
186
+ expect(sentMessages).toHaveLength(3); // service, route, observation
187
+
188
+ const routeMessage = sentMessages.find(m => m.type === 'http-route') as NewObservedHttpRouteMessage;
189
+ expect(routeMessage).toMatchObject({
190
+ event: 'new-entity',
191
+ type: 'http-route',
192
+ method: 'GET',
193
+ route: '/api/users/{id}'
194
+ });
195
+
196
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
197
+ expect(observationMessage.observations).toHaveLength(1);
198
+
199
+ const observation = observationMessage.observations[0] as HttpServerObservation;
200
+ expect(observation).toMatchObject({
201
+ type: 'http-server',
202
+ path: '/api/users/123',
203
+ status: 200
204
+ });
205
+ });
206
+
207
+ it('should extract path from http.url when http.target is not available', () => {
208
+ const span = createMockSpan({
209
+ kind: SpanKind.SERVER,
210
+ attributes: {
211
+ 'http.route': '/api/test',
212
+ 'http.method': 'POST',
213
+ 'http.url': 'https://example.com/api/test?param=value',
214
+ 'http.status_code': 201
215
+ }
216
+ });
217
+
218
+ processor.onEnd(span);
219
+
220
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
221
+ const observation = observationMessage.observations[0] as HttpServerObservation;
222
+ expect(observation.path).toBe('/api/test');
223
+ });
224
+
225
+ it('should handle relative URLs in http.target', () => {
226
+ const span = createMockSpan({
227
+ kind: SpanKind.SERVER,
228
+ attributes: {
229
+ 'http.route': '/api/test',
230
+ 'http.method': 'GET',
231
+ 'http.target': 'invalid-url',
232
+ 'http.status_code': 400
233
+ }
234
+ });
235
+
236
+ processor.onEnd(span);
237
+
238
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
239
+ const observation = observationMessage.observations[0] as HttpServerObservation;
240
+ // When used with placeholder base, 'invalid-url' becomes '/invalid-url'
241
+ expect(observation.path).toBe('/invalid-url');
242
+ });
243
+
244
+ it('should include optional HTTP attributes when present', () => {
245
+ const span = createMockSpan({
246
+ kind: SpanKind.SERVER,
247
+ attributes: {
248
+ 'http.route': '/api/upload',
249
+ 'http.method': 'POST',
250
+ 'http.target': '/api/upload',
251
+ 'http.status_code': 200,
252
+ 'http.flavor': '1.1',
253
+ 'http.user_agent': 'Mozilla/5.0',
254
+ 'http.request_content_length': 1024,
255
+ 'http.response_content_length': 256
256
+ }
257
+ });
258
+
259
+ processor.onEnd(span);
260
+
261
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
262
+ const observation = observationMessage.observations[0] as HttpServerObservation;
263
+ expect(observation).toMatchObject({
264
+ httpVersion: '1.1',
265
+ userAgent: 'Mozilla/5.0',
266
+ requestBytes: 1024,
267
+ responseBytes: 256
268
+ });
269
+ });
270
+
271
+ it('should not duplicate route registrations', () => {
272
+ const span1 = createMockSpan({
273
+ kind: SpanKind.SERVER,
274
+ attributes: {
275
+ 'http.route': '/api/users',
276
+ 'http.method': 'GET',
277
+ 'http.status_code': 200
278
+ }
279
+ });
280
+ const span2 = createMockSpan({
281
+ kind: SpanKind.SERVER,
282
+ attributes: {
283
+ 'http.route': '/api/users',
284
+ 'http.method': 'GET',
285
+ 'http.status_code': 200
286
+ }
287
+ });
288
+
289
+ processor.onEnd(span1);
290
+ processor.onEnd(span2);
291
+
292
+ const routeMessages = sentMessages.filter(m => m.type === 'http-route');
293
+ expect(routeMessages).toHaveLength(1);
294
+ });
295
+ });
296
+
297
+ describe('HTTP Client Processing', () => {
298
+ it('should process HTTP client spans and create service entities and interactions', () => {
299
+ const span = createMockSpan({
300
+ kind: SpanKind.CLIENT,
301
+ attributes: {
302
+ 'http.url': 'https://api.external.com/v1/data',
303
+ 'http.method': 'GET',
304
+ 'http.status_code': 200
305
+ }
306
+ });
307
+
308
+ processor.onEnd(span);
309
+
310
+ // Should have service + http-service + http-request + observation
311
+ expect(sentMessages).toHaveLength(4);
312
+
313
+ const httpServiceMessage = sentMessages.find(m => m.type === 'http-service') as NewObservedHttpServiceMessage;
314
+ expect(httpServiceMessage).toMatchObject({
315
+ event: 'new-entity',
316
+ type: 'http-service',
317
+ protocol: 'https',
318
+ host: 'api.external.com',
319
+ port: 443
320
+ });
321
+
322
+ const httpRequestMessage = sentMessages.find(m => m.type === 'http-request') as NewObservedHttpRequestMessage;
323
+ expect(httpRequestMessage).toMatchObject({
324
+ event: 'new-interaction',
325
+ type: 'http-request'
326
+ });
327
+
328
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
329
+ const observation = observationMessage.observations[0] as HttpClientObservation;
330
+ expect(observation).toMatchObject({
331
+ type: 'http-client',
332
+ path: '/v1/data',
333
+ method: 'GET',
334
+ status: 200
335
+ });
336
+ });
337
+
338
+ it('should handle HTTP on port 80 and HTTPS on port 443 correctly', () => {
339
+ const httpSpan = createMockSpan({
340
+ kind: SpanKind.CLIENT,
341
+ attributes: {
342
+ 'http.url': 'http://api.example.com/test',
343
+ 'http.method': 'GET'
344
+ }
345
+ });
346
+
347
+ processor.onEnd(httpSpan);
348
+
349
+ const httpServiceMessage = sentMessages.find(m => m.type === 'http-service') as NewObservedHttpServiceMessage;
350
+ expect(httpServiceMessage).toMatchObject({
351
+ protocol: 'http',
352
+ host: 'api.example.com',
353
+ port: 80
354
+ });
355
+ });
356
+
357
+ it('should handle custom ports', () => {
358
+ const span = createMockSpan({
359
+ kind: SpanKind.CLIENT,
360
+ attributes: {
361
+ 'http.url': 'https://api.example.com:8443/test',
362
+ 'http.method': 'POST'
363
+ }
364
+ });
365
+
366
+ processor.onEnd(span);
367
+
368
+ const httpServiceMessage = sentMessages.find(m => m.type === 'http-service') as NewObservedHttpServiceMessage;
369
+ expect(httpServiceMessage).toMatchObject({
370
+ protocol: 'https',
371
+ host: 'api.example.com',
372
+ port: 8443
373
+ });
374
+ });
375
+
376
+ it('should not process client spans without http.method', () => {
377
+ const span = createMockSpan({
378
+ kind: SpanKind.CLIENT,
379
+ attributes: {
380
+ 'http.url': 'https://api.example.com/test'
381
+ // Missing http.method
382
+ }
383
+ });
384
+
385
+ processor.onEnd(span);
386
+
387
+ // Should still create service and interaction, but no observation
388
+ const observationMessages = sentMessages.filter(m => m.event === 'observations');
389
+ expect(observationMessages).toHaveLength(0);
390
+ });
391
+ });
392
+
393
+ describe('Database Processing', () => {
394
+ beforeEach(() => {
395
+ // Setup SQL analyzer mock
396
+ mockedAnalyzeSQL.mockReturnValue({
397
+ tableOperations: { users: ['SELECT'] },
398
+ normalizedQuery: 'SELECT * FROM users',
399
+ presentableQuery: 'SELECT * FROM users'
400
+ });
401
+ });
402
+
403
+ it('should process database spans and create database entities', () => {
404
+ const span = createMockSpan({
405
+ attributes: {
406
+ 'db.system': 'postgresql',
407
+ 'db.name': 'myapp',
408
+ 'net.peer.name': 'db.example.com',
409
+ 'net.peer.port': 5432
410
+ }
411
+ });
412
+
413
+ processor.onEnd(span);
414
+
415
+ const databaseMessage = sentMessages.find(m => m.type === 'database') as NewObservedDatabaseMessage;
416
+ expect(databaseMessage).toMatchObject({
417
+ event: 'new-entity',
418
+ type: 'database',
419
+ system: 'postgresql',
420
+ name: 'myapp',
421
+ host: 'db.example.com',
422
+ port: 5432
423
+ });
424
+ });
425
+
426
+ it('should process database connection strings', () => {
427
+ const span = createMockSpan({
428
+ attributes: {
429
+ 'db.system': 'postgresql',
430
+ 'db.connection_string': 'postgresql://user:password@localhost:5432/testdb'
431
+ }
432
+ });
433
+
434
+ processor.onEnd(span);
435
+
436
+ const databaseMessage = sentMessages.find(m => m.type === 'database') as NewObservedDatabaseMessage;
437
+ expect(databaseMessage).toMatchObject({
438
+ system: 'postgresql',
439
+ host: 'localhost',
440
+ port: 5432,
441
+ name: 'testdb'
442
+ });
443
+
444
+ const connectionMessage = sentMessages.find(m => m.type === 'db-connection') as NewObservedDatabaseConnectionMessage;
445
+ expect(connectionMessage).toMatchObject({
446
+ event: 'new-interaction',
447
+ type: 'db-connection',
448
+ user: 'user'
449
+ });
450
+ });
451
+
452
+ it('should process SQL queries and create query interactions', () => {
453
+ const span = createMockSpan({
454
+ attributes: {
455
+ 'db.system': 'postgresql',
456
+ 'db.statement': 'SELECT * FROM users WHERE id = $1',
457
+ 'db.name': 'myapp',
458
+ 'net.peer.name': 'localhost',
459
+ 'db.response.returned_rows': 1
460
+ }
461
+ });
462
+
463
+ processor.onEnd(span);
464
+
465
+ expect(mockedAnalyzeSQL).toHaveBeenCalledWith('SELECT * FROM users WHERE id = $1');
466
+
467
+ const queryMessage = sentMessages.find(m => m.type === 'db-query') as NewObservedDatabaseQueryMessage;
468
+ expect(queryMessage).toMatchObject({
469
+ event: 'new-interaction',
470
+ type: 'db-query',
471
+ query: 'SELECT * FROM users',
472
+ selects: ['users']
473
+ });
474
+
475
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
476
+ const observation = observationMessage.observations[0] as DatabaseQueryObservation;
477
+ expect(observation).toMatchObject({
478
+ type: 'db-query',
479
+ returnedRows: 1
480
+ });
481
+ });
482
+
483
+ it('should handle multiple table operations in SQL', () => {
484
+ mockedAnalyzeSQL.mockReturnValue({
485
+ tableOperations: {
486
+ users: ['SELECT'],
487
+ orders: ['INSERT'],
488
+ products: ['UPDATE']
489
+ },
490
+ normalizedQuery: 'INSERT INTO orders SELECT * FROM users UPDATE products',
491
+ presentableQuery: 'INSERT INTO orders SELECT * FROM users UPDATE products'
492
+ });
493
+
494
+ const span = createMockSpan({
495
+ attributes: {
496
+ 'db.system': 'mysql',
497
+ 'db.statement': 'INSERT INTO orders SELECT * FROM users; UPDATE products SET stock = 0',
498
+ 'db.name': 'ecommerce'
499
+ }
500
+ });
501
+
502
+ processor.onEnd(span);
503
+
504
+ const queryMessage = sentMessages.find(m => m.type === 'db-query') as NewObservedDatabaseQueryMessage;
505
+ expect(queryMessage).toMatchObject({
506
+ selects: ['users'],
507
+ inserts: ['orders'],
508
+ updates: ['products']
509
+ });
510
+ });
511
+
512
+ it('should not process SQL for non-SQL database systems', () => {
513
+ const span = createMockSpan({
514
+ attributes: {
515
+ 'db.system': 'mongodb',
516
+ 'db.statement': 'db.users.find({_id: ObjectId("...")}))',
517
+ 'db.name': 'myapp'
518
+ }
519
+ });
520
+
521
+ processor.onEnd(span);
522
+
523
+ expect(mockedAnalyzeSQL).not.toHaveBeenCalled();
524
+
525
+ // Should still create database and connection, but no query interaction
526
+ const queryMessages = sentMessages.filter(m => m.type === 'db-query');
527
+ expect(queryMessages).toHaveLength(0);
528
+ });
529
+
530
+ it('should handle alternative database connection string formats', () => {
531
+ const span = createMockSpan({
532
+ attributes: {
533
+ 'db.system': 'mssql',
534
+ 'db.connection_string': 'Server=localhost;Database=TestDB;User Id=sa;Password=secret;'
535
+ }
536
+ });
537
+
538
+ processor.onEnd(span);
539
+
540
+ const databaseMessage = sentMessages.find(m => m.type === 'database') as NewObservedDatabaseMessage;
541
+ expect(databaseMessage).toMatchObject({
542
+ system: 'mssql',
543
+ host: 'localhost',
544
+ name: 'TestDB'
545
+ });
546
+
547
+ const connectionMessage = sentMessages.find(m => m.type === 'db-connection') as NewObservedDatabaseConnectionMessage;
548
+ expect(connectionMessage).toMatchObject({
549
+ user: 'sa'
550
+ });
551
+ });
552
+ });
553
+
554
+ describe('Error Handling', () => {
555
+ it('should extract error information from exception events', () => {
556
+ const span = createMockSpan({
557
+ kind: SpanKind.SERVER,
558
+ attributes: {
559
+ 'http.route': '/api/error',
560
+ 'http.method': 'GET',
561
+ 'http.status_code': 500
562
+ },
563
+ events: [{
564
+ name: 'exception',
565
+ attributes: {
566
+ 'exception.message': 'Database connection failed',
567
+ 'exception.type': 'ConnectionError',
568
+ 'exception.stacktrace': 'Error at line 42\n at method()'
569
+ }
570
+ }],
571
+ status: { code: SpanStatusCode.ERROR, message: 'Internal server error' }
572
+ });
573
+
574
+ processor.onEnd(span);
575
+
576
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
577
+ const observation = observationMessage.observations[0] as HttpServerObservation;
578
+ expect(observation).toMatchObject({
579
+ errorMessage: 'Database connection failed',
580
+ errorType: 'ConnectionError',
581
+ stack: 'Error at line 42\n at method()'
582
+ });
583
+ });
584
+
585
+ it('should fallback to span attributes for error information', () => {
586
+ const span = createMockSpan({
587
+ kind: SpanKind.CLIENT,
588
+ attributes: {
589
+ 'http.url': 'https://api.example.com/fail',
590
+ 'http.method': 'POST',
591
+ 'http.status_code': 404,
592
+ 'exception.message': 'Not found',
593
+ 'exception.type': 'HttpError'
594
+ },
595
+ status: { code: SpanStatusCode.ERROR }
596
+ });
597
+
598
+ processor.onEnd(span);
599
+
600
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
601
+ const observation = observationMessage.observations[0] as HttpClientObservation;
602
+ expect(observation).toMatchObject({
603
+ errorMessage: 'Not found',
604
+ errorType: 'HttpError'
605
+ });
606
+ });
607
+
608
+ it('should handle spans with error status but no explicit error attributes', () => {
609
+ const span = createMockSpan({
610
+ kind: SpanKind.SERVER,
611
+ attributes: {
612
+ 'http.route': '/api/timeout',
613
+ 'http.method': 'GET',
614
+ 'http.status_code': 500
615
+ },
616
+ status: { code: SpanStatusCode.ERROR, message: 'Request timeout' }
617
+ });
618
+
619
+ processor.onEnd(span);
620
+
621
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
622
+ const observation = observationMessage.observations[0] as HttpServerObservation;
623
+ expect(observation.errorMessage).toBe('Request timeout');
624
+ });
625
+ });
626
+
627
+ describe('Sequence Numbers and Batching', () => {
628
+ it('should increment sequence numbers for observation messages', () => {
629
+ const span1 = createMockSpan({
630
+ kind: SpanKind.SERVER,
631
+ attributes: {
632
+ 'http.route': '/api/test1',
633
+ 'http.method': 'GET',
634
+ 'http.status_code': 200
635
+ }
636
+ });
637
+ const span2 = createMockSpan({
638
+ kind: SpanKind.SERVER,
639
+ attributes: {
640
+ 'http.route': '/api/test2',
641
+ 'http.method': 'GET',
642
+ 'http.status_code': 200
643
+ }
644
+ });
645
+
646
+ processor.onEnd(span1);
647
+ processor.onEnd(span2);
648
+
649
+ const observationMessages = sentMessages.filter(m => m.event === 'observations') as ObservationMessage[];
650
+ expect(observationMessages).toHaveLength(2);
651
+ expect(observationMessages[0].seq).toBe(1);
652
+ expect(observationMessages[1].seq).toBe(2);
653
+ });
654
+ });
655
+
656
+ describe('Processor Lifecycle', () => {
657
+ it('should construct with debug logging', () => {
658
+ const mockLogger = jest.fn();
659
+ const debugProcessor = new ComprehendDevSpanProcessor({
660
+ organization: 'test-org',
661
+ token: 'test-token',
662
+ debug: mockLogger
663
+ });
664
+
665
+ expect(MockedWebSocketConnection).toHaveBeenCalledWith('test-org', 'test-token', mockLogger);
666
+ debugProcessor.shutdown();
667
+ });
668
+
669
+ it('should construct with debug enabled as boolean', () => {
670
+ const debugProcessor = new ComprehendDevSpanProcessor({
671
+ organization: 'test-org',
672
+ token: 'test-token',
673
+ debug: true
674
+ });
675
+
676
+ expect(MockedWebSocketConnection).toHaveBeenCalledWith('test-org', 'test-token', console.log);
677
+ debugProcessor.shutdown();
678
+ });
679
+
680
+ it('should construct with debug disabled', () => {
681
+ const noDebugProcessor = new ComprehendDevSpanProcessor({
682
+ organization: 'test-org',
683
+ token: 'test-token',
684
+ debug: false
685
+ });
686
+
687
+ expect(MockedWebSocketConnection).toHaveBeenCalledWith('test-org', 'test-token', undefined);
688
+ noDebugProcessor.shutdown();
689
+ });
690
+
691
+ it('should call connection.close() on shutdown', async () => {
692
+ await processor.shutdown();
693
+ expect(mockConnection.close).toHaveBeenCalled();
694
+ });
695
+
696
+ it('should handle forceFlush without error', async () => {
697
+ await expect(processor.forceFlush()).resolves.toBeUndefined();
698
+ });
699
+
700
+ it('onStart should be a no-op', () => {
701
+ expect(() => processor.onStart({} as any, {} as any)).not.toThrow();
702
+ });
703
+ });
704
+
705
+ describe('Integration Tests', () => {
706
+ it('should handle a complete HTTP server request flow', () => {
707
+ const span = createMockSpan({
708
+ kind: SpanKind.SERVER,
709
+ attributes: {
710
+ 'http.route': '/api/users/{id}',
711
+ 'http.method': 'GET',
712
+ 'http.target': '/api/users/123?include=profile',
713
+ 'http.status_code': 200,
714
+ 'http.user_agent': 'test-client/1.0',
715
+ 'http.request_content_length': 0,
716
+ 'http.response_content_length': 456
717
+ },
718
+ resourceAttributes: {
719
+ 'service.name': 'user-api',
720
+ 'service.namespace': 'backend',
721
+ 'deployment.environment': 'staging'
722
+ },
723
+ startTime: [1700000000, 500000000],
724
+ duration: [0, 50000000] // 50ms
725
+ });
726
+
727
+ processor.onEnd(span);
728
+
729
+ // Verify all expected messages
730
+ expect(sentMessages).toHaveLength(3);
731
+
732
+ const serviceMessage = sentMessages[0] as NewObservedServiceMessage;
733
+ expect(serviceMessage).toMatchObject({
734
+ event: 'new-entity',
735
+ type: 'service',
736
+ name: 'user-api',
737
+ namespace: 'backend',
738
+ environment: 'staging'
739
+ });
740
+
741
+ const routeMessage = sentMessages[1] as NewObservedHttpRouteMessage;
742
+ expect(routeMessage).toMatchObject({
743
+ event: 'new-entity',
744
+ type: 'http-route',
745
+ parent: serviceMessage.hash,
746
+ method: 'GET',
747
+ route: '/api/users/{id}'
748
+ });
749
+
750
+ const observationMessage = sentMessages[2] as ObservationMessage;
751
+ expect(observationMessage.seq).toBe(1);
752
+ expect(observationMessage.observations).toHaveLength(1);
753
+
754
+ const observation = observationMessage.observations[0] as HttpServerObservation;
755
+ expect(observation).toMatchObject({
756
+ type: 'http-server',
757
+ subject: routeMessage.hash,
758
+ timestamp: [1700000000, 500000000],
759
+ path: '/api/users/123',
760
+ status: 200,
761
+ duration: [0, 50000000],
762
+ userAgent: 'test-client/1.0',
763
+ requestBytes: 0,
764
+ responseBytes: 456
765
+ });
766
+ });
767
+
768
+ it('should handle a complete database operation flow', () => {
769
+ mockedAnalyzeSQL.mockReturnValue({
770
+ tableOperations: { users: ['SELECT'], orders: ['INSERT'] },
771
+ normalizedQuery: 'INSERT INTO orders SELECT FROM users WHERE active = true',
772
+ presentableQuery: 'INSERT INTO orders SELECT FROM users WHERE active = true'
773
+ });
774
+
775
+ const span = createMockSpan({
776
+ attributes: {
777
+ 'db.system': 'postgresql',
778
+ 'db.connection_string': 'postgresql://app_user:secret@db.prod.com:5432/ecommerce',
779
+ 'db.statement': 'INSERT INTO orders SELECT FROM users WHERE active = true',
780
+ 'db.response.returned_rows': 5
781
+ },
782
+ startTime: [1700000000, 0],
783
+ duration: [0, 25000000] // 25ms
784
+ });
785
+
786
+ processor.onEnd(span);
787
+
788
+ // Verify all expected messages: service, database, connection, query, observation
789
+ expect(sentMessages).toHaveLength(5);
790
+
791
+ const databaseMessage = sentMessages.find(m => m.type === 'database') as NewObservedDatabaseMessage;
792
+ expect(databaseMessage).toMatchObject({
793
+ system: 'postgresql',
794
+ host: 'db.prod.com',
795
+ port: 5432,
796
+ name: 'ecommerce'
797
+ });
798
+
799
+ const connectionMessage = sentMessages.find(m => m.type === 'db-connection') as NewObservedDatabaseConnectionMessage;
800
+ expect(connectionMessage).toMatchObject({
801
+ user: 'app_user'
802
+ });
803
+
804
+ const queryMessage = sentMessages.find(m => m.type === 'db-query') as NewObservedDatabaseQueryMessage;
805
+ expect(queryMessage).toMatchObject({
806
+ query: 'INSERT INTO orders SELECT FROM users WHERE active = true',
807
+ selects: ['users'],
808
+ inserts: ['orders']
809
+ });
810
+
811
+ const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
812
+ const observation = observationMessage.observations[0] as DatabaseQueryObservation;
813
+ expect(observation).toMatchObject({
814
+ type: 'db-query',
815
+ subject: queryMessage.hash,
816
+ timestamp: [1700000000, 0],
817
+ duration: [0, 25000000],
818
+ returnedRows: 5
819
+ });
820
+ });
821
+ });
822
+ });