@comprehend/telemetry-node 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.claude/settings.local.json +2 -2
  2. package/.idea/telemetry-node.iml +0 -1
  3. package/README.md +73 -27
  4. package/dist/ComprehendDevSpanProcessor.d.ts +9 -6
  5. package/dist/ComprehendDevSpanProcessor.js +145 -87
  6. package/dist/ComprehendDevSpanProcessor.test.js +270 -449
  7. package/dist/ComprehendMetricsExporter.d.ts +18 -0
  8. package/dist/ComprehendMetricsExporter.js +178 -0
  9. package/dist/ComprehendMetricsExporter.test.d.ts +1 -0
  10. package/dist/ComprehendMetricsExporter.test.js +266 -0
  11. package/dist/ComprehendSDK.d.ts +18 -0
  12. package/dist/ComprehendSDK.js +56 -0
  13. package/dist/ComprehendSDK.test.d.ts +1 -0
  14. package/dist/ComprehendSDK.test.js +126 -0
  15. package/dist/WebSocketConnection.d.ts +23 -3
  16. package/dist/WebSocketConnection.js +106 -12
  17. package/dist/WebSocketConnection.test.js +236 -169
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.js +5 -1
  20. package/dist/sql-analyzer.js +2 -11
  21. package/dist/sql-analyzer.test.js +0 -12
  22. package/dist/util.d.ts +2 -0
  23. package/dist/util.js +7 -0
  24. package/dist/wire-protocol.d.ts +168 -28
  25. package/package.json +3 -1
  26. package/src/ComprehendDevSpanProcessor.test.ts +311 -507
  27. package/src/ComprehendDevSpanProcessor.ts +169 -105
  28. package/src/ComprehendMetricsExporter.test.ts +334 -0
  29. package/src/ComprehendMetricsExporter.ts +225 -0
  30. package/src/ComprehendSDK.test.ts +160 -0
  31. package/src/ComprehendSDK.ts +63 -0
  32. package/src/WebSocketConnection.test.ts +286 -205
  33. package/src/WebSocketConnection.ts +135 -13
  34. package/src/index.ts +3 -2
  35. package/src/util.ts +6 -0
  36. package/src/wire-protocol.ts +204 -29
  37. package/src/sql-analyzer.test.ts +0 -599
  38. package/src/sql-analyzer.ts +0 -439
@@ -1,6 +1,6 @@
1
1
  import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
2
2
  import { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
- import { ComprehendDevSpanProcessor } from './ComprehendDevSpanProcessor';
3
+ import { ComprehendDevSpanProcessor, matchSpanRule } from './ComprehendDevSpanProcessor';
4
4
  import { WebSocketConnection } from './WebSocketConnection';
5
5
  import {
6
6
  NewObservedServiceMessage,
@@ -9,60 +9,48 @@ import {
9
9
  NewObservedHttpServiceMessage,
10
10
  NewObservedHttpRequestMessage,
11
11
  NewObservedDatabaseConnectionMessage,
12
- NewObservedDatabaseQueryMessage,
13
12
  ObservationMessage,
14
13
  HttpServerObservation,
15
14
  HttpClientObservation,
16
- DatabaseQueryObservation,
17
- ObservationInputMessage
15
+ DatabaseQueryMessage,
16
+ TraceSpansMessage,
17
+ CustomObservation,
18
+ ObservationInputMessage,
19
+ SpanMatcherRule,
18
20
  } from './wire-protocol';
19
21
 
20
22
  // Mock the WebSocketConnection
21
23
  jest.mock('./WebSocketConnection');
22
24
 
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
25
  const MockedWebSocketConnection = WebSocketConnection as jest.MockedClass<typeof WebSocketConnection>;
32
26
 
33
27
  describe('ComprehendDevSpanProcessor', () => {
34
28
  let processor: ComprehendDevSpanProcessor;
35
29
  let mockConnection: jest.Mocked<WebSocketConnection>;
36
30
  let sentMessages: any[];
31
+ let seqCounter: number;
37
32
 
38
33
  beforeEach(() => {
39
- // Reset mocks
40
34
  MockedWebSocketConnection.mockClear();
41
- mockedAnalyzeSQL.mockClear();
42
35
  sentMessages = [];
36
+ seqCounter = 1;
43
37
 
44
- // Create mock connection instance
45
38
  mockConnection = {
46
39
  sendMessage: jest.fn((message: any) => {
47
40
  sentMessages.push(message);
48
41
  }),
42
+ setProcessContext: jest.fn(),
43
+ nextSeq: jest.fn(() => seqCounter++),
49
44
  close: jest.fn()
50
45
  } as any;
51
46
 
52
- MockedWebSocketConnection.mockImplementation(() => mockConnection);
53
-
54
- // Create processor instance
55
- processor = new ComprehendDevSpanProcessor({
56
- organization: 'test-org',
57
- token: 'test-token'
58
- });
47
+ processor = new ComprehendDevSpanProcessor(mockConnection);
59
48
  });
60
49
 
61
50
  afterEach(() => {
62
51
  processor.shutdown();
63
52
  });
64
53
 
65
- // Helper function to create a mock span
66
54
  function createMockSpan(options: {
67
55
  name?: string;
68
56
  kind?: SpanKind;
@@ -72,6 +60,9 @@ describe('ComprehendDevSpanProcessor', () => {
72
60
  events?: Array<{ name: string; attributes?: Record<string, any> }>;
73
61
  startTime?: [number, number];
74
62
  duration?: [number, number];
63
+ traceId?: string;
64
+ spanId?: string;
65
+ parentSpanId?: string;
75
66
  } = {}): ReadableSpan {
76
67
  const {
77
68
  name = 'test-span',
@@ -81,7 +72,10 @@ describe('ComprehendDevSpanProcessor', () => {
81
72
  status = { code: SpanStatusCode.OK },
82
73
  events = [],
83
74
  startTime = [1700000000, 123456789],
84
- duration = [0, 100000000] // 100ms
75
+ duration = [0, 100000000],
76
+ traceId = 'abc123trace',
77
+ spanId = 'def456span',
78
+ parentSpanId,
85
79
  } = options;
86
80
 
87
81
  return {
@@ -98,8 +92,10 @@ describe('ComprehendDevSpanProcessor', () => {
98
92
  time: startTime
99
93
  })),
100
94
  startTime,
101
- duration
102
- } as ReadableSpan;
95
+ duration,
96
+ spanContext: () => ({ traceId, spanId, traceFlags: 1 }),
97
+ parentSpanContext: parentSpanId ? { spanId: parentSpanId, traceId: '', traceFlags: 0 } : undefined,
98
+ } as any;
103
99
  }
104
100
 
105
101
  describe('Service Discovery', () => {
@@ -114,176 +110,110 @@ describe('ComprehendDevSpanProcessor', () => {
114
110
 
115
111
  processor.onEnd(span);
116
112
 
117
- expect(sentMessages).toHaveLength(1);
118
- expect(sentMessages[0]).toMatchObject({
113
+ const serviceMsg = sentMessages.find(m => m.event === 'new-entity' && m.type === 'service');
114
+ expect(serviceMsg).toMatchObject({
119
115
  event: 'new-entity',
120
116
  type: 'service',
121
117
  name: 'my-api',
122
118
  namespace: 'production',
123
119
  environment: 'prod'
124
- } as NewObservedServiceMessage);
125
- expect(sentMessages[0].hash).toBeDefined();
120
+ });
121
+ expect(serviceMsg.hash).toBeDefined();
126
122
  });
127
123
 
128
124
  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
- });
125
+ const span1 = createMockSpan({ resourceAttributes: { 'service.name': 'my-api' } });
126
+ const span2 = createMockSpan({ resourceAttributes: { 'service.name': 'my-api' } });
135
127
 
136
128
  processor.onEnd(span1);
137
129
  processor.onEnd(span2);
138
130
 
139
- // Should only send one service registration
140
131
  const serviceMessages = sentMessages.filter(m => m.event === 'new-entity' && m.type === 'service');
141
132
  expect(serviceMessages).toHaveLength(1);
142
133
  });
143
134
 
144
135
  it('should handle service without namespace and environment', () => {
145
- const span = createMockSpan({
146
- resourceAttributes: { 'service.name': 'simple-service' }
147
- });
136
+ const span = createMockSpan({ resourceAttributes: { 'service.name': 'simple-service' } });
148
137
 
149
138
  processor.onEnd(span);
150
139
 
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');
140
+ const serviceMsg = sentMessages.find(m => m.type === 'service');
141
+ expect(serviceMsg).toMatchObject({ name: 'simple-service' });
142
+ expect(serviceMsg).not.toHaveProperty('namespace');
143
+ expect(serviceMsg).not.toHaveProperty('environment');
158
144
  });
159
145
 
160
146
  it('should ignore spans without service.name', () => {
161
- const span = createMockSpan({
162
- resourceAttributes: {}
163
- });
147
+ const span = createMockSpan({ resourceAttributes: {} });
164
148
 
165
149
  processor.onEnd(span);
166
150
 
167
151
  expect(sentMessages).toHaveLength(0);
168
152
  });
169
- });
170
153
 
171
- describe('HTTP Route Processing', () => {
172
- it('should process HTTP server spans and create route entities', () => {
154
+ it('should set process context on first service discovery', () => {
173
155
  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
156
+ resourceAttributes: {
157
+ 'service.name': 'my-api',
158
+ 'deployment.environment': 'prod',
180
159
  }
181
160
  });
182
161
 
183
162
  processor.onEnd(span);
184
163
 
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
- });
164
+ expect(mockConnection.setProcessContext).toHaveBeenCalledTimes(1);
165
+ const [hash, resources] = mockConnection.setProcessContext.mock.calls[0];
166
+ expect(hash).toBeDefined();
167
+ expect(resources['service.name']).toBe('my-api');
168
+ expect(resources['deployment.environment']).toBe('prod');
205
169
  });
206
170
 
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
- });
171
+ it('should only set process context once', () => {
172
+ const span1 = createMockSpan({ resourceAttributes: { 'service.name': 'svc1' } });
173
+ const span2 = createMockSpan({ resourceAttributes: { 'service.name': 'svc2' } });
217
174
 
218
- processor.onEnd(span);
175
+ processor.onEnd(span1);
176
+ processor.onEnd(span2);
219
177
 
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');
178
+ expect(mockConnection.setProcessContext).toHaveBeenCalledTimes(1);
223
179
  });
180
+ });
224
181
 
225
- it('should handle relative URLs in http.target', () => {
182
+ describe('HTTP Route Processing', () => {
183
+ it('should process HTTP server spans with V2 observations (spanId, traceId)', () => {
226
184
  const span = createMockSpan({
227
185
  kind: SpanKind.SERVER,
228
186
  attributes: {
229
- 'http.route': '/api/test',
187
+ 'http.route': '/api/users/{id}',
230
188
  '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
- }
189
+ 'http.target': '/api/users/123',
190
+ 'http.status_code': 200
191
+ },
192
+ traceId: 'trace-abc',
193
+ spanId: 'span-123',
257
194
  });
258
195
 
259
196
  processor.onEnd(span);
260
197
 
261
- const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
198
+ const observationMessage = sentMessages.find(m => m.event === 'observations');
262
199
  const observation = observationMessage.observations[0] as HttpServerObservation;
263
200
  expect(observation).toMatchObject({
264
- httpVersion: '1.1',
265
- userAgent: 'Mozilla/5.0',
266
- requestBytes: 1024,
267
- responseBytes: 256
201
+ type: 'http-server',
202
+ path: '/api/users/123',
203
+ status: 200,
204
+ spanId: 'span-123',
205
+ traceId: 'trace-abc',
268
206
  });
269
207
  });
270
208
 
271
209
  it('should not duplicate route registrations', () => {
272
210
  const span1 = createMockSpan({
273
211
  kind: SpanKind.SERVER,
274
- attributes: {
275
- 'http.route': '/api/users',
276
- 'http.method': 'GET',
277
- 'http.status_code': 200
278
- }
212
+ attributes: { 'http.route': '/api/users', 'http.method': 'GET', 'http.status_code': 200 }
279
213
  });
280
214
  const span2 = createMockSpan({
281
215
  kind: SpanKind.SERVER,
282
- attributes: {
283
- 'http.route': '/api/users',
284
- 'http.method': 'GET',
285
- 'http.status_code': 200
286
- }
216
+ attributes: { 'http.route': '/api/users', 'http.method': 'GET', 'http.status_code': 200 }
287
217
  });
288
218
 
289
219
  processor.onEnd(span1);
@@ -295,112 +225,59 @@ describe('ComprehendDevSpanProcessor', () => {
295
225
  });
296
226
 
297
227
  describe('HTTP Client Processing', () => {
298
- it('should process HTTP client spans and create service entities and interactions', () => {
228
+ it('should process HTTP client spans with V2 observations', () => {
299
229
  const span = createMockSpan({
300
230
  kind: SpanKind.CLIENT,
301
231
  attributes: {
302
232
  'http.url': 'https://api.external.com/v1/data',
303
233
  'http.method': 'GET',
304
234
  'http.status_code': 200
305
- }
235
+ },
236
+ traceId: 'trace-xyz',
237
+ spanId: 'span-456',
306
238
  });
307
239
 
308
240
  processor.onEnd(span);
309
241
 
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;
242
+ const observationMessage = sentMessages.find(m => m.event === 'observations');
329
243
  const observation = observationMessage.observations[0] as HttpClientObservation;
330
244
  expect(observation).toMatchObject({
331
245
  type: 'http-client',
332
246
  path: '/v1/data',
333
247
  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
248
+ status: 200,
249
+ spanId: 'span-456',
250
+ traceId: 'trace-xyz',
373
251
  });
374
252
  });
253
+ });
375
254
 
376
- it('should not process client spans without http.method', () => {
255
+ describe('Database Processing', () => {
256
+ it('should send DatabaseQueryMessage for SQL queries (server-side analysis)', () => {
377
257
  const span = createMockSpan({
378
- kind: SpanKind.CLIENT,
379
258
  attributes: {
380
- 'http.url': 'https://api.example.com/test'
381
- // Missing http.method
259
+ 'db.system': 'postgresql',
260
+ 'db.statement': 'SELECT * FROM users WHERE id = $1',
261
+ 'db.name': 'myapp',
262
+ 'net.peer.name': 'localhost',
263
+ 'db.response.returned_rows': 1
382
264
  }
383
265
  });
384
266
 
385
267
  processor.onEnd(span);
386
268
 
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'
269
+ const dbQueryMsg = sentMessages.find(m => m.event === 'db-query') as DatabaseQueryMessage;
270
+ expect(dbQueryMsg).toMatchObject({
271
+ event: 'db-query',
272
+ query: 'SELECT * FROM users WHERE id = $1',
273
+ returnedRows: 1,
400
274
  });
275
+ expect(dbQueryMsg.from).toBeDefined();
276
+ expect(dbQueryMsg.to).toBeDefined();
277
+ expect(dbQueryMsg.seq).toBeDefined();
401
278
  });
402
279
 
403
- it('should process database spans and create database entities', () => {
280
+ it('should create database entity and connection interaction', () => {
404
281
  const span = createMockSpan({
405
282
  attributes: {
406
283
  'db.system': 'postgresql',
@@ -412,7 +289,7 @@ describe('ComprehendDevSpanProcessor', () => {
412
289
 
413
290
  processor.onEnd(span);
414
291
 
415
- const databaseMessage = sentMessages.find(m => m.type === 'database') as NewObservedDatabaseMessage;
292
+ const databaseMessage = sentMessages.find(m => m.type === 'database');
416
293
  expect(databaseMessage).toMatchObject({
417
294
  event: 'new-entity',
418
295
  type: 'database',
@@ -421,133 +298,185 @@ describe('ComprehendDevSpanProcessor', () => {
421
298
  host: 'db.example.com',
422
299
  port: 5432
423
300
  });
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
301
 
444
- const connectionMessage = sentMessages.find(m => m.type === 'db-connection') as NewObservedDatabaseConnectionMessage;
302
+ const connectionMessage = sentMessages.find(m => m.type === 'db-connection');
445
303
  expect(connectionMessage).toMatchObject({
446
304
  event: 'new-interaction',
447
305
  type: 'db-connection',
448
- user: 'user'
449
306
  });
450
307
  });
451
308
 
452
- it('should process SQL queries and create query interactions', () => {
309
+ it('should not send db-query for non-SQL database systems', () => {
453
310
  const span = createMockSpan({
454
311
  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
312
+ 'db.system': 'mongodb',
313
+ 'db.statement': 'db.users.find({})',
314
+ 'db.name': 'myapp'
460
315
  }
461
316
  });
462
317
 
463
318
  processor.onEnd(span);
464
319
 
465
- expect(mockedAnalyzeSQL).toHaveBeenCalledWith('SELECT * FROM users WHERE id = $1');
320
+ const dbQueryMessages = sentMessages.filter(m => m.event === 'db-query');
321
+ expect(dbQueryMessages).toHaveLength(0);
322
+ });
323
+ });
466
324
 
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']
325
+ describe('Trace Span Reporting', () => {
326
+ it('should send TraceSpansMessage for every span', () => {
327
+ const span = createMockSpan({
328
+ name: 'GET /api/users',
329
+ kind: SpanKind.SERVER,
330
+ attributes: {
331
+ 'http.route': '/api/users',
332
+ 'http.method': 'GET',
333
+ 'http.status_code': 200,
334
+ },
335
+ traceId: 'trace-001',
336
+ spanId: 'span-001',
337
+ parentSpanId: 'span-000',
473
338
  });
474
339
 
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
- });
340
+ processor.onEnd(span);
482
341
 
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'
342
+ const traceSpanMsg = sentMessages.find(m => m.event === 'tracespans') as TraceSpansMessage;
343
+ expect(traceSpanMsg).toBeDefined();
344
+ expect(traceSpanMsg.data).toHaveLength(1);
345
+ expect(traceSpanMsg.data[0]).toMatchObject({
346
+ trace: 'trace-001',
347
+ span: 'span-001',
348
+ parent: 'span-000',
349
+ name: 'GET /api/users',
492
350
  });
351
+ });
493
352
 
353
+ it('should use empty string for parent when no parentSpanId', () => {
494
354
  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
- }
355
+ traceId: 'trace-root',
356
+ spanId: 'span-root',
357
+ // no parentSpanId
500
358
  });
501
359
 
502
360
  processor.onEnd(span);
503
361
 
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
- });
362
+ const traceSpanMsg = sentMessages.find(m => m.event === 'tracespans') as TraceSpansMessage;
363
+ expect(traceSpanMsg.data[0].parent).toBe('');
510
364
  });
511
365
 
512
- it('should not process SQL for non-SQL database systems', () => {
366
+ it('should send trace span even for internal spans with no http/db attributes', () => {
513
367
  const span = createMockSpan({
514
- attributes: {
515
- 'db.system': 'mongodb',
516
- 'db.statement': 'db.users.find({_id: ObjectId("...")}))',
517
- 'db.name': 'myapp'
518
- }
368
+ name: 'internal-operation',
369
+ kind: SpanKind.INTERNAL,
519
370
  });
520
371
 
521
372
  processor.onEnd(span);
522
373
 
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);
374
+ const traceSpanMsg = sentMessages.find(m => m.event === 'tracespans');
375
+ expect(traceSpanMsg).toBeDefined();
528
376
  });
377
+ });
378
+
379
+ describe('Custom Span Observation Matching', () => {
380
+ it('should match spans by type rule', () => {
381
+ processor.updateCustomMetrics([{
382
+ type: 'span',
383
+ rule: { kind: 'type', value: 'server' },
384
+ subject: 'custom-server-obs',
385
+ }]);
529
386
 
530
- it('should handle alternative database connection string formats', () => {
531
387
  const span = createMockSpan({
388
+ kind: SpanKind.SERVER,
532
389
  attributes: {
533
- 'db.system': 'mssql',
534
- 'db.connection_string': 'Server=localhost;Database=TestDB;User Id=sa;Password=secret;'
535
- }
390
+ 'http.route': '/test',
391
+ 'http.method': 'GET',
392
+ 'http.status_code': 200,
393
+ },
536
394
  });
537
395
 
538
396
  processor.onEnd(span);
539
397
 
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
- });
398
+ const customObs = sentMessages
399
+ .filter(m => m.event === 'observations')
400
+ .flatMap((m: ObservationMessage) => m.observations)
401
+ .find((o: any) => o.type === 'custom') as CustomObservation;
546
402
 
547
- const connectionMessage = sentMessages.find(m => m.type === 'db-connection') as NewObservedDatabaseConnectionMessage;
548
- expect(connectionMessage).toMatchObject({
549
- user: 'sa'
403
+ expect(customObs).toBeDefined();
404
+ expect(customObs.subject).toBe('custom-server-obs');
405
+ expect(customObs.id).toBe('custom-server-obs');
406
+ expect(customObs.attributes).toBeDefined();
407
+ });
408
+
409
+ it('should match spans by attribute-present rule', () => {
410
+ processor.updateCustomMetrics([{
411
+ type: 'span',
412
+ rule: { kind: 'attribute-present', key: 'custom.attr' },
413
+ subject: 'has-custom-attr',
414
+ }]);
415
+
416
+ const spanWith = createMockSpan({ attributes: { 'custom.attr': 'value' } });
417
+ const spanWithout = createMockSpan({ attributes: {} });
418
+
419
+ processor.onEnd(spanWith);
420
+ processor.onEnd(spanWithout);
421
+
422
+ const customObs = sentMessages
423
+ .filter(m => m.event === 'observations')
424
+ .flatMap((m: ObservationMessage) => m.observations)
425
+ .filter((o: any) => o.type === 'custom');
426
+
427
+ expect(customObs).toHaveLength(1);
428
+ });
429
+
430
+ it('should match spans by compound all rule', () => {
431
+ processor.updateCustomMetrics([{
432
+ type: 'span',
433
+ rule: {
434
+ kind: 'all',
435
+ rules: [
436
+ { kind: 'type', value: 'client' },
437
+ { kind: 'attribute-equals', key: 'rpc.system', value: 'grpc' },
438
+ ]
439
+ },
440
+ subject: 'grpc-client',
441
+ }]);
442
+
443
+ const matchingSpan = createMockSpan({
444
+ kind: SpanKind.CLIENT,
445
+ attributes: { 'rpc.system': 'grpc', 'http.url': 'http://x' },
550
446
  });
447
+ const nonMatchingSpan = createMockSpan({
448
+ kind: SpanKind.CLIENT,
449
+ attributes: { 'rpc.system': 'thrift' },
450
+ });
451
+
452
+ processor.onEnd(matchingSpan);
453
+ processor.onEnd(nonMatchingSpan);
454
+
455
+ const customObs = sentMessages
456
+ .filter(m => m.event === 'observations')
457
+ .flatMap((m: ObservationMessage) => m.observations)
458
+ .filter((o: any) => o.type === 'custom');
459
+
460
+ expect(customObs).toHaveLength(1);
461
+ expect(customObs[0].subject).toBe('grpc-client');
462
+ });
463
+
464
+ it('should not create custom observations when no specs match', () => {
465
+ processor.updateCustomMetrics([{
466
+ type: 'span',
467
+ rule: { kind: 'type', value: 'internal' },
468
+ subject: 'internal-only',
469
+ }]);
470
+
471
+ const span = createMockSpan({ kind: SpanKind.SERVER });
472
+ processor.onEnd(span);
473
+
474
+ const customObs = sentMessages
475
+ .filter(m => m.event === 'observations')
476
+ .flatMap((m: ObservationMessage) => m.observations)
477
+ .filter((o: any) => o.type === 'custom');
478
+
479
+ expect(customObs).toHaveLength(0);
551
480
  });
552
481
  });
553
482
 
@@ -573,126 +502,41 @@ describe('ComprehendDevSpanProcessor', () => {
573
502
 
574
503
  processor.onEnd(span);
575
504
 
576
- const observationMessage = sentMessages.find(m => m.event === 'observations') as ObservationMessage;
577
- const observation = observationMessage.observations[0] as HttpServerObservation;
505
+ const observationMessage = sentMessages.find(m => m.event === 'observations');
506
+ const observation = observationMessage.observations[0];
578
507
  expect(observation).toMatchObject({
579
508
  errorMessage: 'Database connection failed',
580
509
  errorType: 'ConnectionError',
581
510
  stack: 'Error at line 42\n at method()'
582
511
  });
583
512
  });
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
513
  });
626
514
 
627
- describe('Sequence Numbers and Batching', () => {
628
- it('should increment sequence numbers for observation messages', () => {
515
+ describe('Sequence Numbers', () => {
516
+ it('should use connection.nextSeq() for observation messages', () => {
629
517
  const span1 = createMockSpan({
630
518
  kind: SpanKind.SERVER,
631
- attributes: {
632
- 'http.route': '/api/test1',
633
- 'http.method': 'GET',
634
- 'http.status_code': 200
635
- }
519
+ attributes: { 'http.route': '/api/test1', 'http.method': 'GET', 'http.status_code': 200 }
636
520
  });
637
521
  const span2 = createMockSpan({
638
522
  kind: SpanKind.SERVER,
639
- attributes: {
640
- 'http.route': '/api/test2',
641
- 'http.method': 'GET',
642
- 'http.status_code': 200
643
- }
523
+ attributes: { 'http.route': '/api/test2', 'http.method': 'GET', 'http.status_code': 200 }
644
524
  });
645
525
 
646
526
  processor.onEnd(span1);
647
527
  processor.onEnd(span2);
648
528
 
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);
529
+ // nextSeq is called for observations AND tracespans
530
+ expect(mockConnection.nextSeq).toHaveBeenCalled();
531
+
532
+ const observationMessages = sentMessages.filter(m => m.event === 'observations');
533
+ // Each should have a unique seq
534
+ const seqs = observationMessages.map((m: any) => m.seq);
535
+ expect(new Set(seqs).size).toBe(seqs.length);
653
536
  });
654
537
  });
655
538
 
656
539
  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
540
  it('should handle forceFlush without error', async () => {
697
541
  await expect(processor.forceFlush()).resolves.toBeUndefined();
698
542
  });
@@ -701,122 +545,82 @@ describe('ComprehendDevSpanProcessor', () => {
701
545
  expect(() => processor.onStart({} as any, {} as any)).not.toThrow();
702
546
  });
703
547
  });
548
+ });
704
549
 
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
- });
550
+ describe('matchSpanRule', () => {
551
+ function createSpan(kind: SpanKind, attributes: Record<string, any> = {}): ReadableSpan {
552
+ return {
553
+ kind,
554
+ attributes,
555
+ name: 'test',
556
+ spanContext: () => ({ traceId: '', spanId: '', traceFlags: 0 }),
557
+ } as any;
558
+ }
767
559
 
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
- });
560
+ it('should match type rule', () => {
561
+ expect(matchSpanRule(createSpan(SpanKind.SERVER), { kind: 'type', value: 'server' })).toBe(true);
562
+ expect(matchSpanRule(createSpan(SpanKind.CLIENT), { kind: 'type', value: 'server' })).toBe(false);
563
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL), { kind: 'type', value: 'internal' })).toBe(true);
564
+ });
774
565
 
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
- });
566
+ it('should match attribute-present rule', () => {
567
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'foo': 'bar' }), { kind: 'attribute-present', key: 'foo' })).toBe(true);
568
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, {}), { kind: 'attribute-present', key: 'foo' })).toBe(false);
569
+ });
785
570
 
786
- processor.onEnd(span);
571
+ it('should match attribute-absent rule', () => {
572
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, {}), { kind: 'attribute-absent', key: 'foo' })).toBe(true);
573
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'foo': 'bar' }), { kind: 'attribute-absent', key: 'foo' })).toBe(false);
574
+ });
787
575
 
788
- // Verify all expected messages: service, database, connection, query, observation
789
- expect(sentMessages).toHaveLength(5);
576
+ it('should match attribute-equals rule', () => {
577
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'k': 'v' }), { kind: 'attribute-equals', key: 'k', value: 'v' })).toBe(true);
578
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'k': 'other' }), { kind: 'attribute-equals', key: 'k', value: 'v' })).toBe(false);
579
+ });
790
580
 
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
- });
581
+ it('should match attribute-not-equals rule', () => {
582
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'k': 'other' }), { kind: 'attribute-not-equals', key: 'k', value: 'v' })).toBe(true);
583
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'k': 'v' }), { kind: 'attribute-not-equals', key: 'k', value: 'v' })).toBe(false);
584
+ });
798
585
 
799
- const connectionMessage = sentMessages.find(m => m.type === 'db-connection') as NewObservedDatabaseConnectionMessage;
800
- expect(connectionMessage).toMatchObject({
801
- user: 'app_user'
802
- });
586
+ it('should match all rule', () => {
587
+ const rule: SpanMatcherRule = {
588
+ kind: 'all',
589
+ rules: [
590
+ { kind: 'type', value: 'server' },
591
+ { kind: 'attribute-present', key: 'http.route' },
592
+ ]
593
+ };
594
+ expect(matchSpanRule(createSpan(SpanKind.SERVER, { 'http.route': '/api' }), rule)).toBe(true);
595
+ expect(matchSpanRule(createSpan(SpanKind.SERVER, {}), rule)).toBe(false);
596
+ expect(matchSpanRule(createSpan(SpanKind.CLIENT, { 'http.route': '/api' }), rule)).toBe(false);
597
+ });
803
598
 
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
- });
599
+ it('should match any rule', () => {
600
+ const rule: SpanMatcherRule = {
601
+ kind: 'any',
602
+ rules: [
603
+ { kind: 'type', value: 'server' },
604
+ { kind: 'type', value: 'client' },
605
+ ]
606
+ };
607
+ expect(matchSpanRule(createSpan(SpanKind.SERVER), rule)).toBe(true);
608
+ expect(matchSpanRule(createSpan(SpanKind.CLIENT), rule)).toBe(true);
609
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL), rule)).toBe(false);
610
+ });
810
611
 
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
- });
612
+ it('should handle nested rules', () => {
613
+ const rule: SpanMatcherRule = {
614
+ kind: 'all',
615
+ rules: [
616
+ { kind: 'any', rules: [
617
+ { kind: 'type', value: 'server' },
618
+ { kind: 'type', value: 'client' },
619
+ ]},
620
+ { kind: 'attribute-equals', key: 'rpc.system', value: 'grpc' },
621
+ ]
622
+ };
623
+ expect(matchSpanRule(createSpan(SpanKind.SERVER, { 'rpc.system': 'grpc' }), rule)).toBe(true);
624
+ expect(matchSpanRule(createSpan(SpanKind.INTERNAL, { 'rpc.system': 'grpc' }), rule)).toBe(false);
821
625
  });
822
- });
626
+ });