@comprehend/telemetry-node 0.1.4 → 0.2.1

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