@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
@@ -1,22 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ComprehendDevSpanProcessor = void 0;
4
+ exports.matchSpanRule = matchSpanRule;
4
5
  const sha2_1 = require("@noble/hashes/sha2");
5
6
  const utils_1 = require("@noble/hashes/utils");
6
- const sql_analyzer_1 = require("./sql-analyzer");
7
- const WebSocketConnection_1 = require("./WebSocketConnection");
7
+ const api_1 = require("@opentelemetry/api");
8
8
  const sqlDbSystems = new Set([
9
9
  'mysql', 'postgresql', 'mssql', 'oracle', 'db2', 'sqlite', 'hsqldb', 'h2',
10
10
  'informix', 'cockroachdb', 'redshift', 'tidb', 'trino', 'greenplum'
11
11
  ]);
12
12
  class ComprehendDevSpanProcessor {
13
- constructor(options) {
13
+ constructor(connection) {
14
14
  this.observedServices = [];
15
15
  this.observedDatabases = [];
16
16
  this.observedHttpServices = [];
17
17
  this.observedInteractions = new Map();
18
- this.observationsSeq = 1;
19
- this.connection = new WebSocketConnection_1.WebSocketConnection(options.organization, options.token, options.debug === true ? console.log : options.debug === false ? undefined : options.debug);
18
+ this.processContextSet = false;
19
+ this.spanObservationSpecs = [];
20
+ this.connection = connection;
21
+ }
22
+ updateCustomMetrics(specs) {
23
+ this.spanObservationSpecs = specs.filter((s) => s.type === 'span');
20
24
  }
21
25
  onStart(span, parentContext) {
22
26
  }
@@ -25,14 +29,12 @@ class ComprehendDevSpanProcessor {
25
29
  if (!currentService)
26
30
  return;
27
31
  const attrs = span.attributes;
28
- if (span.kind === 1) {
29
- // Server span, see if it's something to ingest.
32
+ if (span.kind === 1) { // Server span
30
33
  if (attrs['http.route'] && attrs['http.method']) {
31
34
  this.processHTTPRoute(currentService, attrs['http.route'], attrs['http.method'], span);
32
35
  }
33
36
  }
34
- else if (span.kind === 2) {
35
- // Client span, see if it's something to ingest.
37
+ else if (span.kind === 2) { // Client span
36
38
  if (attrs['http.url']) {
37
39
  this.processHttpRequest(currentService, attrs['http.url'], span);
38
40
  }
@@ -40,9 +42,12 @@ class ComprehendDevSpanProcessor {
40
42
  if (attrs['db.system']) {
41
43
  this.processDatabaseOperation(currentService, span);
42
44
  }
45
+ // Report trace span for every span
46
+ this.reportTraceSpan(span);
47
+ // Check custom span observation matching
48
+ this.checkCustomSpanMatching(span);
43
49
  }
44
50
  discoverService(span) {
45
- // Look for an existing matching entry.
46
51
  const resAttrs = span.resource.attributes;
47
52
  const name = resAttrs['service.name'];
48
53
  if (!name)
@@ -54,7 +59,6 @@ class ComprehendDevSpanProcessor {
54
59
  s.environment === environment);
55
60
  if (existing)
56
61
  return existing;
57
- // New; hash it and add it to the observed services.
58
62
  const idString = `service:${name}:${namespace ?? ''}:${environment ?? ''}`;
59
63
  const hash = hashIdString(idString);
60
64
  const newService = {
@@ -62,7 +66,6 @@ class ComprehendDevSpanProcessor {
62
66
  httpRoutes: []
63
67
  };
64
68
  this.observedServices.push(newService);
65
- // Ingest its existence.
66
69
  const message = {
67
70
  event: "new-entity",
68
71
  type: "service",
@@ -72,17 +75,21 @@ class ComprehendDevSpanProcessor {
72
75
  ...(environment ? { environment } : {})
73
76
  };
74
77
  this.ingestMessage(message);
78
+ // Set process context on first service discovery
79
+ if (!this.processContextSet) {
80
+ this.processContextSet = true;
81
+ const resources = flattenResourceAttributes(resAttrs);
82
+ this.connection.setProcessContext(hash, resources);
83
+ }
84
+ return newService;
75
85
  }
76
86
  processHTTPRoute(service, route, method, span) {
77
- // Check if this route+method already exists under the service
78
87
  let observedRoute = service.httpRoutes.find(r => r.route === route && r.method === method);
79
88
  if (!observedRoute) {
80
- // It's new; hash it and add it to our collection.
81
89
  const idString = `http-route:${service.hash}:${method}:${route}`;
82
90
  const hash = hashIdString(idString);
83
91
  observedRoute = { method, route, hash };
84
92
  service.httpRoutes.push(observedRoute);
85
- // Emit observation message
86
93
  const message = {
87
94
  event: "new-entity",
88
95
  type: "http-route",
@@ -92,14 +99,13 @@ class ComprehendDevSpanProcessor {
92
99
  };
93
100
  this.ingestMessage(message);
94
101
  }
95
- // Extract the request path, making sure we only get that, without any query string.
102
+ // Extract the request path, making sure we strip any query string.
96
103
  const attrs = span.attributes;
97
104
  let path;
98
105
  if (attrs['http.target']) {
99
106
  try {
100
- // This might be just a path like "/search?q=foo"
101
107
  const rawTarget = attrs['http.target'];
102
- const fakeUrl = new URL(rawTarget, 'http://placeholder'); // placeholder base
108
+ const fakeUrl = new URL(rawTarget, 'http://placeholder');
103
109
  path = fakeUrl.pathname;
104
110
  }
105
111
  catch {
@@ -119,7 +125,6 @@ class ComprehendDevSpanProcessor {
119
125
  else {
120
126
  path = '/';
121
127
  }
122
- // Build and ingest observation.
123
128
  const status = attrs['http.status_code'] ?? 0;
124
129
  const duration = span.duration;
125
130
  const httpVersion = attrs['http.flavor'];
@@ -127,9 +132,12 @@ class ComprehendDevSpanProcessor {
127
132
  const requestBytes = attrs['http.request_content_length'];
128
133
  const responseBytes = attrs['http.response_content_length'];
129
134
  const { message: errorMessage, type: errorType, stack: stack } = extractErrorInfo(span);
135
+ const { traceId, spanId } = getSpanContext(span);
130
136
  const observation = {
131
137
  type: 'http-server',
132
138
  subject: observedRoute.hash,
139
+ spanId,
140
+ traceId,
133
141
  timestamp: span.startTime,
134
142
  path,
135
143
  status,
@@ -144,12 +152,11 @@ class ComprehendDevSpanProcessor {
144
152
  };
145
153
  this.ingestMessage({
146
154
  event: "observations",
147
- seq: this.observationsSeq++,
155
+ seq: this.connection.nextSeq(),
148
156
  observations: [observation]
149
157
  });
150
158
  }
151
159
  processDatabaseOperation(currentService, span) {
152
- // Parse the connection string.
153
160
  const attrs = span.attributes;
154
161
  const system = attrs['db.system'];
155
162
  const rawConnection = attrs['db.connection_string'];
@@ -162,10 +169,8 @@ class ComprehendDevSpanProcessor {
162
169
  port: attrs['net.peer.port']?.toString(),
163
170
  name: attrs['db.name'],
164
171
  };
165
- // See if we already have an entry for this database.
166
172
  const hash = hashIdString(`database:${system}:${parsed.host ?? ''}:${parsed.port ?? ''}:${parsed.name ?? ''}`);
167
173
  let observedDatabase = this.observedDatabases.find(db => db.hash === hash);
168
- // If we see it for the first time, add and ingest it.
169
174
  if (!observedDatabase) {
170
175
  observedDatabase = {
171
176
  system,
@@ -175,7 +180,6 @@ class ComprehendDevSpanProcessor {
175
180
  hash
176
181
  };
177
182
  this.observedDatabases.push(observedDatabase);
178
- // The existence of the database.
179
183
  const message = {
180
184
  event: "new-entity",
181
185
  type: "database",
@@ -189,7 +193,6 @@ class ComprehendDevSpanProcessor {
189
193
  };
190
194
  this.ingestMessage(message);
191
195
  }
192
- // The connection to the database should have an interaction.
193
196
  const interactions = this.getInteractions(currentService.hash, observedDatabase.hash);
194
197
  let connectionInteraction = interactions.dbConnections.find(c => c.connection === parsed.scrubbed && c.user === parsed.user);
195
198
  if (!connectionInteraction) {
@@ -210,71 +213,42 @@ class ComprehendDevSpanProcessor {
210
213
  };
211
214
  this.ingestMessage(message);
212
215
  }
213
- // The query of the database from the service (only for SQL for now).
216
+ // For SQL databases, send raw query to server for analysis
214
217
  if (sqlDbSystems.has(system) && attrs['db.statement']) {
215
- // The interaction, based upon the normalized query.
216
- let queryInfo = (0, sql_analyzer_1.analyzeSQL)(attrs['db.statement']);
217
- let queryInteraction = interactions.dbQueries.find(q => q.query === queryInfo.normalizedQuery);
218
- if (!queryInteraction) {
219
- queryInteraction = {
220
- hash: hashIdString(`db-query:${currentService.hash}:${observedDatabase.hash}:${queryInfo.normalizedQuery}`),
221
- query: queryInfo.normalizedQuery
222
- };
223
- interactions.dbQueries.push(queryInteraction);
224
- const selects = getTablesWithOperation(queryInfo.tableOperations, 'SELECT');
225
- const inserts = getTablesWithOperation(queryInfo.tableOperations, 'INSERT');
226
- const updates = getTablesWithOperation(queryInfo.tableOperations, 'UPDATE');
227
- const deletes = getTablesWithOperation(queryInfo.tableOperations, 'DELETE');
228
- const message = {
229
- event: "new-interaction",
230
- type: "db-query",
231
- hash: queryInteraction.hash,
232
- from: currentService.hash,
233
- to: observedDatabase.hash,
234
- query: queryInfo.presentableQuery,
235
- ...(selects ? { selects } : {}),
236
- ...(inserts ? { inserts } : {}),
237
- ...(updates ? { updates } : {}),
238
- ...(deletes ? { deletes } : {}),
239
- };
240
- this.ingestMessage(message);
241
- }
242
- // Build and ingest observation.
243
218
  const duration = span.duration;
244
219
  const returnedRows = attrs['db.response.returned_rows']
245
220
  ?? attrs['db.sql.rows'];
246
221
  const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
247
- const observation = {
248
- type: "db-query",
249
- subject: queryInteraction.hash,
222
+ const { traceId, spanId } = span.spanContext();
223
+ const dbQueryMessage = {
224
+ event: "db-query",
225
+ seq: this.connection.nextSeq(),
226
+ query: attrs['db.statement'],
227
+ from: currentService.hash,
228
+ to: observedDatabase.hash,
250
229
  timestamp: span.startTime,
251
230
  duration,
231
+ traceId,
232
+ spanId,
252
233
  ...(errorMessage ? { errorMessage } : {}),
253
234
  ...(errorType ? { errorType } : {}),
254
235
  ...(stack ? { stack } : {}),
255
236
  ...(returnedRows !== undefined ? { returnedRows } : {})
256
237
  };
257
- this.ingestMessage({
258
- event: "observations",
259
- seq: this.observationsSeq++,
260
- observations: [observation]
261
- });
238
+ this.ingestMessage(dbQueryMessage);
262
239
  }
263
240
  }
264
241
  processHttpRequest(currentService, url, span) {
265
- // Build identity based upon protocol, host, and port.
266
242
  const parsed = new URL(url);
267
- const protocol = parsed.protocol.replace(':', ''); // Remove trailing colon
243
+ const protocol = parsed.protocol.replace(':', '');
268
244
  const host = parsed.hostname;
269
245
  const port = parsed.port ? parseInt(parsed.port) : (protocol === 'https' ? 443 : 80);
270
246
  const idString = `http-service:${protocol}:${host}:${port}`;
271
247
  const hash = hashIdString(idString);
272
- // Ingest it if it's not already observed.
273
248
  let observedHttpService = this.observedHttpServices.find(s => s.protocol === protocol && s.host === host && s.port === port);
274
249
  if (!observedHttpService) {
275
250
  observedHttpService = { protocol, host, port, hash };
276
251
  this.observedHttpServices.push(observedHttpService);
277
- // The existence of the service.
278
252
  const message = {
279
253
  event: "new-entity",
280
254
  type: "http-service",
@@ -285,7 +259,6 @@ class ComprehendDevSpanProcessor {
285
259
  };
286
260
  this.ingestMessage(message);
287
261
  }
288
- // Ingest the interaction if first observed.
289
262
  const interactions = this.getInteractions(currentService.hash, observedHttpService.hash);
290
263
  if (!interactions.httpRequest) {
291
264
  const idString = `http-request:${currentService.hash}:${observedHttpService.hash}`;
@@ -300,11 +273,10 @@ class ComprehendDevSpanProcessor {
300
273
  };
301
274
  this.ingestMessage(message);
302
275
  }
303
- // Build and ingest observation.
304
276
  const attrs = span.attributes;
305
277
  const path = parsed.pathname || '/';
306
278
  const method = span.attributes['http.method'];
307
- if (!method) // Really should always be there
279
+ if (!method)
308
280
  return;
309
281
  const status = attrs['http.status_code'];
310
282
  const duration = span.duration;
@@ -312,9 +284,12 @@ class ComprehendDevSpanProcessor {
312
284
  const requestBytes = attrs['http.request_content_length'];
313
285
  const responseBytes = attrs['http.response_content_length'];
314
286
  const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
287
+ const { traceId, spanId } = getSpanContext(span);
315
288
  const observation = {
316
289
  type: "http-client",
317
290
  subject: interactions.httpRequest.hash,
291
+ spanId,
292
+ traceId,
318
293
  timestamp: span.startTime,
319
294
  path,
320
295
  method,
@@ -329,10 +304,57 @@ class ComprehendDevSpanProcessor {
329
304
  };
330
305
  this.ingestMessage({
331
306
  event: "observations",
332
- seq: this.observationsSeq++,
307
+ seq: this.connection.nextSeq(),
333
308
  observations: [observation]
334
309
  });
335
310
  }
311
+ reportTraceSpan(span) {
312
+ const { traceId, spanId } = getSpanContext(span);
313
+ const parent = getParentSpanId(span);
314
+ const traceSpanMessage = {
315
+ event: "tracespans",
316
+ seq: this.connection.nextSeq(),
317
+ data: [{
318
+ trace: traceId,
319
+ span: spanId,
320
+ parent,
321
+ name: span.name,
322
+ timestamp: span.startTime,
323
+ }]
324
+ };
325
+ this.ingestMessage(traceSpanMessage);
326
+ }
327
+ checkCustomSpanMatching(span) {
328
+ for (const spec of this.spanObservationSpecs) {
329
+ if (matchSpanRule(span, spec.rule)) {
330
+ const { traceId, spanId } = getSpanContext(span);
331
+ const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
332
+ const collectedAttrs = {};
333
+ for (const [key, value] of Object.entries(span.attributes)) {
334
+ if (value !== undefined) {
335
+ collectedAttrs[key] = value;
336
+ }
337
+ }
338
+ const observation = {
339
+ type: "custom",
340
+ subject: spec.subject,
341
+ id: spec.subject,
342
+ spanId,
343
+ traceId,
344
+ timestamp: span.startTime,
345
+ attributes: collectedAttrs,
346
+ ...(errorMessage ? { errorMessage } : {}),
347
+ ...(errorType ? { errorType } : {}),
348
+ ...(stack ? { stack } : {}),
349
+ };
350
+ this.ingestMessage({
351
+ event: "observations",
352
+ seq: this.connection.nextSeq(),
353
+ observations: [observation]
354
+ });
355
+ }
356
+ }
357
+ }
336
358
  getInteractions(from, to) {
337
359
  let fromMap = this.observedInteractions.get(from);
338
360
  if (!fromMap) {
@@ -341,7 +363,7 @@ class ComprehendDevSpanProcessor {
341
363
  }
342
364
  let interactions = fromMap.get(to);
343
365
  if (!interactions) {
344
- interactions = { httpRequest: undefined, dbConnections: [], dbQueries: [] };
366
+ interactions = { httpRequest: undefined, dbConnections: [] };
345
367
  fromMap.set(to, interactions);
346
368
  }
347
369
  return interactions;
@@ -352,7 +374,6 @@ class ComprehendDevSpanProcessor {
352
374
  async forceFlush() {
353
375
  }
354
376
  async shutdown() {
355
- this.connection.close();
356
377
  }
357
378
  }
358
379
  exports.ComprehendDevSpanProcessor = ComprehendDevSpanProcessor;
@@ -361,11 +382,36 @@ function hashIdString(idString) {
361
382
  .map(b => b.toString(16).padStart(2, '0'))
362
383
  .join('');
363
384
  }
364
- /** Try to extract data from the database connection string. Many are URL based, fall back on
365
- * some kind of comma-seperated style for the rest. */
385
+ function getSpanContext(span) {
386
+ const ctx = span.spanContext?.();
387
+ return {
388
+ traceId: ctx?.traceId ?? '',
389
+ spanId: ctx?.spanId ?? '',
390
+ };
391
+ }
392
+ function getParentSpanId(span) {
393
+ return span.parentSpanContext?.spanId ?? '';
394
+ }
395
+ function flattenResourceAttributes(attrs) {
396
+ const result = {};
397
+ function flatten(obj, prefix) {
398
+ for (const [key, value] of Object.entries(obj)) {
399
+ const fullKey = prefix ? `${prefix}.${key}` : key;
400
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
401
+ flatten(value, fullKey);
402
+ }
403
+ else {
404
+ result[fullKey] = String(value);
405
+ }
406
+ }
407
+ }
408
+ flatten(attrs, '');
409
+ return result;
410
+ }
411
+ /** Try to extract data from the database connection string. Many are URL-based; falls back to
412
+ * semicolon-separated key=value parsing for the rest (e.g. MSSQL style). */
366
413
  function parseDatabaseConnectionStringRaw(conn) {
367
414
  try {
368
- // Try URL-style parsing first; scrub the user details if present.
369
415
  const url = new URL(conn);
370
416
  const user = url.username || undefined;
371
417
  const host = url.hostname || undefined;
@@ -382,7 +428,6 @@ function parseDatabaseConnectionStringRaw(conn) {
382
428
  };
383
429
  }
384
430
  catch {
385
- // Not a URL-style string; try semi-structured parsing
386
431
  const parts = conn.split(';');
387
432
  const kv = {};
388
433
  for (const part of parts) {
@@ -394,7 +439,6 @@ function parseDatabaseConnectionStringRaw(conn) {
394
439
  const host = kv['server'] || kv['data source'] || kv['address'];
395
440
  const port = kv['port'];
396
441
  const name = kv['database'] || kv['initial catalog'];
397
- // Reconstruct a scrubbed connection string without credentials
398
442
  const scrubbed = Object.entries(kv)
399
443
  .filter(([key]) => !['user id', 'uid', 'password', 'pwd'].includes(key))
400
444
  .map(([k, v]) => `${k}=${v}`)
@@ -408,11 +452,10 @@ function parseDatabaseConnectionStringRaw(conn) {
408
452
  };
409
453
  }
410
454
  }
411
- /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an event,
412
- * directly on the span with error semantics, and some other more ad-hoc cases. */
455
+ /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an
456
+ * event, directly on the span with error semantics, and some other more ad-hoc cases. */
413
457
  function extractErrorInfo(span) {
414
458
  const attrs = span.attributes;
415
- // Try to extract from a structured 'exception' event, as it should have more detail
416
459
  const exceptionEvent = span.events.find(e => e.name === 'exception');
417
460
  if (exceptionEvent?.attributes) {
418
461
  const message = exceptionEvent.attributes['exception.message'];
@@ -422,7 +465,6 @@ function extractErrorInfo(span) {
422
465
  return { message, type, stack };
423
466
  }
424
467
  }
425
- // Fallback to attributes directly on the span.
426
468
  const isError = span.status.code === 2;
427
469
  const message = attrs['exception.message'] ??
428
470
  attrs['http.error_message'] ??
@@ -439,10 +481,27 @@ function extractErrorInfo(span) {
439
481
  stack
440
482
  };
441
483
  }
442
- function getTablesWithOperation(tableOps, operation) {
443
- const op = operation.toUpperCase();
444
- const result = Object.entries(tableOps)
445
- .filter(([_, ops]) => ops.includes(op))
446
- .map(([table]) => table);
447
- return result.length > 0 ? result : undefined;
484
+ function matchSpanRule(span, rule) {
485
+ switch (rule.kind) {
486
+ case 'type': {
487
+ const kindMap = {
488
+ 'client': api_1.SpanKind.CLIENT,
489
+ 'server': api_1.SpanKind.SERVER,
490
+ 'internal': api_1.SpanKind.INTERNAL,
491
+ };
492
+ return span.kind === kindMap[rule.value];
493
+ }
494
+ case 'attribute-present':
495
+ return span.attributes[rule.key] !== undefined;
496
+ case 'attribute-absent':
497
+ return span.attributes[rule.key] === undefined;
498
+ case 'attribute-equals':
499
+ return String(span.attributes[rule.key]) === rule.value;
500
+ case 'attribute-not-equals':
501
+ return String(span.attributes[rule.key]) !== rule.value;
502
+ case 'all':
503
+ return rule.rules.every(r => matchSpanRule(span, r));
504
+ case 'any':
505
+ return rule.rules.some(r => matchSpanRule(span, r));
506
+ }
448
507
  }
@@ -0,0 +1 @@
1
+ export {};