@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,616 @@
|
|
|
1
|
+
import { WebSocketConnection } from './WebSocketConnection';
|
|
2
|
+
import {
|
|
3
|
+
InitMessage,
|
|
4
|
+
NewObservedServiceMessage,
|
|
5
|
+
NewObservedHttpRouteMessage,
|
|
6
|
+
ObservationMessage,
|
|
7
|
+
HttpServerObservation,
|
|
8
|
+
ObservationOutputMessage,
|
|
9
|
+
TimeSeriesMetricsMessage,
|
|
10
|
+
CumulativeMetricsMessage,
|
|
11
|
+
TraceSpansMessage,
|
|
12
|
+
DatabaseQueryMessage,
|
|
13
|
+
InitAck,
|
|
14
|
+
CustomMetricSpecification,
|
|
15
|
+
} from './wire-protocol';
|
|
16
|
+
|
|
17
|
+
// Mock the ws WebSocket library
|
|
18
|
+
jest.mock('ws');
|
|
19
|
+
|
|
20
|
+
// Mock crypto.randomUUID
|
|
21
|
+
jest.mock('crypto', () => ({
|
|
22
|
+
randomUUID: jest.fn().mockReturnValue('test-uuid-1234'),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import WebSocket from 'ws';
|
|
26
|
+
|
|
27
|
+
const MockedWebSocket = WebSocket as jest.MockedClass<typeof WebSocket>;
|
|
28
|
+
|
|
29
|
+
// Mock WebSocket instance
|
|
30
|
+
interface MockWebSocketInstance {
|
|
31
|
+
on: jest.Mock;
|
|
32
|
+
send: jest.Mock;
|
|
33
|
+
close: jest.Mock;
|
|
34
|
+
readyState: number;
|
|
35
|
+
OPEN: number;
|
|
36
|
+
CLOSED: number;
|
|
37
|
+
_triggerOpen: () => void;
|
|
38
|
+
_triggerMessage: (data: string) => void;
|
|
39
|
+
_triggerClose: (code: number, reason: string) => void;
|
|
40
|
+
_triggerError: (error: Error) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function authAck(customMetrics: CustomMetricSpecification[] = []): string {
|
|
44
|
+
return JSON.stringify({ type: 'ack-authorized', customMetrics } as InitAck);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('WebSocketConnection', () => {
|
|
48
|
+
let mockSocket: MockWebSocketInstance;
|
|
49
|
+
let connection: WebSocketConnection;
|
|
50
|
+
let logMessages: string[];
|
|
51
|
+
let mockLogger: jest.Mock;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
jest.clearAllMocks();
|
|
55
|
+
jest.clearAllTimers();
|
|
56
|
+
jest.useFakeTimers();
|
|
57
|
+
|
|
58
|
+
logMessages = [];
|
|
59
|
+
mockLogger = jest.fn((message: string) => {
|
|
60
|
+
logMessages.push(message);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
mockSocket = {
|
|
64
|
+
on: jest.fn(),
|
|
65
|
+
send: jest.fn(),
|
|
66
|
+
close: jest.fn(),
|
|
67
|
+
readyState: WebSocket.CLOSED,
|
|
68
|
+
OPEN: WebSocket.OPEN,
|
|
69
|
+
CLOSED: WebSocket.CLOSED,
|
|
70
|
+
_triggerOpen: function() {},
|
|
71
|
+
_triggerMessage: function(data: string) {},
|
|
72
|
+
_triggerClose: function(code: number, reason: string) {},
|
|
73
|
+
_triggerError: function(error: Error) {}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const eventHandlers: Record<string, Function> = {};
|
|
77
|
+
mockSocket.on.mockImplementation((event: string, handler: Function) => {
|
|
78
|
+
eventHandlers[event] = handler;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
mockSocket._triggerOpen = () => {
|
|
82
|
+
mockSocket.readyState = WebSocket.OPEN;
|
|
83
|
+
eventHandlers['open']?.();
|
|
84
|
+
};
|
|
85
|
+
mockSocket._triggerMessage = (data: string) => {
|
|
86
|
+
const buffer = Buffer.from(data);
|
|
87
|
+
eventHandlers['message']?.(buffer);
|
|
88
|
+
};
|
|
89
|
+
mockSocket._triggerClose = (code: number, reason: string) => {
|
|
90
|
+
mockSocket.readyState = WebSocket.CLOSED;
|
|
91
|
+
eventHandlers['close']?.(code, Buffer.from(reason));
|
|
92
|
+
};
|
|
93
|
+
mockSocket._triggerError = (error: Error) => {
|
|
94
|
+
eventHandlers['error']?.(error);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
MockedWebSocket.mockImplementation(() => mockSocket as any);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
jest.useRealTimers();
|
|
102
|
+
if (connection) {
|
|
103
|
+
connection.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Connection Establishment', () => {
|
|
108
|
+
it('should create WebSocket connection with correct URL', () => {
|
|
109
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
110
|
+
|
|
111
|
+
expect(MockedWebSocket).toHaveBeenCalledWith('wss://ingestion.comprehend.dev/test-org/observations');
|
|
112
|
+
expect(mockSocket.on).toHaveBeenCalledWith('open', expect.any(Function));
|
|
113
|
+
expect(mockSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
|
|
114
|
+
expect(mockSocket.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
115
|
+
expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should send V2 init message on connection open', () => {
|
|
119
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
120
|
+
|
|
121
|
+
mockSocket._triggerOpen();
|
|
122
|
+
|
|
123
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify({
|
|
124
|
+
event: 'init',
|
|
125
|
+
protocolVersion: 2,
|
|
126
|
+
token: 'test-token'
|
|
127
|
+
}));
|
|
128
|
+
expect(logMessages).toContain('WebSocket connected. Sending init/auth message.');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should work without logger', () => {
|
|
132
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token' });
|
|
133
|
+
|
|
134
|
+
mockSocket._triggerOpen();
|
|
135
|
+
|
|
136
|
+
expect(MockedWebSocket).toHaveBeenCalled();
|
|
137
|
+
expect(mockSocket.send).toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Authorization Flow', () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
144
|
+
mockSocket._triggerOpen();
|
|
145
|
+
jest.clearAllMocks();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle authorization acknowledgment with customMetrics', () => {
|
|
149
|
+
mockSocket._triggerMessage(authAck([{ type: 'cumulative', id: 'test', attributes: [], subject: 'sub' }]));
|
|
150
|
+
|
|
151
|
+
expect(logMessages).toContain('Authorization acknowledged by server.');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should call onAuthorized callback with InitAck', () => {
|
|
155
|
+
const onAuthorized = jest.fn();
|
|
156
|
+
connection = new WebSocketConnection({
|
|
157
|
+
organization: 'test-org', token: 'test-token', logger: mockLogger, onAuthorized
|
|
158
|
+
});
|
|
159
|
+
mockSocket._triggerOpen();
|
|
160
|
+
|
|
161
|
+
const customMetrics: CustomMetricSpecification[] = [{ type: 'cumulative', id: 'test', attributes: [], subject: 'sub' }];
|
|
162
|
+
mockSocket._triggerMessage(authAck(customMetrics));
|
|
163
|
+
|
|
164
|
+
expect(onAuthorized).toHaveBeenCalledWith({ type: 'ack-authorized', customMetrics });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should replay queued messages after authorization', () => {
|
|
168
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
169
|
+
event: 'new-entity',
|
|
170
|
+
type: 'service',
|
|
171
|
+
hash: 'test-hash-1',
|
|
172
|
+
name: 'test-service'
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const observationMessage: ObservationMessage = {
|
|
176
|
+
event: 'observations',
|
|
177
|
+
seq: 1,
|
|
178
|
+
observations: [{
|
|
179
|
+
type: 'http-server',
|
|
180
|
+
subject: 'test-subject',
|
|
181
|
+
spanId: 'span1',
|
|
182
|
+
traceId: 'trace1',
|
|
183
|
+
timestamp: [1700000000, 0],
|
|
184
|
+
path: '/test',
|
|
185
|
+
status: 200,
|
|
186
|
+
duration: [0, 100000000]
|
|
187
|
+
} as HttpServerObservation]
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
connection.sendMessage(serviceMessage);
|
|
191
|
+
connection.sendMessage(observationMessage);
|
|
192
|
+
|
|
193
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
194
|
+
|
|
195
|
+
mockSocket._triggerMessage(authAck());
|
|
196
|
+
|
|
197
|
+
expect(mockSocket.send).toHaveBeenCalledTimes(2);
|
|
198
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
|
|
199
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should send messages immediately when already authorized', () => {
|
|
203
|
+
mockSocket._triggerMessage(authAck());
|
|
204
|
+
jest.clearAllMocks();
|
|
205
|
+
|
|
206
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
207
|
+
event: 'new-entity',
|
|
208
|
+
type: 'service',
|
|
209
|
+
hash: 'test-hash',
|
|
210
|
+
name: 'test-service'
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
connection.sendMessage(serviceMessage);
|
|
214
|
+
|
|
215
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('Context Handling', () => {
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
222
|
+
mockSocket._triggerOpen();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should send context-start after auth when process context is set before auth', () => {
|
|
226
|
+
connection.setProcessContext('service-hash', { 'service.name': 'test' });
|
|
227
|
+
|
|
228
|
+
// Not authorized yet, so no context-start sent
|
|
229
|
+
mockSocket.send.mockClear();
|
|
230
|
+
|
|
231
|
+
mockSocket._triggerMessage(authAck());
|
|
232
|
+
|
|
233
|
+
// Should have sent context-start first
|
|
234
|
+
const sentMessages = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]));
|
|
235
|
+
const contextMsg = sentMessages.find((m: any) => m.event === 'context-start');
|
|
236
|
+
expect(contextMsg).toMatchObject({
|
|
237
|
+
event: 'context-start',
|
|
238
|
+
type: 'process',
|
|
239
|
+
serviceEntityHash: 'service-hash',
|
|
240
|
+
resources: { 'service.name': 'test' },
|
|
241
|
+
ingestionId: expect.any(String),
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should send context-start immediately when already authorized', () => {
|
|
246
|
+
mockSocket._triggerMessage(authAck());
|
|
247
|
+
mockSocket.send.mockClear();
|
|
248
|
+
|
|
249
|
+
connection.setProcessContext('service-hash', { 'service.name': 'test' });
|
|
250
|
+
|
|
251
|
+
const sentMessages = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]));
|
|
252
|
+
expect(sentMessages[0]).toMatchObject({
|
|
253
|
+
event: 'context-start',
|
|
254
|
+
type: 'process',
|
|
255
|
+
serviceEntityHash: 'service-hash',
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should re-send context-start on reconnect before other replays', () => {
|
|
260
|
+
connection.setProcessContext('service-hash', { 'service.name': 'test' });
|
|
261
|
+
mockSocket._triggerMessage(authAck());
|
|
262
|
+
|
|
263
|
+
// Send an entity message
|
|
264
|
+
const entityMsg: NewObservedServiceMessage = {
|
|
265
|
+
event: 'new-entity', type: 'service', hash: 'h1', name: 'svc'
|
|
266
|
+
};
|
|
267
|
+
connection.sendMessage(entityMsg);
|
|
268
|
+
|
|
269
|
+
// Reconnect
|
|
270
|
+
mockSocket._triggerClose(1000, 'test');
|
|
271
|
+
jest.advanceTimersByTime(1000);
|
|
272
|
+
mockSocket._triggerOpen();
|
|
273
|
+
mockSocket.send.mockClear();
|
|
274
|
+
|
|
275
|
+
mockSocket._triggerMessage(authAck());
|
|
276
|
+
|
|
277
|
+
const sentMessages = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]));
|
|
278
|
+
// Context should come first
|
|
279
|
+
expect(sentMessages[0].event).toBe('context-start');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('Message Acknowledgment Handling', () => {
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
286
|
+
mockSocket._triggerOpen();
|
|
287
|
+
mockSocket._triggerMessage(authAck());
|
|
288
|
+
jest.clearAllMocks();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should handle entity/interaction acknowledgments', () => {
|
|
292
|
+
const serviceMessage: NewObservedServiceMessage = {
|
|
293
|
+
event: 'new-entity', type: 'service', hash: 'service-hash', name: 'test-service'
|
|
294
|
+
};
|
|
295
|
+
const routeMessage: NewObservedHttpRouteMessage = {
|
|
296
|
+
event: 'new-entity', type: 'http-route', hash: 'route-hash',
|
|
297
|
+
parent: 'service-hash', method: 'GET', route: '/test'
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
connection.sendMessage(serviceMessage);
|
|
301
|
+
connection.sendMessage(routeMessage);
|
|
302
|
+
|
|
303
|
+
// Acknowledge the service
|
|
304
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-observed', hash: 'service-hash' }));
|
|
305
|
+
|
|
306
|
+
mockSocket.send.mockClear();
|
|
307
|
+
|
|
308
|
+
// Reconnect — only unacked route should replay
|
|
309
|
+
mockSocket._triggerClose(1000, 'test close');
|
|
310
|
+
jest.advanceTimersByTime(1000);
|
|
311
|
+
mockSocket._triggerOpen();
|
|
312
|
+
mockSocket.send.mockClear();
|
|
313
|
+
|
|
314
|
+
mockSocket._triggerMessage(authAck());
|
|
315
|
+
|
|
316
|
+
expect(mockSocket.send).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(routeMessage));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should handle observation acknowledgments', () => {
|
|
321
|
+
const obs1: ObservationMessage = { event: 'observations', seq: 1, observations: [] };
|
|
322
|
+
const obs2: ObservationMessage = { event: 'observations', seq: 2, observations: [] };
|
|
323
|
+
|
|
324
|
+
connection.sendMessage(obs1);
|
|
325
|
+
connection.sendMessage(obs2);
|
|
326
|
+
|
|
327
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-observations', seq: 1 }));
|
|
328
|
+
|
|
329
|
+
mockSocket.send.mockClear();
|
|
330
|
+
mockSocket._triggerClose(1000, 'test');
|
|
331
|
+
jest.advanceTimersByTime(1000);
|
|
332
|
+
mockSocket._triggerOpen();
|
|
333
|
+
mockSocket.send.mockClear();
|
|
334
|
+
|
|
335
|
+
mockSocket._triggerMessage(authAck());
|
|
336
|
+
|
|
337
|
+
expect(mockSocket.send).toHaveBeenCalledTimes(1);
|
|
338
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(obs2));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should handle timeseries acknowledgments', () => {
|
|
342
|
+
const msg: TimeSeriesMetricsMessage = {
|
|
343
|
+
event: 'timeseries', seq: 5, data: []
|
|
344
|
+
};
|
|
345
|
+
connection.sendMessage(msg);
|
|
346
|
+
|
|
347
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-timeseries', seq: 5 }));
|
|
348
|
+
|
|
349
|
+
mockSocket.send.mockClear();
|
|
350
|
+
mockSocket._triggerClose(1000, 'test');
|
|
351
|
+
jest.advanceTimersByTime(1000);
|
|
352
|
+
mockSocket._triggerOpen();
|
|
353
|
+
mockSocket.send.mockClear();
|
|
354
|
+
mockSocket._triggerMessage(authAck());
|
|
355
|
+
|
|
356
|
+
// Should not replay the acked message
|
|
357
|
+
const sentEvents = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]).event);
|
|
358
|
+
expect(sentEvents).not.toContain('timeseries');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should handle cumulative acknowledgments', () => {
|
|
362
|
+
const msg: CumulativeMetricsMessage = { event: 'cumulative', seq: 3, data: [] };
|
|
363
|
+
connection.sendMessage(msg);
|
|
364
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-cumulative', seq: 3 }));
|
|
365
|
+
|
|
366
|
+
mockSocket.send.mockClear();
|
|
367
|
+
mockSocket._triggerClose(1000, 'test');
|
|
368
|
+
jest.advanceTimersByTime(1000);
|
|
369
|
+
mockSocket._triggerOpen();
|
|
370
|
+
mockSocket.send.mockClear();
|
|
371
|
+
mockSocket._triggerMessage(authAck());
|
|
372
|
+
|
|
373
|
+
const sentEvents = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]).event);
|
|
374
|
+
expect(sentEvents).not.toContain('cumulative');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should handle tracespans acknowledgments', () => {
|
|
378
|
+
const msg: TraceSpansMessage = { event: 'tracespans', seq: 7, data: [] };
|
|
379
|
+
connection.sendMessage(msg);
|
|
380
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-tracespans', seq: 7 }));
|
|
381
|
+
|
|
382
|
+
mockSocket.send.mockClear();
|
|
383
|
+
mockSocket._triggerClose(1000, 'test');
|
|
384
|
+
jest.advanceTimersByTime(1000);
|
|
385
|
+
mockSocket._triggerOpen();
|
|
386
|
+
mockSocket.send.mockClear();
|
|
387
|
+
mockSocket._triggerMessage(authAck());
|
|
388
|
+
|
|
389
|
+
const sentEvents = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]).event);
|
|
390
|
+
expect(sentEvents).not.toContain('tracespans');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should handle db-query acknowledgments', () => {
|
|
394
|
+
const msg: DatabaseQueryMessage = {
|
|
395
|
+
event: 'db-query', seq: 9, query: 'SELECT 1', from: 'a', to: 'b',
|
|
396
|
+
timestamp: [0, 0], duration: [0, 0]
|
|
397
|
+
};
|
|
398
|
+
connection.sendMessage(msg);
|
|
399
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-db-query', seq: 9 }));
|
|
400
|
+
|
|
401
|
+
mockSocket.send.mockClear();
|
|
402
|
+
mockSocket._triggerClose(1000, 'test');
|
|
403
|
+
jest.advanceTimersByTime(1000);
|
|
404
|
+
mockSocket._triggerOpen();
|
|
405
|
+
mockSocket.send.mockClear();
|
|
406
|
+
mockSocket._triggerMessage(authAck());
|
|
407
|
+
|
|
408
|
+
const sentEvents = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]).event);
|
|
409
|
+
expect(sentEvents).not.toContain('db-query');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should handle context acknowledgments', () => {
|
|
413
|
+
connection.setProcessContext('svc-hash', { 'service.name': 'test' });
|
|
414
|
+
mockSocket._triggerMessage(authAck());
|
|
415
|
+
|
|
416
|
+
// Find the context seq
|
|
417
|
+
const contextCall = mockSocket.send.mock.calls.find(c => {
|
|
418
|
+
const m = JSON.parse(c[0]);
|
|
419
|
+
return m.event === 'context-start';
|
|
420
|
+
});
|
|
421
|
+
const contextSeq = JSON.parse(contextCall![0]).seq;
|
|
422
|
+
|
|
423
|
+
mockSocket._triggerMessage(JSON.stringify({ type: 'ack-context', seq: contextSeq }));
|
|
424
|
+
|
|
425
|
+
// On reconnect, context-start should still be sent (because setProcessContext data persists)
|
|
426
|
+
// but the acked one should be removed from the unack queue
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('Custom Metric Change', () => {
|
|
431
|
+
it('should call onCustomMetricChange callback', () => {
|
|
432
|
+
const onCustomMetricChange = jest.fn();
|
|
433
|
+
connection = new WebSocketConnection({
|
|
434
|
+
organization: 'test-org', token: 'test-token', onCustomMetricChange
|
|
435
|
+
});
|
|
436
|
+
mockSocket._triggerOpen();
|
|
437
|
+
mockSocket._triggerMessage(authAck());
|
|
438
|
+
|
|
439
|
+
const specs: CustomMetricSpecification[] = [
|
|
440
|
+
{ type: 'timeseries', id: 'metric1', attributes: ['a'], subject: 'sub1' }
|
|
441
|
+
];
|
|
442
|
+
mockSocket._triggerMessage(JSON.stringify({
|
|
443
|
+
type: 'custom-metric-change',
|
|
444
|
+
customMetrics: specs
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
expect(onCustomMetricChange).toHaveBeenCalledWith(specs);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe('Shared Sequence Counter', () => {
|
|
452
|
+
it('should provide incrementing sequence numbers', () => {
|
|
453
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token' });
|
|
454
|
+
|
|
455
|
+
expect(connection.nextSeq()).toBe(1);
|
|
456
|
+
expect(connection.nextSeq()).toBe(2);
|
|
457
|
+
expect(connection.nextSeq()).toBe(3);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('Reconnection Logic', () => {
|
|
462
|
+
beforeEach(() => {
|
|
463
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
464
|
+
mockSocket._triggerOpen();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should reconnect automatically on close', () => {
|
|
468
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
|
|
469
|
+
|
|
470
|
+
mockSocket._triggerClose(1000, 'Normal closure');
|
|
471
|
+
|
|
472
|
+
expect(logMessages).toContain('WebSocket disconnected. Code: 1000, Reason: Normal closure');
|
|
473
|
+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
|
|
474
|
+
|
|
475
|
+
jest.advanceTimersByTime(1000);
|
|
476
|
+
expect(MockedWebSocket).toHaveBeenCalledTimes(2);
|
|
477
|
+
|
|
478
|
+
setTimeoutSpy.mockRestore();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('should reset authorization state on close', () => {
|
|
482
|
+
mockSocket._triggerMessage(authAck());
|
|
483
|
+
|
|
484
|
+
const testMessage: NewObservedServiceMessage = {
|
|
485
|
+
event: 'new-entity', type: 'service', hash: 'test-hash', name: 'test-service'
|
|
486
|
+
};
|
|
487
|
+
connection.sendMessage(testMessage);
|
|
488
|
+
expect(mockSocket.send).toHaveBeenCalled();
|
|
489
|
+
|
|
490
|
+
jest.clearAllMocks();
|
|
491
|
+
mockSocket._triggerClose(1000, 'test');
|
|
492
|
+
|
|
493
|
+
connection.sendMessage(testMessage);
|
|
494
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should not reconnect when explicitly closed', () => {
|
|
498
|
+
connection.close();
|
|
499
|
+
mockSocket._triggerClose(1000, 'Explicit close');
|
|
500
|
+
|
|
501
|
+
jest.advanceTimersByTime(5000);
|
|
502
|
+
expect(MockedWebSocket).toHaveBeenCalledTimes(1);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe('Error Handling', () => {
|
|
507
|
+
beforeEach(() => {
|
|
508
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('should log WebSocket errors', () => {
|
|
512
|
+
const testError = new Error('Connection failed');
|
|
513
|
+
mockSocket._triggerError(testError);
|
|
514
|
+
|
|
515
|
+
expect(logMessages).toContain('WebSocket encountered an error: Connection failed');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should handle malformed server messages gracefully', () => {
|
|
519
|
+
mockSocket._triggerOpen();
|
|
520
|
+
mockSocket._triggerMessage('{invalid json}');
|
|
521
|
+
|
|
522
|
+
const errorLogExists = logMessages.some(msg =>
|
|
523
|
+
msg.startsWith('Error parsing message from server:') &&
|
|
524
|
+
msg.includes('JSON')
|
|
525
|
+
);
|
|
526
|
+
expect(errorLogExists).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should not send messages when socket is not open', () => {
|
|
530
|
+
mockSocket.readyState = WebSocket.CLOSED;
|
|
531
|
+
mockSocket._triggerOpen();
|
|
532
|
+
mockSocket._triggerMessage(authAck());
|
|
533
|
+
mockSocket.send.mockClear();
|
|
534
|
+
|
|
535
|
+
mockSocket.readyState = WebSocket.CLOSED;
|
|
536
|
+
|
|
537
|
+
const testMessage: NewObservedServiceMessage = {
|
|
538
|
+
event: 'new-entity', type: 'service', hash: 'test-hash', name: 'test-service'
|
|
539
|
+
};
|
|
540
|
+
connection.sendMessage(testMessage);
|
|
541
|
+
|
|
542
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe('Connection Lifecycle', () => {
|
|
547
|
+
it('should close WebSocket connection properly', () => {
|
|
548
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
549
|
+
connection.close();
|
|
550
|
+
expect(mockSocket.close).toHaveBeenCalled();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should handle close when socket is null', () => {
|
|
554
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
555
|
+
(connection as any).socket = null;
|
|
556
|
+
expect(() => connection.close()).not.toThrow();
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe('Message Queuing for All Types', () => {
|
|
561
|
+
beforeEach(() => {
|
|
562
|
+
connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
|
|
563
|
+
mockSocket._triggerOpen();
|
|
564
|
+
mockSocket.send.mockClear();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('should queue and replay timeseries messages', () => {
|
|
568
|
+
const msg: TimeSeriesMetricsMessage = {
|
|
569
|
+
event: 'timeseries', seq: 1, data: [
|
|
570
|
+
{ subject: 's', type: 't', timestamp: [0, 0], value: 42, unit: 'By', attributes: {} }
|
|
571
|
+
]
|
|
572
|
+
};
|
|
573
|
+
connection.sendMessage(msg);
|
|
574
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
575
|
+
|
|
576
|
+
mockSocket._triggerMessage(authAck());
|
|
577
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('should queue and replay cumulative messages', () => {
|
|
581
|
+
const msg: CumulativeMetricsMessage = {
|
|
582
|
+
event: 'cumulative', seq: 2, data: []
|
|
583
|
+
};
|
|
584
|
+
connection.sendMessage(msg);
|
|
585
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
586
|
+
|
|
587
|
+
mockSocket._triggerMessage(authAck());
|
|
588
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('should queue and replay tracespans messages', () => {
|
|
592
|
+
const msg: TraceSpansMessage = {
|
|
593
|
+
event: 'tracespans', seq: 3, data: [
|
|
594
|
+
{ trace: 't1', span: 's1', parent: '', name: 'test', timestamp: [0, 0] }
|
|
595
|
+
]
|
|
596
|
+
};
|
|
597
|
+
connection.sendMessage(msg);
|
|
598
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
599
|
+
|
|
600
|
+
mockSocket._triggerMessage(authAck());
|
|
601
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('should queue and replay db-query messages', () => {
|
|
605
|
+
const msg: DatabaseQueryMessage = {
|
|
606
|
+
event: 'db-query', seq: 4, query: 'SELECT 1', from: 'a', to: 'b',
|
|
607
|
+
timestamp: [0, 0], duration: [0, 1000000]
|
|
608
|
+
};
|
|
609
|
+
connection.sendMessage(msg);
|
|
610
|
+
expect(mockSocket.send).not.toHaveBeenCalled();
|
|
611
|
+
|
|
612
|
+
mockSocket._triggerMessage(authAck());
|
|
613
|
+
expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
});
|