@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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/.idea/telemetry-node.iml +0 -1
  3. package/DEVELOPMENT.md +69 -0
  4. package/README.md +173 -0
  5. package/dist/ComprehendDevSpanProcessor.d.ts +9 -6
  6. package/dist/ComprehendDevSpanProcessor.js +146 -87
  7. package/dist/ComprehendDevSpanProcessor.test.d.ts +1 -0
  8. package/dist/ComprehendDevSpanProcessor.test.js +495 -0
  9. package/dist/ComprehendMetricsExporter.d.ts +18 -0
  10. package/dist/ComprehendMetricsExporter.js +178 -0
  11. package/dist/ComprehendMetricsExporter.test.d.ts +1 -0
  12. package/dist/ComprehendMetricsExporter.test.js +266 -0
  13. package/dist/ComprehendSDK.d.ts +18 -0
  14. package/dist/ComprehendSDK.js +56 -0
  15. package/dist/ComprehendSDK.test.d.ts +1 -0
  16. package/dist/ComprehendSDK.test.js +126 -0
  17. package/dist/WebSocketConnection.d.ts +23 -3
  18. package/dist/WebSocketConnection.js +106 -12
  19. package/dist/WebSocketConnection.test.d.ts +1 -0
  20. package/dist/WebSocketConnection.test.js +473 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.js +5 -1
  23. package/dist/sql-analyzer.js +2 -11
  24. package/dist/sql-analyzer.test.js +0 -12
  25. package/dist/util.d.ts +2 -0
  26. package/dist/util.js +7 -0
  27. package/dist/wire-protocol.d.ts +168 -28
  28. package/jest.config.js +1 -0
  29. package/package.json +4 -2
  30. package/src/ComprehendDevSpanProcessor.test.ts +626 -0
  31. package/src/ComprehendDevSpanProcessor.ts +170 -105
  32. package/src/ComprehendMetricsExporter.test.ts +334 -0
  33. package/src/ComprehendMetricsExporter.ts +225 -0
  34. package/src/ComprehendSDK.test.ts +160 -0
  35. package/src/ComprehendSDK.ts +63 -0
  36. package/src/WebSocketConnection.test.ts +616 -0
  37. package/src/WebSocketConnection.ts +135 -13
  38. package/src/index.ts +3 -2
  39. package/src/util.ts +6 -0
  40. package/src/wire-protocol.ts +204 -29
  41. package/src/sql-analyzer.test.ts +0 -599
  42. 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
+ });