@comprehend/telemetry-node 0.1.4 → 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 (38) hide show
  1. package/.claude/settings.local.json +2 -2
  2. package/.idea/telemetry-node.iml +0 -1
  3. package/README.md +73 -27
  4. package/dist/ComprehendDevSpanProcessor.d.ts +9 -6
  5. package/dist/ComprehendDevSpanProcessor.js +145 -87
  6. package/dist/ComprehendDevSpanProcessor.test.js +270 -449
  7. package/dist/ComprehendMetricsExporter.d.ts +18 -0
  8. package/dist/ComprehendMetricsExporter.js +178 -0
  9. package/dist/ComprehendMetricsExporter.test.d.ts +1 -0
  10. package/dist/ComprehendMetricsExporter.test.js +266 -0
  11. package/dist/ComprehendSDK.d.ts +18 -0
  12. package/dist/ComprehendSDK.js +56 -0
  13. package/dist/ComprehendSDK.test.d.ts +1 -0
  14. package/dist/ComprehendSDK.test.js +126 -0
  15. package/dist/WebSocketConnection.d.ts +23 -3
  16. package/dist/WebSocketConnection.js +106 -12
  17. package/dist/WebSocketConnection.test.js +236 -169
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.js +5 -1
  20. package/dist/sql-analyzer.js +2 -11
  21. package/dist/sql-analyzer.test.js +0 -12
  22. package/dist/util.d.ts +2 -0
  23. package/dist/util.js +7 -0
  24. package/dist/wire-protocol.d.ts +168 -28
  25. package/package.json +3 -1
  26. package/src/ComprehendDevSpanProcessor.test.ts +311 -507
  27. package/src/ComprehendDevSpanProcessor.ts +169 -105
  28. package/src/ComprehendMetricsExporter.test.ts +334 -0
  29. package/src/ComprehendMetricsExporter.ts +225 -0
  30. package/src/ComprehendSDK.test.ts +160 -0
  31. package/src/ComprehendSDK.ts +63 -0
  32. package/src/WebSocketConnection.test.ts +286 -205
  33. package/src/WebSocketConnection.ts +135 -13
  34. package/src/index.ts +3 -2
  35. package/src/util.ts +6 -0
  36. package/src/wire-protocol.ts +204 -29
  37. package/src/sql-analyzer.test.ts +0 -599
  38. 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,18 +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
+ }
75
84
  return newService;
76
85
  }
77
86
  processHTTPRoute(service, route, method, span) {
78
- // Check if this route+method already exists under the service
79
87
  let observedRoute = service.httpRoutes.find(r => r.route === route && r.method === method);
80
88
  if (!observedRoute) {
81
- // It's new; hash it and add it to our collection.
82
89
  const idString = `http-route:${service.hash}:${method}:${route}`;
83
90
  const hash = hashIdString(idString);
84
91
  observedRoute = { method, route, hash };
85
92
  service.httpRoutes.push(observedRoute);
86
- // Emit observation message
87
93
  const message = {
88
94
  event: "new-entity",
89
95
  type: "http-route",
@@ -93,14 +99,13 @@ class ComprehendDevSpanProcessor {
93
99
  };
94
100
  this.ingestMessage(message);
95
101
  }
96
- // 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.
97
103
  const attrs = span.attributes;
98
104
  let path;
99
105
  if (attrs['http.target']) {
100
106
  try {
101
- // This might be just a path like "/search?q=foo"
102
107
  const rawTarget = attrs['http.target'];
103
- const fakeUrl = new URL(rawTarget, 'http://placeholder'); // placeholder base
108
+ const fakeUrl = new URL(rawTarget, 'http://placeholder');
104
109
  path = fakeUrl.pathname;
105
110
  }
106
111
  catch {
@@ -120,7 +125,6 @@ class ComprehendDevSpanProcessor {
120
125
  else {
121
126
  path = '/';
122
127
  }
123
- // Build and ingest observation.
124
128
  const status = attrs['http.status_code'] ?? 0;
125
129
  const duration = span.duration;
126
130
  const httpVersion = attrs['http.flavor'];
@@ -128,9 +132,12 @@ class ComprehendDevSpanProcessor {
128
132
  const requestBytes = attrs['http.request_content_length'];
129
133
  const responseBytes = attrs['http.response_content_length'];
130
134
  const { message: errorMessage, type: errorType, stack: stack } = extractErrorInfo(span);
135
+ const { traceId, spanId } = getSpanContext(span);
131
136
  const observation = {
132
137
  type: 'http-server',
133
138
  subject: observedRoute.hash,
139
+ spanId,
140
+ traceId,
134
141
  timestamp: span.startTime,
135
142
  path,
136
143
  status,
@@ -145,12 +152,11 @@ class ComprehendDevSpanProcessor {
145
152
  };
146
153
  this.ingestMessage({
147
154
  event: "observations",
148
- seq: this.observationsSeq++,
155
+ seq: this.connection.nextSeq(),
149
156
  observations: [observation]
150
157
  });
151
158
  }
152
159
  processDatabaseOperation(currentService, span) {
153
- // Parse the connection string.
154
160
  const attrs = span.attributes;
155
161
  const system = attrs['db.system'];
156
162
  const rawConnection = attrs['db.connection_string'];
@@ -163,10 +169,8 @@ class ComprehendDevSpanProcessor {
163
169
  port: attrs['net.peer.port']?.toString(),
164
170
  name: attrs['db.name'],
165
171
  };
166
- // See if we already have an entry for this database.
167
172
  const hash = hashIdString(`database:${system}:${parsed.host ?? ''}:${parsed.port ?? ''}:${parsed.name ?? ''}`);
168
173
  let observedDatabase = this.observedDatabases.find(db => db.hash === hash);
169
- // If we see it for the first time, add and ingest it.
170
174
  if (!observedDatabase) {
171
175
  observedDatabase = {
172
176
  system,
@@ -176,7 +180,6 @@ class ComprehendDevSpanProcessor {
176
180
  hash
177
181
  };
178
182
  this.observedDatabases.push(observedDatabase);
179
- // The existence of the database.
180
183
  const message = {
181
184
  event: "new-entity",
182
185
  type: "database",
@@ -190,7 +193,6 @@ class ComprehendDevSpanProcessor {
190
193
  };
191
194
  this.ingestMessage(message);
192
195
  }
193
- // The connection to the database should have an interaction.
194
196
  const interactions = this.getInteractions(currentService.hash, observedDatabase.hash);
195
197
  let connectionInteraction = interactions.dbConnections.find(c => c.connection === parsed.scrubbed && c.user === parsed.user);
196
198
  if (!connectionInteraction) {
@@ -211,71 +213,42 @@ class ComprehendDevSpanProcessor {
211
213
  };
212
214
  this.ingestMessage(message);
213
215
  }
214
- // The query of the database from the service (only for SQL for now).
216
+ // For SQL databases, send raw query to server for analysis
215
217
  if (sqlDbSystems.has(system) && attrs['db.statement']) {
216
- // The interaction, based upon the normalized query.
217
- let queryInfo = (0, sql_analyzer_1.analyzeSQL)(attrs['db.statement']);
218
- let queryInteraction = interactions.dbQueries.find(q => q.query === queryInfo.normalizedQuery);
219
- if (!queryInteraction) {
220
- queryInteraction = {
221
- hash: hashIdString(`db-query:${currentService.hash}:${observedDatabase.hash}:${queryInfo.normalizedQuery}`),
222
- query: queryInfo.normalizedQuery
223
- };
224
- interactions.dbQueries.push(queryInteraction);
225
- const selects = getTablesWithOperation(queryInfo.tableOperations, 'SELECT');
226
- const inserts = getTablesWithOperation(queryInfo.tableOperations, 'INSERT');
227
- const updates = getTablesWithOperation(queryInfo.tableOperations, 'UPDATE');
228
- const deletes = getTablesWithOperation(queryInfo.tableOperations, 'DELETE');
229
- const message = {
230
- event: "new-interaction",
231
- type: "db-query",
232
- hash: queryInteraction.hash,
233
- from: currentService.hash,
234
- to: observedDatabase.hash,
235
- query: queryInfo.presentableQuery,
236
- ...(selects ? { selects } : {}),
237
- ...(inserts ? { inserts } : {}),
238
- ...(updates ? { updates } : {}),
239
- ...(deletes ? { deletes } : {}),
240
- };
241
- this.ingestMessage(message);
242
- }
243
- // Build and ingest observation.
244
218
  const duration = span.duration;
245
219
  const returnedRows = attrs['db.response.returned_rows']
246
220
  ?? attrs['db.sql.rows'];
247
221
  const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
248
- const observation = {
249
- type: "db-query",
250
- 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,
251
229
  timestamp: span.startTime,
252
230
  duration,
231
+ traceId,
232
+ spanId,
253
233
  ...(errorMessage ? { errorMessage } : {}),
254
234
  ...(errorType ? { errorType } : {}),
255
235
  ...(stack ? { stack } : {}),
256
236
  ...(returnedRows !== undefined ? { returnedRows } : {})
257
237
  };
258
- this.ingestMessage({
259
- event: "observations",
260
- seq: this.observationsSeq++,
261
- observations: [observation]
262
- });
238
+ this.ingestMessage(dbQueryMessage);
263
239
  }
264
240
  }
265
241
  processHttpRequest(currentService, url, span) {
266
- // Build identity based upon protocol, host, and port.
267
242
  const parsed = new URL(url);
268
- const protocol = parsed.protocol.replace(':', ''); // Remove trailing colon
243
+ const protocol = parsed.protocol.replace(':', '');
269
244
  const host = parsed.hostname;
270
245
  const port = parsed.port ? parseInt(parsed.port) : (protocol === 'https' ? 443 : 80);
271
246
  const idString = `http-service:${protocol}:${host}:${port}`;
272
247
  const hash = hashIdString(idString);
273
- // Ingest it if it's not already observed.
274
248
  let observedHttpService = this.observedHttpServices.find(s => s.protocol === protocol && s.host === host && s.port === port);
275
249
  if (!observedHttpService) {
276
250
  observedHttpService = { protocol, host, port, hash };
277
251
  this.observedHttpServices.push(observedHttpService);
278
- // The existence of the service.
279
252
  const message = {
280
253
  event: "new-entity",
281
254
  type: "http-service",
@@ -286,7 +259,6 @@ class ComprehendDevSpanProcessor {
286
259
  };
287
260
  this.ingestMessage(message);
288
261
  }
289
- // Ingest the interaction if first observed.
290
262
  const interactions = this.getInteractions(currentService.hash, observedHttpService.hash);
291
263
  if (!interactions.httpRequest) {
292
264
  const idString = `http-request:${currentService.hash}:${observedHttpService.hash}`;
@@ -301,11 +273,10 @@ class ComprehendDevSpanProcessor {
301
273
  };
302
274
  this.ingestMessage(message);
303
275
  }
304
- // Build and ingest observation.
305
276
  const attrs = span.attributes;
306
277
  const path = parsed.pathname || '/';
307
278
  const method = span.attributes['http.method'];
308
- if (!method) // Really should always be there
279
+ if (!method)
309
280
  return;
310
281
  const status = attrs['http.status_code'];
311
282
  const duration = span.duration;
@@ -313,9 +284,12 @@ class ComprehendDevSpanProcessor {
313
284
  const requestBytes = attrs['http.request_content_length'];
314
285
  const responseBytes = attrs['http.response_content_length'];
315
286
  const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
287
+ const { traceId, spanId } = getSpanContext(span);
316
288
  const observation = {
317
289
  type: "http-client",
318
290
  subject: interactions.httpRequest.hash,
291
+ spanId,
292
+ traceId,
319
293
  timestamp: span.startTime,
320
294
  path,
321
295
  method,
@@ -330,10 +304,57 @@ class ComprehendDevSpanProcessor {
330
304
  };
331
305
  this.ingestMessage({
332
306
  event: "observations",
333
- seq: this.observationsSeq++,
307
+ seq: this.connection.nextSeq(),
334
308
  observations: [observation]
335
309
  });
336
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
+ }
337
358
  getInteractions(from, to) {
338
359
  let fromMap = this.observedInteractions.get(from);
339
360
  if (!fromMap) {
@@ -342,7 +363,7 @@ class ComprehendDevSpanProcessor {
342
363
  }
343
364
  let interactions = fromMap.get(to);
344
365
  if (!interactions) {
345
- interactions = { httpRequest: undefined, dbConnections: [], dbQueries: [] };
366
+ interactions = { httpRequest: undefined, dbConnections: [] };
346
367
  fromMap.set(to, interactions);
347
368
  }
348
369
  return interactions;
@@ -353,7 +374,6 @@ class ComprehendDevSpanProcessor {
353
374
  async forceFlush() {
354
375
  }
355
376
  async shutdown() {
356
- this.connection.close();
357
377
  }
358
378
  }
359
379
  exports.ComprehendDevSpanProcessor = ComprehendDevSpanProcessor;
@@ -362,11 +382,36 @@ function hashIdString(idString) {
362
382
  .map(b => b.toString(16).padStart(2, '0'))
363
383
  .join('');
364
384
  }
365
- /** Try to extract data from the database connection string. Many are URL based, fall back on
366
- * 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). */
367
413
  function parseDatabaseConnectionStringRaw(conn) {
368
414
  try {
369
- // Try URL-style parsing first; scrub the user details if present.
370
415
  const url = new URL(conn);
371
416
  const user = url.username || undefined;
372
417
  const host = url.hostname || undefined;
@@ -383,7 +428,6 @@ function parseDatabaseConnectionStringRaw(conn) {
383
428
  };
384
429
  }
385
430
  catch {
386
- // Not a URL-style string; try semi-structured parsing
387
431
  const parts = conn.split(';');
388
432
  const kv = {};
389
433
  for (const part of parts) {
@@ -395,7 +439,6 @@ function parseDatabaseConnectionStringRaw(conn) {
395
439
  const host = kv['server'] || kv['data source'] || kv['address'];
396
440
  const port = kv['port'];
397
441
  const name = kv['database'] || kv['initial catalog'];
398
- // Reconstruct a scrubbed connection string without credentials
399
442
  const scrubbed = Object.entries(kv)
400
443
  .filter(([key]) => !['user id', 'uid', 'password', 'pwd'].includes(key))
401
444
  .map(([k, v]) => `${k}=${v}`)
@@ -409,11 +452,10 @@ function parseDatabaseConnectionStringRaw(conn) {
409
452
  };
410
453
  }
411
454
  }
412
- /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an event,
413
- * 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. */
414
457
  function extractErrorInfo(span) {
415
458
  const attrs = span.attributes;
416
- // Try to extract from a structured 'exception' event, as it should have more detail
417
459
  const exceptionEvent = span.events.find(e => e.name === 'exception');
418
460
  if (exceptionEvent?.attributes) {
419
461
  const message = exceptionEvent.attributes['exception.message'];
@@ -423,7 +465,6 @@ function extractErrorInfo(span) {
423
465
  return { message, type, stack };
424
466
  }
425
467
  }
426
- // Fallback to attributes directly on the span.
427
468
  const isError = span.status.code === 2;
428
469
  const message = attrs['exception.message'] ??
429
470
  attrs['http.error_message'] ??
@@ -440,10 +481,27 @@ function extractErrorInfo(span) {
440
481
  stack
441
482
  };
442
483
  }
443
- function getTablesWithOperation(tableOps, operation) {
444
- const op = operation.toUpperCase();
445
- const result = Object.entries(tableOps)
446
- .filter(([_, ops]) => ops.includes(op))
447
- .map(([table]) => table);
448
- 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
+ }
449
507
  }