@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.
- package/.claude/settings.local.json +2 -2
- package/.idea/telemetry-node.iml +1 -0
- package/DEVELOPMENT.md +69 -0
- package/README.md +127 -0
- package/dist/ComprehendDevSpanProcessor.js +1 -0
- package/dist/ComprehendDevSpanProcessor.test.d.ts +1 -0
- package/dist/ComprehendDevSpanProcessor.test.js +674 -0
- package/dist/WebSocketConnection.test.d.ts +1 -0
- package/dist/WebSocketConnection.test.js +406 -0
- package/dist/sql-analyzer.js +11 -2
- package/dist/sql-analyzer.test.js +12 -0
- package/jest.config.js +1 -0
- package/package.json +2 -2
- package/src/ComprehendDevSpanProcessor.test.ts +822 -0
- package/src/ComprehendDevSpanProcessor.ts +1 -0
- package/src/WebSocketConnection.test.ts +535 -0
- package/src/sql-analyzer.test.ts +16 -0
- package/src/sql-analyzer.ts +11 -2
|
@@ -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
|
+
});
|