@comprehend/telemetry-node 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,23 @@ import {
5
5
  NewObservedHttpRouteMessage,
6
6
  ObservationMessage,
7
7
  HttpServerObservation,
8
- ObservationOutputMessage
8
+ ObservationOutputMessage,
9
+ TimeSeriesMetricsMessage,
10
+ CumulativeMetricsMessage,
11
+ TraceSpansMessage,
12
+ DatabaseQueryMessage,
13
+ InitAck,
14
+ CustomMetricSpecification,
9
15
  } from './wire-protocol';
10
16
 
11
17
  // Mock the ws WebSocket library
12
18
  jest.mock('ws');
13
19
 
20
+ // Mock crypto.randomUUID
21
+ jest.mock('crypto', () => ({
22
+ randomUUID: jest.fn().mockReturnValue('test-uuid-1234'),
23
+ }));
24
+
14
25
  import WebSocket from 'ws';
15
26
 
16
27
  const MockedWebSocket = WebSocket as jest.MockedClass<typeof WebSocket>;
@@ -23,13 +34,16 @@ interface MockWebSocketInstance {
23
34
  readyState: number;
24
35
  OPEN: number;
25
36
  CLOSED: number;
26
- // Event handlers that tests can trigger
27
37
  _triggerOpen: () => void;
28
38
  _triggerMessage: (data: string) => void;
29
39
  _triggerClose: (code: number, reason: string) => void;
30
40
  _triggerError: (error: Error) => void;
31
41
  }
32
42
 
43
+ function authAck(customMetrics: CustomMetricSpecification[] = []): string {
44
+ return JSON.stringify({ type: 'ack-authorized', customMetrics } as InitAck);
45
+ }
46
+
33
47
  describe('WebSocketConnection', () => {
34
48
  let mockSocket: MockWebSocketInstance;
35
49
  let connection: WebSocketConnection;
@@ -37,7 +51,6 @@ describe('WebSocketConnection', () => {
37
51
  let mockLogger: jest.Mock;
38
52
 
39
53
  beforeEach(() => {
40
- // Reset all mocks
41
54
  jest.clearAllMocks();
42
55
  jest.clearAllTimers();
43
56
  jest.useFakeTimers();
@@ -47,7 +60,6 @@ describe('WebSocketConnection', () => {
47
60
  logMessages.push(message);
48
61
  });
49
62
 
50
- // Create mock WebSocket instance with event handling
51
63
  mockSocket = {
52
64
  on: jest.fn(),
53
65
  send: jest.fn(),
@@ -55,13 +67,12 @@ describe('WebSocketConnection', () => {
55
67
  readyState: WebSocket.CLOSED,
56
68
  OPEN: WebSocket.OPEN,
57
69
  CLOSED: WebSocket.CLOSED,
58
- _triggerOpen: function() { /* will be set up below */ },
59
- _triggerMessage: function(data: string) { /* will be set up below */ },
60
- _triggerClose: function(code: number, reason: string) { /* will be set up below */ },
61
- _triggerError: function(error: Error) { /* will be set up below */ }
70
+ _triggerOpen: function() {},
71
+ _triggerMessage: function(data: string) {},
72
+ _triggerClose: function(code: number, reason: string) {},
73
+ _triggerError: function(error: Error) {}
62
74
  };
63
75
 
64
- // Set up event handler storage and triggering
65
76
  const eventHandlers: Record<string, Function> = {};
66
77
  mockSocket.on.mockImplementation((event: string, handler: Function) => {
67
78
  eventHandlers[event] = handler;
@@ -83,7 +94,6 @@ describe('WebSocketConnection', () => {
83
94
  eventHandlers['error']?.(error);
84
95
  };
85
96
 
86
- // Mock WebSocket constructor to return our mock instance
87
97
  MockedWebSocket.mockImplementation(() => mockSocket as any);
88
98
  });
89
99
 
@@ -96,7 +106,7 @@ describe('WebSocketConnection', () => {
96
106
 
97
107
  describe('Connection Establishment', () => {
98
108
  it('should create WebSocket connection with correct URL', () => {
99
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
109
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
100
110
 
101
111
  expect(MockedWebSocket).toHaveBeenCalledWith('wss://ingestion.comprehend.dev/test-org/observations');
102
112
  expect(mockSocket.on).toHaveBeenCalledWith('open', expect.any(Function));
@@ -105,21 +115,21 @@ describe('WebSocketConnection', () => {
105
115
  expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
106
116
  });
107
117
 
108
- it('should send init message on connection open', () => {
109
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
118
+ it('should send V2 init message on connection open', () => {
119
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
110
120
 
111
121
  mockSocket._triggerOpen();
112
122
 
113
123
  expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify({
114
124
  event: 'init',
115
- protocolVersion: 1,
125
+ protocolVersion: 2,
116
126
  token: 'test-token'
117
127
  }));
118
128
  expect(logMessages).toContain('WebSocket connected. Sending init/auth message.');
119
129
  });
120
130
 
121
131
  it('should work without logger', () => {
122
- connection = new WebSocketConnection('test-org', 'test-token');
132
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token' });
123
133
 
124
134
  mockSocket._triggerOpen();
125
135
 
@@ -130,23 +140,31 @@ describe('WebSocketConnection', () => {
130
140
 
131
141
  describe('Authorization Flow', () => {
132
142
  beforeEach(() => {
133
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
143
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
134
144
  mockSocket._triggerOpen();
135
- jest.clearAllMocks(); // Clear the init message send
145
+ jest.clearAllMocks();
136
146
  });
137
147
 
138
- it('should handle authorization acknowledgment', () => {
139
- const authAck: ObservationOutputMessage = {
140
- type: 'ack-authorized'
141
- };
142
-
143
- mockSocket._triggerMessage(JSON.stringify(authAck));
148
+ it('should handle authorization acknowledgment with customMetrics', () => {
149
+ mockSocket._triggerMessage(authAck([{ type: 'cumulative', id: 'test', attributes: [], subject: 'sub' }]));
144
150
 
145
151
  expect(logMessages).toContain('Authorization acknowledged by server.');
146
152
  });
147
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
+
148
167
  it('should replay queued messages after authorization', () => {
149
- // Queue some messages before authorization
150
168
  const serviceMessage: NewObservedServiceMessage = {
151
169
  event: 'new-entity',
152
170
  type: 'service',
@@ -160,6 +178,8 @@ describe('WebSocketConnection', () => {
160
178
  observations: [{
161
179
  type: 'http-server',
162
180
  subject: 'test-subject',
181
+ spanId: 'span1',
182
+ traceId: 'trace1',
163
183
  timestamp: [1700000000, 0],
164
184
  path: '/test',
165
185
  status: 200,
@@ -170,21 +190,17 @@ describe('WebSocketConnection', () => {
170
190
  connection.sendMessage(serviceMessage);
171
191
  connection.sendMessage(observationMessage);
172
192
 
173
- // Should not send immediately (not authorized yet)
174
193
  expect(mockSocket.send).not.toHaveBeenCalled();
175
194
 
176
- // Now authorize
177
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
195
+ mockSocket._triggerMessage(authAck());
178
196
 
179
- // Should replay both messages
180
197
  expect(mockSocket.send).toHaveBeenCalledTimes(2);
181
198
  expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
182
199
  expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
183
200
  });
184
201
 
185
202
  it('should send messages immediately when already authorized', () => {
186
- // First authorize
187
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
203
+ mockSocket._triggerMessage(authAck());
188
204
  jest.clearAllMocks();
189
205
 
190
206
  const serviceMessage: NewObservedServiceMessage = {
@@ -200,104 +216,251 @@ describe('WebSocketConnection', () => {
200
216
  });
201
217
  });
202
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
+
203
283
  describe('Message Acknowledgment Handling', () => {
204
284
  beforeEach(() => {
205
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
285
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
206
286
  mockSocket._triggerOpen();
207
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
287
+ mockSocket._triggerMessage(authAck());
208
288
  jest.clearAllMocks();
209
289
  });
210
290
 
211
291
  it('should handle entity/interaction acknowledgments', () => {
212
292
  const serviceMessage: NewObservedServiceMessage = {
213
- event: 'new-entity',
214
- type: 'service',
215
- hash: 'service-hash',
216
- name: 'test-service'
293
+ event: 'new-entity', type: 'service', hash: 'service-hash', name: 'test-service'
217
294
  };
218
-
219
295
  const routeMessage: NewObservedHttpRouteMessage = {
220
- event: 'new-entity',
221
- type: 'http-route',
222
- hash: 'route-hash',
223
- parent: 'service-hash',
224
- method: 'GET',
225
- route: '/test'
296
+ event: 'new-entity', type: 'http-route', hash: 'route-hash',
297
+ parent: 'service-hash', method: 'GET', route: '/test'
226
298
  };
227
299
 
228
- // Send messages
229
300
  connection.sendMessage(serviceMessage);
230
301
  connection.sendMessage(routeMessage);
231
302
 
232
- // Acknowledge the service message
233
- mockSocket._triggerMessage(JSON.stringify({
234
- type: 'ack-observed',
235
- hash: 'service-hash'
236
- }));
303
+ // Acknowledge the service
304
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-observed', hash: 'service-hash' }));
237
305
 
238
- // Clear previous calls before reconnection test
239
306
  mockSocket.send.mockClear();
240
307
 
241
- // If we reconnect, only the route message should be replayed
308
+ // Reconnect only unacked route should replay
242
309
  mockSocket._triggerClose(1000, 'test close');
243
310
  jest.advanceTimersByTime(1000);
244
311
  mockSocket._triggerOpen();
245
-
246
- // Clear the init message that gets sent on reconnection
247
312
  mockSocket.send.mockClear();
248
313
 
249
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
314
+ mockSocket._triggerMessage(authAck());
250
315
 
251
- // Should only send the unacknowledged route message
252
316
  expect(mockSocket.send).toHaveBeenCalledTimes(1);
253
317
  expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(routeMessage));
254
318
  });
255
319
 
256
320
  it('should handle observation acknowledgments', () => {
257
- const observationMessage1: ObservationMessage = {
258
- event: 'observations',
259
- seq: 1,
260
- observations: []
261
- };
321
+ const obs1: ObservationMessage = { event: 'observations', seq: 1, observations: [] };
322
+ const obs2: ObservationMessage = { event: 'observations', seq: 2, observations: [] };
262
323
 
263
- const observationMessage2: ObservationMessage = {
264
- event: 'observations',
265
- seq: 2,
266
- observations: []
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: []
267
344
  };
345
+ connection.sendMessage(msg);
268
346
 
269
- // Send messages
270
- connection.sendMessage(observationMessage1);
271
- connection.sendMessage(observationMessage2);
347
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-timeseries', seq: 5 }));
272
348
 
273
- // Acknowledge first observation
274
- mockSocket._triggerMessage(JSON.stringify({
275
- type: 'ack-observations',
276
- seq: 1
277
- }));
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 }));
278
365
 
279
- // Clear previous calls before reconnection test
280
366
  mockSocket.send.mockClear();
367
+ mockSocket._triggerClose(1000, 'test');
368
+ jest.advanceTimersByTime(1000);
369
+ mockSocket._triggerOpen();
370
+ mockSocket.send.mockClear();
371
+ mockSocket._triggerMessage(authAck());
281
372
 
282
- // Reconnect - only second observation should be replayed
283
- mockSocket._triggerClose(1000, 'test close');
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');
284
384
  jest.advanceTimersByTime(1000);
285
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
+ });
286
392
 
287
- // Clear the init message that gets sent on reconnection
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();
288
405
  mockSocket.send.mockClear();
406
+ mockSocket._triggerMessage(authAck());
289
407
 
290
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
408
+ const sentEvents = mockSocket.send.mock.calls.map(c => JSON.parse(c[0]).event);
409
+ expect(sentEvents).not.toContain('db-query');
410
+ });
291
411
 
292
- // Should only send the unacknowledged second observation message
293
- expect(mockSocket.send).toHaveBeenCalledTimes(1);
294
- expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage2));
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);
295
458
  });
296
459
  });
297
460
 
298
461
  describe('Reconnection Logic', () => {
299
462
  beforeEach(() => {
300
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
463
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
301
464
  mockSocket._triggerOpen();
302
465
  });
303
466
 
@@ -307,39 +470,26 @@ describe('WebSocketConnection', () => {
307
470
  mockSocket._triggerClose(1000, 'Normal closure');
308
471
 
309
472
  expect(logMessages).toContain('WebSocket disconnected. Code: 1000, Reason: Normal closure');
310
-
311
- // Should schedule reconnection
312
473
  expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
313
474
 
314
- // Advance timer to trigger reconnection
315
475
  jest.advanceTimersByTime(1000);
316
-
317
- // Should create new WebSocket connection
318
476
  expect(MockedWebSocket).toHaveBeenCalledTimes(2);
319
477
 
320
478
  setTimeoutSpy.mockRestore();
321
479
  });
322
480
 
323
481
  it('should reset authorization state on close', () => {
324
- // First authorize
325
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
482
+ mockSocket._triggerMessage(authAck());
326
483
 
327
- // Send a message (should send immediately)
328
484
  const testMessage: NewObservedServiceMessage = {
329
- event: 'new-entity',
330
- type: 'service',
331
- hash: 'test-hash',
332
- name: 'test-service'
485
+ event: 'new-entity', type: 'service', hash: 'test-hash', name: 'test-service'
333
486
  };
334
487
  connection.sendMessage(testMessage);
335
488
  expect(mockSocket.send).toHaveBeenCalled();
336
489
 
337
490
  jest.clearAllMocks();
338
-
339
- // Close connection
340
491
  mockSocket._triggerClose(1000, 'test');
341
492
 
342
- // Send another message - should queue (not send immediately)
343
493
  connection.sendMessage(testMessage);
344
494
  expect(mockSocket.send).not.toHaveBeenCalled();
345
495
  });
@@ -348,15 +498,14 @@ describe('WebSocketConnection', () => {
348
498
  connection.close();
349
499
  mockSocket._triggerClose(1000, 'Explicit close');
350
500
 
351
- // Should not schedule reconnection
352
501
  jest.advanceTimersByTime(5000);
353
- expect(MockedWebSocket).toHaveBeenCalledTimes(1); // Only initial connection
502
+ expect(MockedWebSocket).toHaveBeenCalledTimes(1);
354
503
  });
355
504
  });
356
505
 
357
506
  describe('Error Handling', () => {
358
507
  beforeEach(() => {
359
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
508
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
360
509
  });
361
510
 
362
511
  it('should log WebSocket errors', () => {
@@ -370,7 +519,6 @@ describe('WebSocketConnection', () => {
370
519
  mockSocket._triggerOpen();
371
520
  mockSocket._triggerMessage('{invalid json}');
372
521
 
373
- // Check that an error message was logged (exact message varies by Node version)
374
522
  const errorLogExists = logMessages.some(msg =>
375
523
  msg.startsWith('Error parsing message from server:') &&
376
524
  msg.includes('JSON')
@@ -378,158 +526,91 @@ describe('WebSocketConnection', () => {
378
526
  expect(errorLogExists).toBe(true);
379
527
  });
380
528
 
381
- it('should handle non-string error objects in message parsing', () => {
382
- mockSocket._triggerOpen();
383
-
384
- // Mock JSON.parse to throw a non-Error object
385
- const originalParse = JSON.parse;
386
- JSON.parse = jest.fn().mockImplementation(() => {
387
- throw 'string error';
388
- });
389
-
390
- mockSocket._triggerMessage('{}');
391
-
392
- expect(logMessages).toContain('Error parsing message from server: string error');
393
-
394
- // Restore original JSON.parse
395
- JSON.parse = originalParse;
396
- });
397
-
398
529
  it('should not send messages when socket is not open', () => {
399
530
  mockSocket.readyState = WebSocket.CLOSED;
400
531
  mockSocket._triggerOpen();
401
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
402
-
403
- // Clear the init and any previous messages
532
+ mockSocket._triggerMessage(authAck());
404
533
  mockSocket.send.mockClear();
405
534
 
406
- // Force socket to closed state
407
535
  mockSocket.readyState = WebSocket.CLOSED;
408
536
 
409
537
  const testMessage: NewObservedServiceMessage = {
410
- event: 'new-entity',
411
- type: 'service',
412
- hash: 'test-hash',
413
- name: 'test-service'
538
+ event: 'new-entity', type: 'service', hash: 'test-hash', name: 'test-service'
414
539
  };
415
-
416
540
  connection.sendMessage(testMessage);
417
541
 
418
- // Should not call send when socket is closed
419
542
  expect(mockSocket.send).not.toHaveBeenCalled();
420
543
  });
421
544
  });
422
545
 
423
546
  describe('Connection Lifecycle', () => {
424
547
  it('should close WebSocket connection properly', () => {
425
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
426
-
548
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
427
549
  connection.close();
428
-
429
550
  expect(mockSocket.close).toHaveBeenCalled();
430
551
  });
431
552
 
432
553
  it('should handle close when socket is null', () => {
433
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
434
-
435
- // Simulate socket being null
554
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
436
555
  (connection as any).socket = null;
437
-
438
556
  expect(() => connection.close()).not.toThrow();
439
557
  });
440
558
  });
441
559
 
442
- describe('Message Types and Queuing', () => {
560
+ describe('Message Queuing for All Types', () => {
443
561
  beforeEach(() => {
444
- connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
562
+ connection = new WebSocketConnection({ organization: 'test-org', token: 'test-token', logger: mockLogger });
445
563
  mockSocket._triggerOpen();
446
- });
447
-
448
- it('should queue new-entity messages', () => {
449
- // Clear the initial init message
450
564
  mockSocket.send.mockClear();
565
+ });
451
566
 
452
- const serviceMessage: NewObservedServiceMessage = {
453
- event: 'new-entity',
454
- type: 'service',
455
- hash: 'service-hash',
456
- name: 'test-service'
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
+ ]
457
572
  };
458
-
459
- connection.sendMessage(serviceMessage);
460
-
461
- // Should be queued, not sent immediately (not authorized)
573
+ connection.sendMessage(msg);
462
574
  expect(mockSocket.send).not.toHaveBeenCalled();
463
575
 
464
- // Authorize and check replay
465
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
466
- expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
576
+ mockSocket._triggerMessage(authAck());
577
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
467
578
  });
468
579
 
469
- it('should queue new-interaction messages', () => {
470
- // Clear the initial init message
471
- mockSocket.send.mockClear();
472
-
473
- const httpRequestMessage = {
474
- event: 'new-interaction' as const,
475
- type: 'http-request' as const,
476
- hash: 'request-hash',
477
- from: 'service-hash',
478
- to: 'http-service-hash'
580
+ it('should queue and replay cumulative messages', () => {
581
+ const msg: CumulativeMetricsMessage = {
582
+ event: 'cumulative', seq: 2, data: []
479
583
  };
480
-
481
- connection.sendMessage(httpRequestMessage);
482
-
483
- // Should be queued
584
+ connection.sendMessage(msg);
484
585
  expect(mockSocket.send).not.toHaveBeenCalled();
485
586
 
486
- // Authorize and check replay
487
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
488
- expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(httpRequestMessage));
587
+ mockSocket._triggerMessage(authAck());
588
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
489
589
  });
490
590
 
491
- it('should queue observation messages', () => {
492
- // Clear the initial init message
493
- mockSocket.send.mockClear();
494
-
495
- const observationMessage: ObservationMessage = {
496
- event: 'observations',
497
- seq: 1,
498
- observations: [{
499
- type: 'http-server',
500
- subject: 'route-hash',
501
- timestamp: [1700000000, 0],
502
- path: '/test',
503
- status: 200,
504
- duration: [0, 50000000]
505
- } as HttpServerObservation]
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
+ ]
506
596
  };
507
-
508
- connection.sendMessage(observationMessage);
509
-
510
- // Should be queued
597
+ connection.sendMessage(msg);
511
598
  expect(mockSocket.send).not.toHaveBeenCalled();
512
599
 
513
- // Authorize and check replay
514
- mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
515
- expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
600
+ mockSocket._triggerMessage(authAck());
601
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
516
602
  });
517
603
 
518
- it('should not queue init messages in unacknowledged maps', () => {
519
- // This is implicit - init messages are sent via sendRaw, not sendMessage
520
- // and don't go through the queuing logic
521
-
522
- const initMessage: InitMessage = {
523
- event: 'init',
524
- protocolVersion: 1,
525
- token: 'test-token'
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]
526
608
  };
609
+ connection.sendMessage(msg);
610
+ expect(mockSocket.send).not.toHaveBeenCalled();
527
611
 
528
- // Manually test sendRaw behavior (private method testing)
529
- mockSocket.readyState = WebSocket.OPEN;
530
- (connection as any).sendRaw(initMessage);
531
-
532
- expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(initMessage));
612
+ mockSocket._triggerMessage(authAck());
613
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(msg));
533
614
  });
534
615
  });
535
- });
616
+ });