@comprehend/telemetry-node 0.1.3 → 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.
- package/.claude/settings.local.json +2 -1
- package/.idea/telemetry-node.iml +0 -1
- package/DEVELOPMENT.md +69 -0
- package/README.md +173 -0
- package/dist/ComprehendDevSpanProcessor.d.ts +9 -6
- package/dist/ComprehendDevSpanProcessor.js +146 -87
- package/dist/ComprehendDevSpanProcessor.test.d.ts +1 -0
- package/dist/ComprehendDevSpanProcessor.test.js +495 -0
- package/dist/ComprehendMetricsExporter.d.ts +18 -0
- package/dist/ComprehendMetricsExporter.js +178 -0
- package/dist/ComprehendMetricsExporter.test.d.ts +1 -0
- package/dist/ComprehendMetricsExporter.test.js +266 -0
- package/dist/ComprehendSDK.d.ts +18 -0
- package/dist/ComprehendSDK.js +56 -0
- package/dist/ComprehendSDK.test.d.ts +1 -0
- package/dist/ComprehendSDK.test.js +126 -0
- package/dist/WebSocketConnection.d.ts +23 -3
- package/dist/WebSocketConnection.js +106 -12
- package/dist/WebSocketConnection.test.d.ts +1 -0
- package/dist/WebSocketConnection.test.js +473 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/sql-analyzer.js +2 -11
- package/dist/sql-analyzer.test.js +0 -12
- package/dist/util.d.ts +2 -0
- package/dist/util.js +7 -0
- package/dist/wire-protocol.d.ts +168 -28
- package/jest.config.js +1 -0
- package/package.json +4 -2
- package/src/ComprehendDevSpanProcessor.test.ts +626 -0
- package/src/ComprehendDevSpanProcessor.ts +170 -105
- package/src/ComprehendMetricsExporter.test.ts +334 -0
- package/src/ComprehendMetricsExporter.ts +225 -0
- package/src/ComprehendSDK.test.ts +160 -0
- package/src/ComprehendSDK.ts +63 -0
- package/src/WebSocketConnection.test.ts +616 -0
- package/src/WebSocketConnection.ts +135 -13
- package/src/index.ts +3 -2
- package/src/util.ts +6 -0
- package/src/wire-protocol.ts +204 -29
- package/src/sql-analyzer.test.ts +0 -599
- package/src/sql-analyzer.ts +0 -439
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
|
2
|
+
import { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
3
|
+
import { ComprehendDevSpanProcessor, matchSpanRule } from './ComprehendDevSpanProcessor';
|
|
4
|
+
import { WebSocketConnection } from './WebSocketConnection';
|
|
5
|
+
import {
|
|
6
|
+
NewObservedServiceMessage,
|
|
7
|
+
NewObservedHttpRouteMessage,
|
|
8
|
+
NewObservedDatabaseMessage,
|
|
9
|
+
NewObservedHttpServiceMessage,
|
|
10
|
+
NewObservedHttpRequestMessage,
|
|
11
|
+
NewObservedDatabaseConnectionMessage,
|
|
12
|
+
ObservationMessage,
|
|
13
|
+
HttpServerObservation,
|
|
14
|
+
HttpClientObservation,
|
|
15
|
+
DatabaseQueryMessage,
|
|
16
|
+
TraceSpansMessage,
|
|
17
|
+
CustomObservation,
|
|
18
|
+
ObservationInputMessage,
|
|
19
|
+
SpanMatcherRule,
|
|
20
|
+
} from './wire-protocol';
|
|
21
|
+
|
|
22
|
+
// Mock the WebSocketConnection
|
|
23
|
+
jest.mock('./WebSocketConnection');
|
|
24
|
+
|
|
25
|
+
const MockedWebSocketConnection = WebSocketConnection as jest.MockedClass<typeof WebSocketConnection>;
|
|
26
|
+
|
|
27
|
+
describe('ComprehendDevSpanProcessor', () => {
|
|
28
|
+
let processor: ComprehendDevSpanProcessor;
|
|
29
|
+
let mockConnection: jest.Mocked<WebSocketConnection>;
|
|
30
|
+
let sentMessages: any[];
|
|
31
|
+
let seqCounter: number;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
MockedWebSocketConnection.mockClear();
|
|
35
|
+
sentMessages = [];
|
|
36
|
+
seqCounter = 1;
|
|
37
|
+
|
|
38
|
+
mockConnection = {
|
|
39
|
+
sendMessage: jest.fn((message: any) => {
|
|
40
|
+
sentMessages.push(message);
|
|
41
|
+
}),
|
|
42
|
+
setProcessContext: jest.fn(),
|
|
43
|
+
nextSeq: jest.fn(() => seqCounter++),
|
|
44
|
+
close: jest.fn()
|
|
45
|
+
} as any;
|
|
46
|
+
|
|
47
|
+
processor = new ComprehendDevSpanProcessor(mockConnection);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
processor.shutdown();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function createMockSpan(options: {
|
|
55
|
+
name?: string;
|
|
56
|
+
kind?: SpanKind;
|
|
57
|
+
attributes?: Record<string, any>;
|
|
58
|
+
resourceAttributes?: Record<string, any>;
|
|
59
|
+
status?: { code: SpanStatusCode; message?: string };
|
|
60
|
+
events?: Array<{ name: string; attributes?: Record<string, any> }>;
|
|
61
|
+
startTime?: [number, number];
|
|
62
|
+
duration?: [number, number];
|
|
63
|
+
traceId?: string;
|
|
64
|
+
spanId?: string;
|
|
65
|
+
parentSpanId?: string;
|
|
66
|
+
} = {}): ReadableSpan {
|
|
67
|
+
const {
|
|
68
|
+
name = 'test-span',
|
|
69
|
+
kind = SpanKind.INTERNAL,
|
|
70
|
+
attributes = {},
|
|
71
|
+
resourceAttributes = { 'service.name': 'test-service' },
|
|
72
|
+
status = { code: SpanStatusCode.OK },
|
|
73
|
+
events = [],
|
|
74
|
+
startTime = [1700000000, 123456789],
|
|
75
|
+
duration = [0, 100000000],
|
|
76
|
+
traceId = 'abc123trace',
|
|
77
|
+
spanId = 'def456span',
|
|
78
|
+
parentSpanId,
|
|
79
|
+
} = options;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
kind,
|
|
84
|
+
attributes,
|
|
85
|
+
resource: {
|
|
86
|
+
attributes: resourceAttributes
|
|
87
|
+
} as any,
|
|
88
|
+
status,
|
|
89
|
+
events: events.map(e => ({
|
|
90
|
+
name: e.name,
|
|
91
|
+
attributes: e.attributes || {},
|
|
92
|
+
time: startTime
|
|
93
|
+
})),
|
|
94
|
+
startTime,
|
|
95
|
+
duration,
|
|
96
|
+
spanContext: () => ({ traceId, spanId, traceFlags: 1 }),
|
|
97
|
+
parentSpanContext: parentSpanId ? { spanId: parentSpanId, traceId: '', traceFlags: 0 } : undefined,
|
|
98
|
+
} as any;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('Service Discovery', () => {
|
|
102
|
+
it('should register a new service and send NewObservedServiceMessage', () => {
|
|
103
|
+
const span = createMockSpan({
|
|
104
|
+
resourceAttributes: {
|
|
105
|
+
'service.name': 'my-api',
|
|
106
|
+
'service.namespace': 'production',
|
|
107
|
+
'deployment.environment': 'prod'
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
processor.onEnd(span);
|
|
112
|
+
|
|
113
|
+
const serviceMsg = sentMessages.find(m => m.event === 'new-entity' && m.type === 'service');
|
|
114
|
+
expect(serviceMsg).toMatchObject({
|
|
115
|
+
event: 'new-entity',
|
|
116
|
+
type: 'service',
|
|
117
|
+
name: 'my-api',
|
|
118
|
+
namespace: 'production',
|
|
119
|
+
environment: 'prod'
|
|
120
|
+
});
|
|
121
|
+
expect(serviceMsg.hash).toBeDefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should not duplicate service registrations', () => {
|
|
125
|
+
const span1 = createMockSpan({ resourceAttributes: { 'service.name': 'my-api' } });
|
|
126
|
+
const span2 = createMockSpan({ resourceAttributes: { 'service.name': 'my-api' } });
|
|
127
|
+
|
|
128
|
+
processor.onEnd(span1);
|
|
129
|
+
processor.onEnd(span2);
|
|
130
|
+
|
|
131
|
+
const serviceMessages = sentMessages.filter(m => m.event === 'new-entity' && m.type === 'service');
|
|
132
|
+
expect(serviceMessages).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle service without namespace and environment', () => {
|
|
136
|
+
const span = createMockSpan({ resourceAttributes: { 'service.name': 'simple-service' } });
|
|
137
|
+
|
|
138
|
+
processor.onEnd(span);
|
|
139
|
+
|
|
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');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should ignore spans without service.name', () => {
|
|
147
|
+
const span = createMockSpan({ resourceAttributes: {} });
|
|
148
|
+
|
|
149
|
+
processor.onEnd(span);
|
|
150
|
+
|
|
151
|
+
expect(sentMessages).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should set process context on first service discovery', () => {
|
|
155
|
+
const span = createMockSpan({
|
|
156
|
+
resourceAttributes: {
|
|
157
|
+
'service.name': 'my-api',
|
|
158
|
+
'deployment.environment': 'prod',
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
processor.onEnd(span);
|
|
163
|
+
|
|
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');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should only set process context once', () => {
|
|
172
|
+
const span1 = createMockSpan({ resourceAttributes: { 'service.name': 'svc1' } });
|
|
173
|
+
const span2 = createMockSpan({ resourceAttributes: { 'service.name': 'svc2' } });
|
|
174
|
+
|
|
175
|
+
processor.onEnd(span1);
|
|
176
|
+
processor.onEnd(span2);
|
|
177
|
+
|
|
178
|
+
expect(mockConnection.setProcessContext).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('HTTP Route Processing', () => {
|
|
183
|
+
it('should process HTTP server spans with V2 observations (spanId, traceId)', () => {
|
|
184
|
+
const span = createMockSpan({
|
|
185
|
+
kind: SpanKind.SERVER,
|
|
186
|
+
attributes: {
|
|
187
|
+
'http.route': '/api/users/{id}',
|
|
188
|
+
'http.method': 'GET',
|
|
189
|
+
'http.target': '/api/users/123',
|
|
190
|
+
'http.status_code': 200
|
|
191
|
+
},
|
|
192
|
+
traceId: 'trace-abc',
|
|
193
|
+
spanId: 'span-123',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
processor.onEnd(span);
|
|
197
|
+
|
|
198
|
+
const observationMessage = sentMessages.find(m => m.event === 'observations');
|
|
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
|
+
spanId: 'span-123',
|
|
205
|
+
traceId: 'trace-abc',
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should not duplicate route registrations', () => {
|
|
210
|
+
const span1 = createMockSpan({
|
|
211
|
+
kind: SpanKind.SERVER,
|
|
212
|
+
attributes: { 'http.route': '/api/users', 'http.method': 'GET', 'http.status_code': 200 }
|
|
213
|
+
});
|
|
214
|
+
const span2 = createMockSpan({
|
|
215
|
+
kind: SpanKind.SERVER,
|
|
216
|
+
attributes: { 'http.route': '/api/users', 'http.method': 'GET', 'http.status_code': 200 }
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
processor.onEnd(span1);
|
|
220
|
+
processor.onEnd(span2);
|
|
221
|
+
|
|
222
|
+
const routeMessages = sentMessages.filter(m => m.type === 'http-route');
|
|
223
|
+
expect(routeMessages).toHaveLength(1);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('HTTP Client Processing', () => {
|
|
228
|
+
it('should process HTTP client spans with V2 observations', () => {
|
|
229
|
+
const span = createMockSpan({
|
|
230
|
+
kind: SpanKind.CLIENT,
|
|
231
|
+
attributes: {
|
|
232
|
+
'http.url': 'https://api.external.com/v1/data',
|
|
233
|
+
'http.method': 'GET',
|
|
234
|
+
'http.status_code': 200
|
|
235
|
+
},
|
|
236
|
+
traceId: 'trace-xyz',
|
|
237
|
+
spanId: 'span-456',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
processor.onEnd(span);
|
|
241
|
+
|
|
242
|
+
const observationMessage = sentMessages.find(m => m.event === 'observations');
|
|
243
|
+
const observation = observationMessage.observations[0] as HttpClientObservation;
|
|
244
|
+
expect(observation).toMatchObject({
|
|
245
|
+
type: 'http-client',
|
|
246
|
+
path: '/v1/data',
|
|
247
|
+
method: 'GET',
|
|
248
|
+
status: 200,
|
|
249
|
+
spanId: 'span-456',
|
|
250
|
+
traceId: 'trace-xyz',
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('Database Processing', () => {
|
|
256
|
+
it('should send DatabaseQueryMessage for SQL queries (server-side analysis)', () => {
|
|
257
|
+
const span = createMockSpan({
|
|
258
|
+
attributes: {
|
|
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
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
processor.onEnd(span);
|
|
268
|
+
|
|
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,
|
|
274
|
+
});
|
|
275
|
+
expect(dbQueryMsg.from).toBeDefined();
|
|
276
|
+
expect(dbQueryMsg.to).toBeDefined();
|
|
277
|
+
expect(dbQueryMsg.seq).toBeDefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should create database entity and connection interaction', () => {
|
|
281
|
+
const span = createMockSpan({
|
|
282
|
+
attributes: {
|
|
283
|
+
'db.system': 'postgresql',
|
|
284
|
+
'db.name': 'myapp',
|
|
285
|
+
'net.peer.name': 'db.example.com',
|
|
286
|
+
'net.peer.port': 5432
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
processor.onEnd(span);
|
|
291
|
+
|
|
292
|
+
const databaseMessage = sentMessages.find(m => m.type === 'database');
|
|
293
|
+
expect(databaseMessage).toMatchObject({
|
|
294
|
+
event: 'new-entity',
|
|
295
|
+
type: 'database',
|
|
296
|
+
system: 'postgresql',
|
|
297
|
+
name: 'myapp',
|
|
298
|
+
host: 'db.example.com',
|
|
299
|
+
port: 5432
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const connectionMessage = sentMessages.find(m => m.type === 'db-connection');
|
|
303
|
+
expect(connectionMessage).toMatchObject({
|
|
304
|
+
event: 'new-interaction',
|
|
305
|
+
type: 'db-connection',
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should not send db-query for non-SQL database systems', () => {
|
|
310
|
+
const span = createMockSpan({
|
|
311
|
+
attributes: {
|
|
312
|
+
'db.system': 'mongodb',
|
|
313
|
+
'db.statement': 'db.users.find({})',
|
|
314
|
+
'db.name': 'myapp'
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
processor.onEnd(span);
|
|
319
|
+
|
|
320
|
+
const dbQueryMessages = sentMessages.filter(m => m.event === 'db-query');
|
|
321
|
+
expect(dbQueryMessages).toHaveLength(0);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
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',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
processor.onEnd(span);
|
|
341
|
+
|
|
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',
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should use empty string for parent when no parentSpanId', () => {
|
|
354
|
+
const span = createMockSpan({
|
|
355
|
+
traceId: 'trace-root',
|
|
356
|
+
spanId: 'span-root',
|
|
357
|
+
// no parentSpanId
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
processor.onEnd(span);
|
|
361
|
+
|
|
362
|
+
const traceSpanMsg = sentMessages.find(m => m.event === 'tracespans') as TraceSpansMessage;
|
|
363
|
+
expect(traceSpanMsg.data[0].parent).toBe('');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should send trace span even for internal spans with no http/db attributes', () => {
|
|
367
|
+
const span = createMockSpan({
|
|
368
|
+
name: 'internal-operation',
|
|
369
|
+
kind: SpanKind.INTERNAL,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
processor.onEnd(span);
|
|
373
|
+
|
|
374
|
+
const traceSpanMsg = sentMessages.find(m => m.event === 'tracespans');
|
|
375
|
+
expect(traceSpanMsg).toBeDefined();
|
|
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
|
+
}]);
|
|
386
|
+
|
|
387
|
+
const span = createMockSpan({
|
|
388
|
+
kind: SpanKind.SERVER,
|
|
389
|
+
attributes: {
|
|
390
|
+
'http.route': '/test',
|
|
391
|
+
'http.method': 'GET',
|
|
392
|
+
'http.status_code': 200,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
processor.onEnd(span);
|
|
397
|
+
|
|
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;
|
|
402
|
+
|
|
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' },
|
|
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);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('Error Handling', () => {
|
|
484
|
+
it('should extract error information from exception events', () => {
|
|
485
|
+
const span = createMockSpan({
|
|
486
|
+
kind: SpanKind.SERVER,
|
|
487
|
+
attributes: {
|
|
488
|
+
'http.route': '/api/error',
|
|
489
|
+
'http.method': 'GET',
|
|
490
|
+
'http.status_code': 500
|
|
491
|
+
},
|
|
492
|
+
events: [{
|
|
493
|
+
name: 'exception',
|
|
494
|
+
attributes: {
|
|
495
|
+
'exception.message': 'Database connection failed',
|
|
496
|
+
'exception.type': 'ConnectionError',
|
|
497
|
+
'exception.stacktrace': 'Error at line 42\n at method()'
|
|
498
|
+
}
|
|
499
|
+
}],
|
|
500
|
+
status: { code: SpanStatusCode.ERROR, message: 'Internal server error' }
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
processor.onEnd(span);
|
|
504
|
+
|
|
505
|
+
const observationMessage = sentMessages.find(m => m.event === 'observations');
|
|
506
|
+
const observation = observationMessage.observations[0];
|
|
507
|
+
expect(observation).toMatchObject({
|
|
508
|
+
errorMessage: 'Database connection failed',
|
|
509
|
+
errorType: 'ConnectionError',
|
|
510
|
+
stack: 'Error at line 42\n at method()'
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe('Sequence Numbers', () => {
|
|
516
|
+
it('should use connection.nextSeq() for observation messages', () => {
|
|
517
|
+
const span1 = createMockSpan({
|
|
518
|
+
kind: SpanKind.SERVER,
|
|
519
|
+
attributes: { 'http.route': '/api/test1', 'http.method': 'GET', 'http.status_code': 200 }
|
|
520
|
+
});
|
|
521
|
+
const span2 = createMockSpan({
|
|
522
|
+
kind: SpanKind.SERVER,
|
|
523
|
+
attributes: { 'http.route': '/api/test2', 'http.method': 'GET', 'http.status_code': 200 }
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
processor.onEnd(span1);
|
|
527
|
+
processor.onEnd(span2);
|
|
528
|
+
|
|
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);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
describe('Processor Lifecycle', () => {
|
|
540
|
+
it('should handle forceFlush without error', async () => {
|
|
541
|
+
await expect(processor.forceFlush()).resolves.toBeUndefined();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('onStart should be a no-op', () => {
|
|
545
|
+
expect(() => processor.onStart({} as any, {} as any)).not.toThrow();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
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
|
+
}
|
|
559
|
+
|
|
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
|
+
});
|
|
565
|
+
|
|
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
|
+
});
|
|
570
|
+
|
|
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
|
+
});
|
|
575
|
+
|
|
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
|
+
});
|
|
580
|
+
|
|
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
|
+
});
|
|
585
|
+
|
|
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
|
+
});
|
|
598
|
+
|
|
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
|
+
});
|
|
611
|
+
|
|
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);
|
|
625
|
+
});
|
|
626
|
+
});
|