@comprehend/telemetry-node 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -144,6 +144,7 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
144
144
  ...(environment ? { environment } : {})
145
145
  };
146
146
  this.ingestMessage(message);
147
+ return newService;
147
148
  }
148
149
 
149
150
  private processHTTPRoute(service: ObservedService, route: string, method: string, span: ReadableSpan): void {
@@ -0,0 +1,535 @@
1
+ import { WebSocketConnection } from './WebSocketConnection';
2
+ import {
3
+ InitMessage,
4
+ NewObservedServiceMessage,
5
+ NewObservedHttpRouteMessage,
6
+ ObservationMessage,
7
+ HttpServerObservation,
8
+ ObservationOutputMessage
9
+ } from './wire-protocol';
10
+
11
+ // Mock the ws WebSocket library
12
+ jest.mock('ws');
13
+
14
+ import WebSocket from 'ws';
15
+
16
+ const MockedWebSocket = WebSocket as jest.MockedClass<typeof WebSocket>;
17
+
18
+ // Mock WebSocket instance
19
+ interface MockWebSocketInstance {
20
+ on: jest.Mock;
21
+ send: jest.Mock;
22
+ close: jest.Mock;
23
+ readyState: number;
24
+ OPEN: number;
25
+ CLOSED: number;
26
+ // Event handlers that tests can trigger
27
+ _triggerOpen: () => void;
28
+ _triggerMessage: (data: string) => void;
29
+ _triggerClose: (code: number, reason: string) => void;
30
+ _triggerError: (error: Error) => void;
31
+ }
32
+
33
+ describe('WebSocketConnection', () => {
34
+ let mockSocket: MockWebSocketInstance;
35
+ let connection: WebSocketConnection;
36
+ let logMessages: string[];
37
+ let mockLogger: jest.Mock;
38
+
39
+ beforeEach(() => {
40
+ // Reset all mocks
41
+ jest.clearAllMocks();
42
+ jest.clearAllTimers();
43
+ jest.useFakeTimers();
44
+
45
+ logMessages = [];
46
+ mockLogger = jest.fn((message: string) => {
47
+ logMessages.push(message);
48
+ });
49
+
50
+ // Create mock WebSocket instance with event handling
51
+ mockSocket = {
52
+ on: jest.fn(),
53
+ send: jest.fn(),
54
+ close: jest.fn(),
55
+ readyState: WebSocket.CLOSED,
56
+ OPEN: WebSocket.OPEN,
57
+ 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 */ }
62
+ };
63
+
64
+ // Set up event handler storage and triggering
65
+ const eventHandlers: Record<string, Function> = {};
66
+ mockSocket.on.mockImplementation((event: string, handler: Function) => {
67
+ eventHandlers[event] = handler;
68
+ });
69
+
70
+ mockSocket._triggerOpen = () => {
71
+ mockSocket.readyState = WebSocket.OPEN;
72
+ eventHandlers['open']?.();
73
+ };
74
+ mockSocket._triggerMessage = (data: string) => {
75
+ const buffer = Buffer.from(data);
76
+ eventHandlers['message']?.(buffer);
77
+ };
78
+ mockSocket._triggerClose = (code: number, reason: string) => {
79
+ mockSocket.readyState = WebSocket.CLOSED;
80
+ eventHandlers['close']?.(code, Buffer.from(reason));
81
+ };
82
+ mockSocket._triggerError = (error: Error) => {
83
+ eventHandlers['error']?.(error);
84
+ };
85
+
86
+ // Mock WebSocket constructor to return our mock instance
87
+ MockedWebSocket.mockImplementation(() => mockSocket as any);
88
+ });
89
+
90
+ afterEach(() => {
91
+ jest.useRealTimers();
92
+ if (connection) {
93
+ connection.close();
94
+ }
95
+ });
96
+
97
+ describe('Connection Establishment', () => {
98
+ it('should create WebSocket connection with correct URL', () => {
99
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
100
+
101
+ expect(MockedWebSocket).toHaveBeenCalledWith('wss://ingestion.comprehend.dev/test-org/observations');
102
+ expect(mockSocket.on).toHaveBeenCalledWith('open', expect.any(Function));
103
+ expect(mockSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
104
+ expect(mockSocket.on).toHaveBeenCalledWith('close', expect.any(Function));
105
+ expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
106
+ });
107
+
108
+ it('should send init message on connection open', () => {
109
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
110
+
111
+ mockSocket._triggerOpen();
112
+
113
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify({
114
+ event: 'init',
115
+ protocolVersion: 1,
116
+ token: 'test-token'
117
+ }));
118
+ expect(logMessages).toContain('WebSocket connected. Sending init/auth message.');
119
+ });
120
+
121
+ it('should work without logger', () => {
122
+ connection = new WebSocketConnection('test-org', 'test-token');
123
+
124
+ mockSocket._triggerOpen();
125
+
126
+ expect(MockedWebSocket).toHaveBeenCalled();
127
+ expect(mockSocket.send).toHaveBeenCalled();
128
+ });
129
+ });
130
+
131
+ describe('Authorization Flow', () => {
132
+ beforeEach(() => {
133
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
134
+ mockSocket._triggerOpen();
135
+ jest.clearAllMocks(); // Clear the init message send
136
+ });
137
+
138
+ it('should handle authorization acknowledgment', () => {
139
+ const authAck: ObservationOutputMessage = {
140
+ type: 'ack-authorized'
141
+ };
142
+
143
+ mockSocket._triggerMessage(JSON.stringify(authAck));
144
+
145
+ expect(logMessages).toContain('Authorization acknowledged by server.');
146
+ });
147
+
148
+ it('should replay queued messages after authorization', () => {
149
+ // Queue some messages before authorization
150
+ const serviceMessage: NewObservedServiceMessage = {
151
+ event: 'new-entity',
152
+ type: 'service',
153
+ hash: 'test-hash-1',
154
+ name: 'test-service'
155
+ };
156
+
157
+ const observationMessage: ObservationMessage = {
158
+ event: 'observations',
159
+ seq: 1,
160
+ observations: [{
161
+ type: 'http-server',
162
+ subject: 'test-subject',
163
+ timestamp: [1700000000, 0],
164
+ path: '/test',
165
+ status: 200,
166
+ duration: [0, 100000000]
167
+ } as HttpServerObservation]
168
+ };
169
+
170
+ connection.sendMessage(serviceMessage);
171
+ connection.sendMessage(observationMessage);
172
+
173
+ // Should not send immediately (not authorized yet)
174
+ expect(mockSocket.send).not.toHaveBeenCalled();
175
+
176
+ // Now authorize
177
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
178
+
179
+ // Should replay both messages
180
+ expect(mockSocket.send).toHaveBeenCalledTimes(2);
181
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
182
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
183
+ });
184
+
185
+ it('should send messages immediately when already authorized', () => {
186
+ // First authorize
187
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
188
+ jest.clearAllMocks();
189
+
190
+ const serviceMessage: NewObservedServiceMessage = {
191
+ event: 'new-entity',
192
+ type: 'service',
193
+ hash: 'test-hash',
194
+ name: 'test-service'
195
+ };
196
+
197
+ connection.sendMessage(serviceMessage);
198
+
199
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
200
+ });
201
+ });
202
+
203
+ describe('Message Acknowledgment Handling', () => {
204
+ beforeEach(() => {
205
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
206
+ mockSocket._triggerOpen();
207
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
208
+ jest.clearAllMocks();
209
+ });
210
+
211
+ it('should handle entity/interaction acknowledgments', () => {
212
+ const serviceMessage: NewObservedServiceMessage = {
213
+ event: 'new-entity',
214
+ type: 'service',
215
+ hash: 'service-hash',
216
+ name: 'test-service'
217
+ };
218
+
219
+ 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'
226
+ };
227
+
228
+ // Send messages
229
+ connection.sendMessage(serviceMessage);
230
+ connection.sendMessage(routeMessage);
231
+
232
+ // Acknowledge the service message
233
+ mockSocket._triggerMessage(JSON.stringify({
234
+ type: 'ack-observed',
235
+ hash: 'service-hash'
236
+ }));
237
+
238
+ // Clear previous calls before reconnection test
239
+ mockSocket.send.mockClear();
240
+
241
+ // If we reconnect, only the route message should be replayed
242
+ mockSocket._triggerClose(1000, 'test close');
243
+ jest.advanceTimersByTime(1000);
244
+ mockSocket._triggerOpen();
245
+
246
+ // Clear the init message that gets sent on reconnection
247
+ mockSocket.send.mockClear();
248
+
249
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
250
+
251
+ // Should only send the unacknowledged route message
252
+ expect(mockSocket.send).toHaveBeenCalledTimes(1);
253
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(routeMessage));
254
+ });
255
+
256
+ it('should handle observation acknowledgments', () => {
257
+ const observationMessage1: ObservationMessage = {
258
+ event: 'observations',
259
+ seq: 1,
260
+ observations: []
261
+ };
262
+
263
+ const observationMessage2: ObservationMessage = {
264
+ event: 'observations',
265
+ seq: 2,
266
+ observations: []
267
+ };
268
+
269
+ // Send messages
270
+ connection.sendMessage(observationMessage1);
271
+ connection.sendMessage(observationMessage2);
272
+
273
+ // Acknowledge first observation
274
+ mockSocket._triggerMessage(JSON.stringify({
275
+ type: 'ack-observations',
276
+ seq: 1
277
+ }));
278
+
279
+ // Clear previous calls before reconnection test
280
+ mockSocket.send.mockClear();
281
+
282
+ // Reconnect - only second observation should be replayed
283
+ mockSocket._triggerClose(1000, 'test close');
284
+ jest.advanceTimersByTime(1000);
285
+ mockSocket._triggerOpen();
286
+
287
+ // Clear the init message that gets sent on reconnection
288
+ mockSocket.send.mockClear();
289
+
290
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
291
+
292
+ // Should only send the unacknowledged second observation message
293
+ expect(mockSocket.send).toHaveBeenCalledTimes(1);
294
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage2));
295
+ });
296
+ });
297
+
298
+ describe('Reconnection Logic', () => {
299
+ beforeEach(() => {
300
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
301
+ mockSocket._triggerOpen();
302
+ });
303
+
304
+ it('should reconnect automatically on close', () => {
305
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
306
+
307
+ mockSocket._triggerClose(1000, 'Normal closure');
308
+
309
+ expect(logMessages).toContain('WebSocket disconnected. Code: 1000, Reason: Normal closure');
310
+
311
+ // Should schedule reconnection
312
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
313
+
314
+ // Advance timer to trigger reconnection
315
+ jest.advanceTimersByTime(1000);
316
+
317
+ // Should create new WebSocket connection
318
+ expect(MockedWebSocket).toHaveBeenCalledTimes(2);
319
+
320
+ setTimeoutSpy.mockRestore();
321
+ });
322
+
323
+ it('should reset authorization state on close', () => {
324
+ // First authorize
325
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
326
+
327
+ // Send a message (should send immediately)
328
+ const testMessage: NewObservedServiceMessage = {
329
+ event: 'new-entity',
330
+ type: 'service',
331
+ hash: 'test-hash',
332
+ name: 'test-service'
333
+ };
334
+ connection.sendMessage(testMessage);
335
+ expect(mockSocket.send).toHaveBeenCalled();
336
+
337
+ jest.clearAllMocks();
338
+
339
+ // Close connection
340
+ mockSocket._triggerClose(1000, 'test');
341
+
342
+ // Send another message - should queue (not send immediately)
343
+ connection.sendMessage(testMessage);
344
+ expect(mockSocket.send).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it('should not reconnect when explicitly closed', () => {
348
+ connection.close();
349
+ mockSocket._triggerClose(1000, 'Explicit close');
350
+
351
+ // Should not schedule reconnection
352
+ jest.advanceTimersByTime(5000);
353
+ expect(MockedWebSocket).toHaveBeenCalledTimes(1); // Only initial connection
354
+ });
355
+ });
356
+
357
+ describe('Error Handling', () => {
358
+ beforeEach(() => {
359
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
360
+ });
361
+
362
+ it('should log WebSocket errors', () => {
363
+ const testError = new Error('Connection failed');
364
+ mockSocket._triggerError(testError);
365
+
366
+ expect(logMessages).toContain('WebSocket encountered an error: Connection failed');
367
+ });
368
+
369
+ it('should handle malformed server messages gracefully', () => {
370
+ mockSocket._triggerOpen();
371
+ mockSocket._triggerMessage('{invalid json}');
372
+
373
+ // Check that an error message was logged (exact message varies by Node version)
374
+ const errorLogExists = logMessages.some(msg =>
375
+ msg.startsWith('Error parsing message from server:') &&
376
+ msg.includes('JSON')
377
+ );
378
+ expect(errorLogExists).toBe(true);
379
+ });
380
+
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
+ it('should not send messages when socket is not open', () => {
399
+ mockSocket.readyState = WebSocket.CLOSED;
400
+ mockSocket._triggerOpen();
401
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
402
+
403
+ // Clear the init and any previous messages
404
+ mockSocket.send.mockClear();
405
+
406
+ // Force socket to closed state
407
+ mockSocket.readyState = WebSocket.CLOSED;
408
+
409
+ const testMessage: NewObservedServiceMessage = {
410
+ event: 'new-entity',
411
+ type: 'service',
412
+ hash: 'test-hash',
413
+ name: 'test-service'
414
+ };
415
+
416
+ connection.sendMessage(testMessage);
417
+
418
+ // Should not call send when socket is closed
419
+ expect(mockSocket.send).not.toHaveBeenCalled();
420
+ });
421
+ });
422
+
423
+ describe('Connection Lifecycle', () => {
424
+ it('should close WebSocket connection properly', () => {
425
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
426
+
427
+ connection.close();
428
+
429
+ expect(mockSocket.close).toHaveBeenCalled();
430
+ });
431
+
432
+ it('should handle close when socket is null', () => {
433
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
434
+
435
+ // Simulate socket being null
436
+ (connection as any).socket = null;
437
+
438
+ expect(() => connection.close()).not.toThrow();
439
+ });
440
+ });
441
+
442
+ describe('Message Types and Queuing', () => {
443
+ beforeEach(() => {
444
+ connection = new WebSocketConnection('test-org', 'test-token', mockLogger);
445
+ mockSocket._triggerOpen();
446
+ });
447
+
448
+ it('should queue new-entity messages', () => {
449
+ // Clear the initial init message
450
+ mockSocket.send.mockClear();
451
+
452
+ const serviceMessage: NewObservedServiceMessage = {
453
+ event: 'new-entity',
454
+ type: 'service',
455
+ hash: 'service-hash',
456
+ name: 'test-service'
457
+ };
458
+
459
+ connection.sendMessage(serviceMessage);
460
+
461
+ // Should be queued, not sent immediately (not authorized)
462
+ expect(mockSocket.send).not.toHaveBeenCalled();
463
+
464
+ // Authorize and check replay
465
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
466
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
467
+ });
468
+
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'
479
+ };
480
+
481
+ connection.sendMessage(httpRequestMessage);
482
+
483
+ // Should be queued
484
+ expect(mockSocket.send).not.toHaveBeenCalled();
485
+
486
+ // Authorize and check replay
487
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
488
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(httpRequestMessage));
489
+ });
490
+
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]
506
+ };
507
+
508
+ connection.sendMessage(observationMessage);
509
+
510
+ // Should be queued
511
+ expect(mockSocket.send).not.toHaveBeenCalled();
512
+
513
+ // Authorize and check replay
514
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
515
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
516
+ });
517
+
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'
526
+ };
527
+
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));
533
+ });
534
+ });
535
+ });
@@ -580,4 +580,20 @@ describe('SQL Analyzer - bulk INSERT VALUES cardinality reduction', () => {
580
580
  expect(result.presentableQuery).toEqual(`INSERT INTO comments (text, author) VALUES
581
581
  (...)`);
582
582
  });
583
+
584
+ it('preserves whitespace before ON CONFLICT after VALUES clause', () => {
585
+ const sql = `INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com') ON CONFLICT (email) DO NOTHING`;
586
+ const result = analyzeSQL(sql);
587
+
588
+ expect(result.tableOperations).toEqual({ users: ['INSERT'] });
589
+ expect(result.presentableQuery).toEqual(`INSERT INTO users (name, email) VALUES (...) ON CONFLICT (email) DO NOTHING`);
590
+ });
591
+
592
+ it('preserves whitespace before ON CONFLICT with multiple VALUES tuples', () => {
593
+ const sql = `INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`;
594
+ const result = analyzeSQL(sql);
595
+
596
+ expect(result.tableOperations).toEqual({ users: ['INSERT'] });
597
+ expect(result.presentableQuery).toEqual(`INSERT INTO users (name, email) VALUES (...) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`);
598
+ });
583
599
  });
@@ -28,6 +28,7 @@ export function analyzeSQL(sql: string): SQLAnalysisResult {
28
28
  let skippingValues = false;
29
29
  let lookingForCommaOrEnd = false;
30
30
  let valuesDepth = 0;
31
+ let skippedWhitespace: Token[] = [];
31
32
  for (let token of tokenizeSQL(sql)) {
32
33
  switch (token.type) {
33
34
  case "whitespace":
@@ -140,22 +141,30 @@ export function analyzeSQL(sql: string): SQLAnalysisResult {
140
141
  switch (token.type) {
141
142
  case "comment":
142
143
  case "whitespace":
143
- // Skip whitespace/comments while looking for comma or end
144
+ // Collect whitespace/comments while looking for comma or end
145
+ skippedWhitespace.push(token);
144
146
  break;
145
147
  case "punct":
146
148
  if (token.value === ",") {
147
- // More tuples coming, continue skipping
149
+ // More tuples coming, clear skipped whitespace and continue skipping
150
+ skippedWhitespace = [];
148
151
  lookingForCommaOrEnd = false;
149
152
  skippingValues = true;
150
153
  } else {
151
154
  // Not a comma, so VALUES clause is done
155
+ // Add back the skipped whitespace, then the current token
156
+ presentableTokens.push(...skippedWhitespace);
152
157
  presentableTokens.push(token);
158
+ skippedWhitespace = [];
153
159
  lookingForCommaOrEnd = false;
154
160
  }
155
161
  break;
156
162
  default:
157
163
  // VALUES clause is done, resume normal processing
164
+ // Add back the skipped whitespace, then the current token
165
+ presentableTokens.push(...skippedWhitespace);
158
166
  presentableTokens.push(token);
167
+ skippedWhitespace = [];
159
168
  lookingForCommaOrEnd = false;
160
169
  break;
161
170
  }