@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.
@@ -1,19 +1,25 @@
1
1
  import {sha256} from "@noble/hashes/sha2";
2
2
  import {utf8ToBytes} from "@noble/hashes/utils";
3
- import {Context} from "@opentelemetry/api";
3
+ import {Context, SpanKind} from "@opentelemetry/api";
4
4
  import {ReadableSpan, Span, SpanProcessor} from "@opentelemetry/sdk-trace-base";
5
5
  import {
6
- DatabaseQueryObservation,
6
+ AttributeType,
7
+ CustomObservation,
8
+ CustomSpanObservationSpecification,
9
+ DatabaseQueryMessage,
7
10
  HttpClientObservation,
8
- HttpServerObservation, NewObservedDatabaseConnectionMessage,
9
- NewObservedDatabaseMessage, NewObservedDatabaseQueryMessage,
11
+ HttpServerObservation,
12
+ NewObservedDatabaseConnectionMessage,
13
+ NewObservedDatabaseMessage,
10
14
  NewObservedHttpRequestMessage,
11
15
  NewObservedHttpRouteMessage,
12
16
  NewObservedHttpServiceMessage,
13
17
  NewObservedServiceMessage,
14
- ObservationInputMessage
18
+ ObservationInputMessage,
19
+ SpanMatcherRule,
20
+ TraceSpansMessage,
21
+ CustomMetricSpecification,
15
22
  } from "./wire-protocol";
16
- import {analyzeSQL} from "./sql-analyzer";
17
23
  import {WebSocketConnection} from "./WebSocketConnection";
18
24
 
19
25
  interface ObservedService {
@@ -32,7 +38,7 @@ interface ObservedHTTPRoute {
32
38
 
33
39
  interface ObservedDatabase {
34
40
  system: string;
35
- connection?: string; // Connection string, hopefully scrubbed of user info
41
+ connection?: string;
36
42
  host?: string;
37
43
  port?: number;
38
44
  name?: string;
@@ -56,11 +62,6 @@ interface ObservedDatabaseConnection {
56
62
  user?: string;
57
63
  }
58
64
 
59
- interface ObservedDatabaseQuery {
60
- hash: string;
61
- query: string;
62
- }
63
-
64
65
  const sqlDbSystems = new Set([
65
66
  'mysql', 'postgresql', 'mssql', 'oracle', 'db2', 'sqlite', 'hsqldb', 'h2',
66
67
  'informix', 'cockroachdb', 'redshift', 'tidb', 'trino', 'greenplum'
@@ -73,33 +74,45 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
73
74
  private observedHttpServices: ObservedHttpService[] = [];
74
75
  private observedInteractions: Map<string, Map<string, {
75
76
  dbConnections: ObservedDatabaseConnection[],
76
- dbQueries: ObservedDatabaseQuery[],
77
77
  httpRequest?: ObservedHttpRequest
78
78
  }>> = new Map();
79
- private observationsSeq = 1;
79
+ private processContextSet = false;
80
+ private spanObservationSpecs: CustomSpanObservationSpecification[] = [];
81
+
82
+ constructor(connection: WebSocketConnection) {
83
+ this.connection = connection;
84
+ }
80
85
 
81
- constructor(options: { organization: string, token: string, debug?: boolean | ((message: string) => void) }) {
82
- this.connection = new WebSocketConnection(options.organization, options.token,
83
- options.debug === true ? console.log : options.debug === false ? undefined : options.debug);
86
+ updateCustomMetrics(specs: CustomMetricSpecification[]): void {
87
+ this.spanObservationSpecs = specs.filter(
88
+ (s): s is CustomSpanObservationSpecification => s.type === 'span'
89
+ );
84
90
  }
85
91
 
86
92
  onStart(span: Span, parentContext: Context): void {
87
93
  }
88
94
 
89
95
  onEnd(span: ReadableSpan): void {
96
+ const process = () => this.processSpan(span);
97
+ if (span.resource.asyncAttributesPending) {
98
+ span.resource.waitForAsyncAttributes?.().then(process);
99
+ } else {
100
+ process();
101
+ }
102
+ }
103
+
104
+ private processSpan(span: ReadableSpan): void {
90
105
  const currentService = this.discoverService(span);
91
106
  if (!currentService)
92
107
  return;
93
108
 
94
109
  const attrs = span.attributes;
95
- if (span.kind === 1) {
96
- // Server span, see if it's something to ingest.
110
+ if (span.kind === 1) { // Server span
97
111
  if (attrs['http.route'] && attrs['http.method']) {
98
112
  this.processHTTPRoute(currentService, attrs['http.route'] as string, attrs['http.method'] as string, span);
99
113
  }
100
114
  }
101
- else if (span.kind === 2) {
102
- // Client span, see if it's something to ingest.
115
+ else if (span.kind === 2) { // Client span
103
116
  if (attrs['http.url']) {
104
117
  this.processHttpRequest(currentService, attrs['http.url'] as string, span);
105
118
  }
@@ -107,10 +120,15 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
107
120
  if (attrs['db.system']) {
108
121
  this.processDatabaseOperation(currentService, span);
109
122
  }
123
+
124
+ // Report trace span for every span
125
+ this.reportTraceSpan(span);
126
+
127
+ // Check custom span observation matching
128
+ this.checkCustomSpanMatching(span);
110
129
  }
111
130
 
112
131
  private discoverService(span: ReadableSpan): ObservedService | undefined {
113
- // Look for an existing matching entry.
114
132
  const resAttrs = span.resource.attributes;
115
133
  const name = resAttrs['service.name'] as string | undefined;
116
134
  if (!name)
@@ -125,7 +143,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
125
143
  if (existing)
126
144
  return existing;
127
145
 
128
- // New; hash it and add it to the observed services.
129
146
  const idString = `service:${name}:${namespace ?? ''}:${environment ?? ''}`;
130
147
  const hash = hashIdString(idString);
131
148
  const newService: ObservedService = {
@@ -134,7 +151,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
134
151
  };
135
152
  this.observedServices.push(newService);
136
153
 
137
- // Ingest its existence.
138
154
  const message: NewObservedServiceMessage = {
139
155
  event: "new-entity",
140
156
  type: "service",
@@ -144,22 +160,27 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
144
160
  ...(environment ? { environment } : {})
145
161
  };
146
162
  this.ingestMessage(message);
163
+
164
+ // Set process context on first service discovery
165
+ if (!this.processContextSet) {
166
+ this.processContextSet = true;
167
+ const resources = flattenResourceAttributes(resAttrs);
168
+ this.connection.setProcessContext(hash, resources);
169
+ }
170
+
147
171
  return newService;
148
172
  }
149
173
 
150
174
  private processHTTPRoute(service: ObservedService, route: string, method: string, span: ReadableSpan): void {
151
- // Check if this route+method already exists under the service
152
175
  let observedRoute = service.httpRoutes.find(r =>
153
176
  r.route === route && r.method === method
154
177
  );
155
178
  if (!observedRoute) {
156
- // It's new; hash it and add it to our collection.
157
179
  const idString = `http-route:${service.hash}:${method}:${route}`;
158
180
  const hash = hashIdString(idString);
159
181
  observedRoute = { method, route, hash };
160
182
  service.httpRoutes.push(observedRoute);
161
183
 
162
- // Emit observation message
163
184
  const message: NewObservedHttpRouteMessage = {
164
185
  event: "new-entity",
165
186
  type: "http-route",
@@ -170,14 +191,13 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
170
191
  this.ingestMessage(message);
171
192
  }
172
193
 
173
- // Extract the request path, making sure we only get that, without any query string.
194
+ // Extract the request path, making sure we strip any query string.
174
195
  const attrs = span.attributes;
175
196
  let path: string;
176
197
  if (attrs['http.target']) {
177
198
  try {
178
- // This might be just a path like "/search?q=foo"
179
199
  const rawTarget = attrs['http.target'] as string;
180
- const fakeUrl = new URL(rawTarget, 'http://placeholder'); // placeholder base
200
+ const fakeUrl = new URL(rawTarget, 'http://placeholder');
181
201
  path = fakeUrl.pathname;
182
202
  } catch {
183
203
  path = '/';
@@ -196,7 +216,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
196
216
  path = '/';
197
217
  }
198
218
 
199
- // Build and ingest observation.
200
219
  const status = attrs['http.status_code'] as number | undefined ?? 0;
201
220
  const duration = span.duration;
202
221
  const httpVersion = attrs['http.flavor'] as string | undefined;
@@ -204,9 +223,12 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
204
223
  const requestBytes = attrs['http.request_content_length'] as number | undefined;
205
224
  const responseBytes = attrs['http.response_content_length'] as number | undefined;
206
225
  const { message: errorMessage, type: errorType, stack: stack } = extractErrorInfo(span);
226
+ const { traceId, spanId } = getSpanContext(span);
207
227
  const observation: HttpServerObservation = {
208
228
  type: 'http-server',
209
229
  subject: observedRoute.hash,
230
+ spanId,
231
+ traceId,
210
232
  timestamp: span.startTime,
211
233
  path,
212
234
  status,
@@ -221,13 +243,12 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
221
243
  };
222
244
  this.ingestMessage({
223
245
  event: "observations",
224
- seq: this.observationsSeq++,
246
+ seq: this.connection.nextSeq(),
225
247
  observations: [observation]
226
248
  });
227
249
  }
228
250
 
229
251
  private processDatabaseOperation(currentService: ObservedService, span: ReadableSpan): void {
230
- // Parse the connection string.
231
252
  const attrs = span.attributes;
232
253
  const system = attrs['db.system'] as string;
233
254
  const rawConnection = attrs['db.connection_string'] as string | undefined;
@@ -241,11 +262,9 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
241
262
  name: attrs['db.name'] as string | undefined,
242
263
  };
243
264
 
244
- // See if we already have an entry for this database.
245
265
  const hash = hashIdString(`database:${system}:${parsed.host ?? ''}:${parsed.port ?? ''}:${parsed.name ?? ''}`);
246
266
  let observedDatabase = this.observedDatabases.find(db => db.hash === hash);
247
267
 
248
- // If we see it for the first time, add and ingest it.
249
268
  if (!observedDatabase) {
250
269
  observedDatabase = {
251
270
  system,
@@ -256,7 +275,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
256
275
  };
257
276
  this.observedDatabases.push(observedDatabase);
258
277
 
259
- // The existence of the database.
260
278
  const message: NewObservedDatabaseMessage = {
261
279
  event: "new-entity",
262
280
  type: "database",
@@ -271,7 +289,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
271
289
  this.ingestMessage(message);
272
290
  }
273
291
 
274
- // The connection to the database should have an interaction.
275
292
  const interactions = this.getInteractions(currentService.hash, observedDatabase.hash);
276
293
  let connectionInteraction = interactions.dbConnections.find(c => c.connection === parsed.scrubbed && c.user === parsed.user);
277
294
  if (!connectionInteraction) {
@@ -293,69 +310,40 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
293
310
  this.ingestMessage(message);
294
311
  }
295
312
 
296
- // The query of the database from the service (only for SQL for now).
313
+ // For SQL databases, send raw query to server for analysis
297
314
  if (sqlDbSystems.has(system) && attrs['db.statement']) {
298
- // The interaction, based upon the normalized query.
299
- let queryInfo = analyzeSQL(attrs['db.statement'] as string);
300
- let queryInteraction = interactions.dbQueries.find(q => q.query === queryInfo.normalizedQuery);
301
- if (!queryInteraction) {
302
- queryInteraction = {
303
- hash: hashIdString(`db-query:${currentService.hash}:${observedDatabase.hash}:${queryInfo.normalizedQuery}`),
304
- query: queryInfo.normalizedQuery
305
- };
306
- interactions.dbQueries.push(queryInteraction);
307
- const selects = getTablesWithOperation(queryInfo.tableOperations, 'SELECT');
308
- const inserts = getTablesWithOperation(queryInfo.tableOperations, 'INSERT');
309
- const updates = getTablesWithOperation(queryInfo.tableOperations, 'UPDATE');
310
- const deletes = getTablesWithOperation(queryInfo.tableOperations, 'DELETE');
311
- const message: NewObservedDatabaseQueryMessage = {
312
- event: "new-interaction",
313
- type: "db-query",
314
- hash: queryInteraction.hash,
315
- from: currentService.hash,
316
- to: observedDatabase.hash,
317
- query: queryInfo.presentableQuery,
318
- ...(selects ? { selects } : {}),
319
- ...(inserts ? { inserts } : {}),
320
- ...(updates ? { updates } : {}),
321
- ...(deletes ? { deletes } : {}),
322
- };
323
- this.ingestMessage(message);
324
- }
325
-
326
- // Build and ingest observation.
327
315
  const duration = span.duration;
328
316
  const returnedRows = (attrs['db.response.returned_rows'] as number | undefined)
329
317
  ?? (attrs['db.sql.rows'] as number | undefined);
330
318
  const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
331
- const observation: DatabaseQueryObservation = {
332
- type: "db-query",
333
- subject: queryInteraction.hash,
319
+ const { traceId, spanId } = span.spanContext();
320
+ const dbQueryMessage: DatabaseQueryMessage = {
321
+ event: "db-query",
322
+ seq: this.connection.nextSeq(),
323
+ query: attrs['db.statement'] as string,
324
+ from: currentService.hash,
325
+ to: observedDatabase.hash,
334
326
  timestamp: span.startTime,
335
327
  duration,
328
+ traceId,
329
+ spanId,
336
330
  ...(errorMessage ? { errorMessage } : {}),
337
- ...(errorType ? { errorType } : {}),
331
+ ...(errorType ? { errorType } : {}),
338
332
  ...(stack ? { stack } : {}),
339
333
  ...(returnedRows !== undefined ? { returnedRows } : {})
340
334
  };
341
- this.ingestMessage({
342
- event: "observations",
343
- seq: this.observationsSeq++,
344
- observations: [observation]
345
- });
335
+ this.ingestMessage(dbQueryMessage);
346
336
  }
347
337
  }
348
338
 
349
339
  private processHttpRequest(currentService: ObservedService, url: string, span: ReadableSpan): void {
350
- // Build identity based upon protocol, host, and port.
351
340
  const parsed = new URL(url);
352
- const protocol = parsed.protocol.replace(':', ''); // Remove trailing colon
341
+ const protocol = parsed.protocol.replace(':', '');
353
342
  const host = parsed.hostname;
354
343
  const port = parsed.port ? parseInt(parsed.port) : (protocol === 'https' ? 443 : 80);
355
344
  const idString = `http-service:${protocol}:${host}:${port}`;
356
345
  const hash = hashIdString(idString);
357
346
 
358
- // Ingest it if it's not already observed.
359
347
  let observedHttpService = this.observedHttpServices.find(s =>
360
348
  s.protocol === protocol && s.host === host && s.port === port
361
349
  );
@@ -363,7 +351,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
363
351
  observedHttpService = { protocol, host, port, hash };
364
352
  this.observedHttpServices.push(observedHttpService);
365
353
 
366
- // The existence of the service.
367
354
  const message: NewObservedHttpServiceMessage = {
368
355
  event: "new-entity",
369
356
  type: "http-service",
@@ -375,7 +362,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
375
362
  this.ingestMessage(message);
376
363
  }
377
364
 
378
- // Ingest the interaction if first observed.
379
365
  const interactions = this.getInteractions(currentService.hash, observedHttpService.hash);
380
366
  if (!interactions.httpRequest) {
381
367
  const idString = `http-request:${currentService.hash}:${observedHttpService.hash}`;
@@ -391,11 +377,10 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
391
377
  this.ingestMessage(message);
392
378
  }
393
379
 
394
- // Build and ingest observation.
395
380
  const attrs = span.attributes;
396
381
  const path = parsed.pathname || '/';
397
382
  const method = span.attributes['http.method'] as string;
398
- if (!method) // Really should always be there
383
+ if (!method)
399
384
  return;
400
385
  const status = attrs['http.status_code'] as number | undefined;
401
386
  const duration = span.duration;
@@ -403,9 +388,12 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
403
388
  const requestBytes = attrs['http.request_content_length'] as number | undefined;
404
389
  const responseBytes = attrs['http.response_content_length'] as number | undefined;
405
390
  const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
391
+ const { traceId, spanId } = getSpanContext(span);
406
392
  const observation: HttpClientObservation = {
407
393
  type: "http-client",
408
394
  subject: interactions.httpRequest.hash,
395
+ spanId,
396
+ traceId,
409
397
  timestamp: span.startTime,
410
398
  path,
411
399
  method,
@@ -415,16 +403,65 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
415
403
  ...(requestBytes !== undefined ? { requestBytes } : {}),
416
404
  ...(responseBytes !== undefined ? { responseBytes } : {}),
417
405
  ...(errorMessage ? { errorMessage } : {}),
418
- ...(errorType ? { errorType } : {}),
406
+ ...(errorType ? { errorType } : {}),
419
407
  ...(stack ? { stack } : {})
420
408
  };
421
409
  this.ingestMessage({
422
410
  event: "observations",
423
- seq: this.observationsSeq++,
411
+ seq: this.connection.nextSeq(),
424
412
  observations: [observation]
425
413
  });
426
414
  }
427
415
 
416
+ private reportTraceSpan(span: ReadableSpan): void {
417
+ const { traceId, spanId } = getSpanContext(span);
418
+ const parent = getParentSpanId(span);
419
+ const traceSpanMessage: TraceSpansMessage = {
420
+ event: "tracespans",
421
+ seq: this.connection.nextSeq(),
422
+ data: [{
423
+ trace: traceId,
424
+ span: spanId,
425
+ parent,
426
+ name: span.name,
427
+ timestamp: span.startTime,
428
+ }]
429
+ };
430
+ this.ingestMessage(traceSpanMessage);
431
+ }
432
+
433
+ private checkCustomSpanMatching(span: ReadableSpan): void {
434
+ for (const spec of this.spanObservationSpecs) {
435
+ if (matchSpanRule(span, spec.rule)) {
436
+ const { traceId, spanId } = getSpanContext(span);
437
+ const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
438
+ const collectedAttrs: Record<string, AttributeType> = {};
439
+ for (const [key, value] of Object.entries(span.attributes)) {
440
+ if (value !== undefined) {
441
+ collectedAttrs[key] = value as AttributeType;
442
+ }
443
+ }
444
+ const observation: CustomObservation = {
445
+ type: "custom",
446
+ subject: spec.subject,
447
+ id: spec.subject,
448
+ spanId,
449
+ traceId,
450
+ timestamp: span.startTime,
451
+ attributes: collectedAttrs,
452
+ ...(errorMessage ? { errorMessage } : {}),
453
+ ...(errorType ? { errorType } : {}),
454
+ ...(stack ? { stack } : {}),
455
+ };
456
+ this.ingestMessage({
457
+ event: "observations",
458
+ seq: this.connection.nextSeq(),
459
+ observations: [observation]
460
+ });
461
+ }
462
+ }
463
+ }
464
+
428
465
  private getInteractions(from: string, to: string) {
429
466
  let fromMap = this.observedInteractions.get(from);
430
467
  if (!fromMap) {
@@ -433,7 +470,7 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
433
470
  }
434
471
  let interactions = fromMap.get(to);
435
472
  if (!interactions) {
436
- interactions = { httpRequest: undefined, dbConnections: [], dbQueries: [] };
473
+ interactions = { httpRequest: undefined, dbConnections: [] };
437
474
  fromMap.set(to, interactions);
438
475
  }
439
476
  return interactions;
@@ -447,7 +484,6 @@ export class ComprehendDevSpanProcessor implements SpanProcessor {
447
484
  }
448
485
 
449
486
  async shutdown(): Promise<void> {
450
- this.connection.close()
451
487
  }
452
488
  }
453
489
 
@@ -457,8 +493,36 @@ function hashIdString(idString: string) {
457
493
  .join('');
458
494
  }
459
495
 
460
- /** Try to extract data from the database connection string. Many are URL based, fall back on
461
- * some kind of comma-seperated style for the rest. */
496
+ function getSpanContext(span: ReadableSpan): { traceId: string, spanId: string } {
497
+ const ctx = span.spanContext?.();
498
+ return {
499
+ traceId: ctx?.traceId ?? '',
500
+ spanId: ctx?.spanId ?? '',
501
+ };
502
+ }
503
+
504
+ function getParentSpanId(span: ReadableSpan): string {
505
+ return span.parentSpanContext?.spanId ?? '';
506
+ }
507
+
508
+ function flattenResourceAttributes(attrs: Record<string, any>): Record<string, AttributeType> {
509
+ const result: Record<string, AttributeType> = {};
510
+ function flatten(obj: any, prefix: string) {
511
+ for (const [key, value] of Object.entries(obj)) {
512
+ const fullKey = prefix ? `${prefix}.${key}` : key;
513
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
514
+ flatten(value, fullKey);
515
+ } else {
516
+ result[fullKey] = String(value);
517
+ }
518
+ }
519
+ }
520
+ flatten(attrs, '');
521
+ return result;
522
+ }
523
+
524
+ /** Try to extract data from the database connection string. Many are URL-based; falls back to
525
+ * semicolon-separated key=value parsing for the rest (e.g. MSSQL style). */
462
526
  function parseDatabaseConnectionStringRaw(conn: string): {
463
527
  scrubbed: string;
464
528
  user?: string;
@@ -467,7 +531,6 @@ function parseDatabaseConnectionStringRaw(conn: string): {
467
531
  name?: string;
468
532
  } {
469
533
  try {
470
- // Try URL-style parsing first; scrub the user details if present.
471
534
  const url = new URL(conn);
472
535
  const user = url.username || undefined;
473
536
  const host = url.hostname || undefined;
@@ -483,7 +546,6 @@ function parseDatabaseConnectionStringRaw(conn: string): {
483
546
  name: dbName
484
547
  };
485
548
  } catch {
486
- // Not a URL-style string; try semi-structured parsing
487
549
  const parts = conn.split(';');
488
550
  const kv: Record<string, string> = {};
489
551
 
@@ -497,7 +559,6 @@ function parseDatabaseConnectionStringRaw(conn: string): {
497
559
  const port = kv['port'];
498
560
  const name = kv['database'] || kv['initial catalog'];
499
561
 
500
- // Reconstruct a scrubbed connection string without credentials
501
562
  const scrubbed = Object.entries(kv)
502
563
  .filter(([key]) => !['user id', 'uid', 'password', 'pwd'].includes(key))
503
564
  .map(([k, v]) => `${k}=${v}`)
@@ -513,8 +574,8 @@ function parseDatabaseConnectionStringRaw(conn: string): {
513
574
  }
514
575
  }
515
576
 
516
- /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an event,
517
- * directly on the span with error semantics, and some other more ad-hoc cases. */
577
+ /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an
578
+ * event, directly on the span with error semantics, and some other more ad-hoc cases. */
518
579
  function extractErrorInfo(span: ReadableSpan): {
519
580
  message?: string;
520
581
  type?: string;
@@ -522,7 +583,6 @@ function extractErrorInfo(span: ReadableSpan): {
522
583
  } {
523
584
  const attrs = span.attributes;
524
585
 
525
- // Try to extract from a structured 'exception' event, as it should have more detail
526
586
  const exceptionEvent = span.events.find(e => e.name === 'exception');
527
587
  if (exceptionEvent?.attributes) {
528
588
  const message = exceptionEvent.attributes['exception.message'] as string | undefined;
@@ -533,7 +593,6 @@ function extractErrorInfo(span: ReadableSpan): {
533
593
  }
534
594
  }
535
595
 
536
- // Fallback to attributes directly on the span.
537
596
  const isError = span.status.code === 2;
538
597
  const message =
539
598
  (attrs['exception.message'] as string | undefined) ??
@@ -553,13 +612,27 @@ function extractErrorInfo(span: ReadableSpan): {
553
612
  };
554
613
  }
555
614
 
556
- function getTablesWithOperation(
557
- tableOps: Record<string, string[]>,
558
- operation: string
559
- ): string[] | undefined {
560
- const op = operation.toUpperCase();
561
- const result = Object.entries(tableOps)
562
- .filter(([_, ops]) => ops.includes(op))
563
- .map(([table]) => table);
564
- return result.length > 0 ? result : undefined;
615
+ export function matchSpanRule(span: ReadableSpan, rule: SpanMatcherRule): boolean {
616
+ switch (rule.kind) {
617
+ case 'type': {
618
+ const kindMap: Record<string, SpanKind> = {
619
+ 'client': SpanKind.CLIENT,
620
+ 'server': SpanKind.SERVER,
621
+ 'internal': SpanKind.INTERNAL,
622
+ };
623
+ return span.kind === kindMap[rule.value];
624
+ }
625
+ case 'attribute-present':
626
+ return span.attributes[rule.key] !== undefined;
627
+ case 'attribute-absent':
628
+ return span.attributes[rule.key] === undefined;
629
+ case 'attribute-equals':
630
+ return String(span.attributes[rule.key]) === rule.value;
631
+ case 'attribute-not-equals':
632
+ return String(span.attributes[rule.key]) !== rule.value;
633
+ case 'all':
634
+ return rule.rules.every(r => matchSpanRule(span, r));
635
+ case 'any':
636
+ return rule.rules.some(r => matchSpanRule(span, r));
637
+ }
565
638
  }