@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.
@@ -0,0 +1,406 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const WebSocketConnection_1 = require("./WebSocketConnection");
7
+ // Mock the ws WebSocket library
8
+ jest.mock('ws');
9
+ const ws_1 = __importDefault(require("ws"));
10
+ const MockedWebSocket = ws_1.default;
11
+ describe('WebSocketConnection', () => {
12
+ let mockSocket;
13
+ let connection;
14
+ let logMessages;
15
+ let mockLogger;
16
+ beforeEach(() => {
17
+ // Reset all mocks
18
+ jest.clearAllMocks();
19
+ jest.clearAllTimers();
20
+ jest.useFakeTimers();
21
+ logMessages = [];
22
+ mockLogger = jest.fn((message) => {
23
+ logMessages.push(message);
24
+ });
25
+ // Create mock WebSocket instance with event handling
26
+ mockSocket = {
27
+ on: jest.fn(),
28
+ send: jest.fn(),
29
+ close: jest.fn(),
30
+ readyState: ws_1.default.CLOSED,
31
+ OPEN: ws_1.default.OPEN,
32
+ CLOSED: ws_1.default.CLOSED,
33
+ _triggerOpen: function () { },
34
+ _triggerMessage: function (data) { },
35
+ _triggerClose: function (code, reason) { },
36
+ _triggerError: function (error) { }
37
+ };
38
+ // Set up event handler storage and triggering
39
+ const eventHandlers = {};
40
+ mockSocket.on.mockImplementation((event, handler) => {
41
+ eventHandlers[event] = handler;
42
+ });
43
+ mockSocket._triggerOpen = () => {
44
+ mockSocket.readyState = ws_1.default.OPEN;
45
+ eventHandlers['open']?.();
46
+ };
47
+ mockSocket._triggerMessage = (data) => {
48
+ const buffer = Buffer.from(data);
49
+ eventHandlers['message']?.(buffer);
50
+ };
51
+ mockSocket._triggerClose = (code, reason) => {
52
+ mockSocket.readyState = ws_1.default.CLOSED;
53
+ eventHandlers['close']?.(code, Buffer.from(reason));
54
+ };
55
+ mockSocket._triggerError = (error) => {
56
+ eventHandlers['error']?.(error);
57
+ };
58
+ // Mock WebSocket constructor to return our mock instance
59
+ MockedWebSocket.mockImplementation(() => mockSocket);
60
+ });
61
+ afterEach(() => {
62
+ jest.useRealTimers();
63
+ if (connection) {
64
+ connection.close();
65
+ }
66
+ });
67
+ describe('Connection Establishment', () => {
68
+ it('should create WebSocket connection with correct URL', () => {
69
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
70
+ expect(MockedWebSocket).toHaveBeenCalledWith('wss://ingestion.comprehend.dev/test-org/observations');
71
+ expect(mockSocket.on).toHaveBeenCalledWith('open', expect.any(Function));
72
+ expect(mockSocket.on).toHaveBeenCalledWith('message', expect.any(Function));
73
+ expect(mockSocket.on).toHaveBeenCalledWith('close', expect.any(Function));
74
+ expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
75
+ });
76
+ it('should send init message on connection open', () => {
77
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
78
+ mockSocket._triggerOpen();
79
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify({
80
+ event: 'init',
81
+ protocolVersion: 1,
82
+ token: 'test-token'
83
+ }));
84
+ expect(logMessages).toContain('WebSocket connected. Sending init/auth message.');
85
+ });
86
+ it('should work without logger', () => {
87
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token');
88
+ mockSocket._triggerOpen();
89
+ expect(MockedWebSocket).toHaveBeenCalled();
90
+ expect(mockSocket.send).toHaveBeenCalled();
91
+ });
92
+ });
93
+ describe('Authorization Flow', () => {
94
+ beforeEach(() => {
95
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
96
+ mockSocket._triggerOpen();
97
+ jest.clearAllMocks(); // Clear the init message send
98
+ });
99
+ it('should handle authorization acknowledgment', () => {
100
+ const authAck = {
101
+ type: 'ack-authorized'
102
+ };
103
+ mockSocket._triggerMessage(JSON.stringify(authAck));
104
+ expect(logMessages).toContain('Authorization acknowledged by server.');
105
+ });
106
+ it('should replay queued messages after authorization', () => {
107
+ // Queue some messages before authorization
108
+ const serviceMessage = {
109
+ event: 'new-entity',
110
+ type: 'service',
111
+ hash: 'test-hash-1',
112
+ name: 'test-service'
113
+ };
114
+ const observationMessage = {
115
+ event: 'observations',
116
+ seq: 1,
117
+ observations: [{
118
+ type: 'http-server',
119
+ subject: 'test-subject',
120
+ timestamp: [1700000000, 0],
121
+ path: '/test',
122
+ status: 200,
123
+ duration: [0, 100000000]
124
+ }]
125
+ };
126
+ connection.sendMessage(serviceMessage);
127
+ connection.sendMessage(observationMessage);
128
+ // Should not send immediately (not authorized yet)
129
+ expect(mockSocket.send).not.toHaveBeenCalled();
130
+ // Now authorize
131
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
132
+ // Should replay both messages
133
+ expect(mockSocket.send).toHaveBeenCalledTimes(2);
134
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
135
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
136
+ });
137
+ it('should send messages immediately when already authorized', () => {
138
+ // First authorize
139
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
140
+ jest.clearAllMocks();
141
+ const serviceMessage = {
142
+ event: 'new-entity',
143
+ type: 'service',
144
+ hash: 'test-hash',
145
+ name: 'test-service'
146
+ };
147
+ connection.sendMessage(serviceMessage);
148
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
149
+ });
150
+ });
151
+ describe('Message Acknowledgment Handling', () => {
152
+ beforeEach(() => {
153
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
154
+ mockSocket._triggerOpen();
155
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
156
+ jest.clearAllMocks();
157
+ });
158
+ it('should handle entity/interaction acknowledgments', () => {
159
+ const serviceMessage = {
160
+ event: 'new-entity',
161
+ type: 'service',
162
+ hash: 'service-hash',
163
+ name: 'test-service'
164
+ };
165
+ const routeMessage = {
166
+ event: 'new-entity',
167
+ type: 'http-route',
168
+ hash: 'route-hash',
169
+ parent: 'service-hash',
170
+ method: 'GET',
171
+ route: '/test'
172
+ };
173
+ // Send messages
174
+ connection.sendMessage(serviceMessage);
175
+ connection.sendMessage(routeMessage);
176
+ // Acknowledge the service message
177
+ mockSocket._triggerMessage(JSON.stringify({
178
+ type: 'ack-observed',
179
+ hash: 'service-hash'
180
+ }));
181
+ // Clear previous calls before reconnection test
182
+ mockSocket.send.mockClear();
183
+ // If we reconnect, only the route message should be replayed
184
+ mockSocket._triggerClose(1000, 'test close');
185
+ jest.advanceTimersByTime(1000);
186
+ mockSocket._triggerOpen();
187
+ // Clear the init message that gets sent on reconnection
188
+ mockSocket.send.mockClear();
189
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
190
+ // Should only send the unacknowledged route message
191
+ expect(mockSocket.send).toHaveBeenCalledTimes(1);
192
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(routeMessage));
193
+ });
194
+ it('should handle observation acknowledgments', () => {
195
+ const observationMessage1 = {
196
+ event: 'observations',
197
+ seq: 1,
198
+ observations: []
199
+ };
200
+ const observationMessage2 = {
201
+ event: 'observations',
202
+ seq: 2,
203
+ observations: []
204
+ };
205
+ // Send messages
206
+ connection.sendMessage(observationMessage1);
207
+ connection.sendMessage(observationMessage2);
208
+ // Acknowledge first observation
209
+ mockSocket._triggerMessage(JSON.stringify({
210
+ type: 'ack-observations',
211
+ seq: 1
212
+ }));
213
+ // Clear previous calls before reconnection test
214
+ mockSocket.send.mockClear();
215
+ // Reconnect - only second observation should be replayed
216
+ mockSocket._triggerClose(1000, 'test close');
217
+ jest.advanceTimersByTime(1000);
218
+ mockSocket._triggerOpen();
219
+ // Clear the init message that gets sent on reconnection
220
+ mockSocket.send.mockClear();
221
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
222
+ // Should only send the unacknowledged second observation message
223
+ expect(mockSocket.send).toHaveBeenCalledTimes(1);
224
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage2));
225
+ });
226
+ });
227
+ describe('Reconnection Logic', () => {
228
+ beforeEach(() => {
229
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
230
+ mockSocket._triggerOpen();
231
+ });
232
+ it('should reconnect automatically on close', () => {
233
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
234
+ mockSocket._triggerClose(1000, 'Normal closure');
235
+ expect(logMessages).toContain('WebSocket disconnected. Code: 1000, Reason: Normal closure');
236
+ // Should schedule reconnection
237
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000);
238
+ // Advance timer to trigger reconnection
239
+ jest.advanceTimersByTime(1000);
240
+ // Should create new WebSocket connection
241
+ expect(MockedWebSocket).toHaveBeenCalledTimes(2);
242
+ setTimeoutSpy.mockRestore();
243
+ });
244
+ it('should reset authorization state on close', () => {
245
+ // First authorize
246
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
247
+ // Send a message (should send immediately)
248
+ const testMessage = {
249
+ event: 'new-entity',
250
+ type: 'service',
251
+ hash: 'test-hash',
252
+ name: 'test-service'
253
+ };
254
+ connection.sendMessage(testMessage);
255
+ expect(mockSocket.send).toHaveBeenCalled();
256
+ jest.clearAllMocks();
257
+ // Close connection
258
+ mockSocket._triggerClose(1000, 'test');
259
+ // Send another message - should queue (not send immediately)
260
+ connection.sendMessage(testMessage);
261
+ expect(mockSocket.send).not.toHaveBeenCalled();
262
+ });
263
+ it('should not reconnect when explicitly closed', () => {
264
+ connection.close();
265
+ mockSocket._triggerClose(1000, 'Explicit close');
266
+ // Should not schedule reconnection
267
+ jest.advanceTimersByTime(5000);
268
+ expect(MockedWebSocket).toHaveBeenCalledTimes(1); // Only initial connection
269
+ });
270
+ });
271
+ describe('Error Handling', () => {
272
+ beforeEach(() => {
273
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
274
+ });
275
+ it('should log WebSocket errors', () => {
276
+ const testError = new Error('Connection failed');
277
+ mockSocket._triggerError(testError);
278
+ expect(logMessages).toContain('WebSocket encountered an error: Connection failed');
279
+ });
280
+ it('should handle malformed server messages gracefully', () => {
281
+ mockSocket._triggerOpen();
282
+ mockSocket._triggerMessage('{invalid json}');
283
+ // Check that an error message was logged (exact message varies by Node version)
284
+ const errorLogExists = logMessages.some(msg => msg.startsWith('Error parsing message from server:') &&
285
+ msg.includes('JSON'));
286
+ expect(errorLogExists).toBe(true);
287
+ });
288
+ it('should handle non-string error objects in message parsing', () => {
289
+ mockSocket._triggerOpen();
290
+ // Mock JSON.parse to throw a non-Error object
291
+ const originalParse = JSON.parse;
292
+ JSON.parse = jest.fn().mockImplementation(() => {
293
+ throw 'string error';
294
+ });
295
+ mockSocket._triggerMessage('{}');
296
+ expect(logMessages).toContain('Error parsing message from server: string error');
297
+ // Restore original JSON.parse
298
+ JSON.parse = originalParse;
299
+ });
300
+ it('should not send messages when socket is not open', () => {
301
+ mockSocket.readyState = ws_1.default.CLOSED;
302
+ mockSocket._triggerOpen();
303
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
304
+ // Clear the init and any previous messages
305
+ mockSocket.send.mockClear();
306
+ // Force socket to closed state
307
+ mockSocket.readyState = ws_1.default.CLOSED;
308
+ const testMessage = {
309
+ event: 'new-entity',
310
+ type: 'service',
311
+ hash: 'test-hash',
312
+ name: 'test-service'
313
+ };
314
+ connection.sendMessage(testMessage);
315
+ // Should not call send when socket is closed
316
+ expect(mockSocket.send).not.toHaveBeenCalled();
317
+ });
318
+ });
319
+ describe('Connection Lifecycle', () => {
320
+ it('should close WebSocket connection properly', () => {
321
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
322
+ connection.close();
323
+ expect(mockSocket.close).toHaveBeenCalled();
324
+ });
325
+ it('should handle close when socket is null', () => {
326
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
327
+ // Simulate socket being null
328
+ connection.socket = null;
329
+ expect(() => connection.close()).not.toThrow();
330
+ });
331
+ });
332
+ describe('Message Types and Queuing', () => {
333
+ beforeEach(() => {
334
+ connection = new WebSocketConnection_1.WebSocketConnection('test-org', 'test-token', mockLogger);
335
+ mockSocket._triggerOpen();
336
+ });
337
+ it('should queue new-entity messages', () => {
338
+ // Clear the initial init message
339
+ mockSocket.send.mockClear();
340
+ const serviceMessage = {
341
+ event: 'new-entity',
342
+ type: 'service',
343
+ hash: 'service-hash',
344
+ name: 'test-service'
345
+ };
346
+ connection.sendMessage(serviceMessage);
347
+ // Should be queued, not sent immediately (not authorized)
348
+ expect(mockSocket.send).not.toHaveBeenCalled();
349
+ // Authorize and check replay
350
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
351
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(serviceMessage));
352
+ });
353
+ it('should queue new-interaction messages', () => {
354
+ // Clear the initial init message
355
+ mockSocket.send.mockClear();
356
+ const httpRequestMessage = {
357
+ event: 'new-interaction',
358
+ type: 'http-request',
359
+ hash: 'request-hash',
360
+ from: 'service-hash',
361
+ to: 'http-service-hash'
362
+ };
363
+ connection.sendMessage(httpRequestMessage);
364
+ // Should be queued
365
+ expect(mockSocket.send).not.toHaveBeenCalled();
366
+ // Authorize and check replay
367
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
368
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(httpRequestMessage));
369
+ });
370
+ it('should queue observation messages', () => {
371
+ // Clear the initial init message
372
+ mockSocket.send.mockClear();
373
+ const observationMessage = {
374
+ event: 'observations',
375
+ seq: 1,
376
+ observations: [{
377
+ type: 'http-server',
378
+ subject: 'route-hash',
379
+ timestamp: [1700000000, 0],
380
+ path: '/test',
381
+ status: 200,
382
+ duration: [0, 50000000]
383
+ }]
384
+ };
385
+ connection.sendMessage(observationMessage);
386
+ // Should be queued
387
+ expect(mockSocket.send).not.toHaveBeenCalled();
388
+ // Authorize and check replay
389
+ mockSocket._triggerMessage(JSON.stringify({ type: 'ack-authorized' }));
390
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(observationMessage));
391
+ });
392
+ it('should not queue init messages in unacknowledged maps', () => {
393
+ // This is implicit - init messages are sent via sendRaw, not sendMessage
394
+ // and don't go through the queuing logic
395
+ const initMessage = {
396
+ event: 'init',
397
+ protocolVersion: 1,
398
+ token: 'test-token'
399
+ };
400
+ // Manually test sendRaw behavior (private method testing)
401
+ mockSocket.readyState = ws_1.default.OPEN;
402
+ connection.sendRaw(initMessage);
403
+ expect(mockSocket.send).toHaveBeenCalledWith(JSON.stringify(initMessage));
404
+ });
405
+ });
406
+ });
@@ -23,6 +23,7 @@ function analyzeSQL(sql) {
23
23
  let skippingValues = false;
24
24
  let lookingForCommaOrEnd = false;
25
25
  let valuesDepth = 0;
26
+ let skippedWhitespace = [];
26
27
  for (let token of tokenizeSQL(sql)) {
27
28
  switch (token.type) {
28
29
  case "whitespace":
@@ -136,23 +137,31 @@ function analyzeSQL(sql) {
136
137
  switch (token.type) {
137
138
  case "comment":
138
139
  case "whitespace":
139
- // Skip whitespace/comments while looking for comma or end
140
+ // Collect whitespace/comments while looking for comma or end
141
+ skippedWhitespace.push(token);
140
142
  break;
141
143
  case "punct":
142
144
  if (token.value === ",") {
143
- // More tuples coming, continue skipping
145
+ // More tuples coming, clear skipped whitespace and continue skipping
146
+ skippedWhitespace = [];
144
147
  lookingForCommaOrEnd = false;
145
148
  skippingValues = true;
146
149
  }
147
150
  else {
148
151
  // Not a comma, so VALUES clause is done
152
+ // Add back the skipped whitespace, then the current token
153
+ presentableTokens.push(...skippedWhitespace);
149
154
  presentableTokens.push(token);
155
+ skippedWhitespace = [];
150
156
  lookingForCommaOrEnd = false;
151
157
  }
152
158
  break;
153
159
  default:
154
160
  // VALUES clause is done, resume normal processing
161
+ // Add back the skipped whitespace, then the current token
162
+ presentableTokens.push(...skippedWhitespace);
155
163
  presentableTokens.push(token);
164
+ skippedWhitespace = [];
156
165
  lookingForCommaOrEnd = false;
157
166
  break;
158
167
  }
@@ -482,4 +482,16 @@ describe('SQL Analyzer - bulk INSERT VALUES cardinality reduction', () => {
482
482
  expect(result.presentableQuery).toEqual(`INSERT INTO comments (text, author) VALUES
483
483
  (...)`);
484
484
  });
485
+ it('preserves whitespace before ON CONFLICT after VALUES clause', () => {
486
+ const sql = `INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com') ON CONFLICT (email) DO NOTHING`;
487
+ const result = (0, sql_analyzer_1.analyzeSQL)(sql);
488
+ expect(result.tableOperations).toEqual({ users: ['INSERT'] });
489
+ expect(result.presentableQuery).toEqual(`INSERT INTO users (name, email) VALUES (...) ON CONFLICT (email) DO NOTHING`);
490
+ });
491
+ it('preserves whitespace before ON CONFLICT with multiple VALUES tuples', () => {
492
+ 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`;
493
+ const result = (0, sql_analyzer_1.analyzeSQL)(sql);
494
+ expect(result.tableOperations).toEqual({ users: ['INSERT'] });
495
+ expect(result.presentableQuery).toEqual(`INSERT INTO users (name, email) VALUES (...) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`);
496
+ });
485
497
  });
package/jest.config.js CHANGED
@@ -8,4 +8,5 @@ module.exports = {
8
8
  transform: {
9
9
  ...tsJestTransformCfg,
10
10
  },
11
+ testPathIgnorePatterns: ["/node_modules/", "/dist/"],
11
12
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comprehend/telemetry-node",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Integration of comprehend.dev with OpenTelemetry in Node.js and similar environemnts.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,7 @@
10
10
  "test": "jest"
11
11
  },
12
12
  "keywords": [],
13
- "author": "Comprehend AB",
13
+ "author": "Comprehend.dev AB",
14
14
  "license": "LicenseRef-Proprietary-Audit",
15
15
  "private": false,
16
16
  "dependencies": {