From 72d314016df7ccfe6ebd12c243eed078016b2c99 Mon Sep 17 00:00:00 2001 From: gejemai Date: Fri, 26 Jun 2026 16:20:35 +0100 Subject: [PATCH] feat: add db.tx span with op and table_count attributes Introduce OpenTelemetry span 'db.tx' wrapping TransactionManager transactions, exposing 'op' (operation label) and 'table_count' (number of unique tables referenced) as span attributes. - Add DbSpans constant to src/tracing/tracer.ts - Add optional 'op' field to TransactionOptions - Track unique table names via SQL keyword regex in createBudgetedClient - Set table_count attribute on the span after the user fn completes - Document the new span in docs/observability.md Closes #560 --- docs/observability.md | 28 ++++++++++++++++++++++++++++ src/db/transaction.ts | 29 +++++++++++++++++++++++++++-- src/tracing/tracer.ts | 7 +++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 348dfa91..afe94da7 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -311,6 +311,34 @@ DEBUG=redaction npm test -- redaction - **#329**: Outbox Publisher Observability (metrics) - **#390**: ESLint plugin for logger schema validation (`require-schema-context` + `unvalidated-logger-call`) +## Database Transaction Spans + +Every database transaction managed by `TransactionManager.withTransaction` creates an OpenTelemetry span named `db.tx` with the following attributes: + +| Attribute | Type | Description | +|----------------|--------|----------------------------------------------------------| +| `op` | string | Operation label (e.g. `"process_payment"`). Set via the `op` option in `TransactionOptions`. Omitted when not provided. | +| `table_count` | number | Number of unique SQL tables referenced inside the transaction body. Extracted from `FROM`, `INTO`, `UPDATE`, `TABLE`, and `JOIN` clauses. | + +### Example + +```typescript +import { TransactionManager } from '../db/transaction.js' + +const txManager = new TransactionManager(pool) + +const result = await txManager.withTransaction( + async (client) => { + const { rows } = await client.query('SELECT * FROM users WHERE id = $1', [id]) + return rows[0] + }, + { op: 'fetch_user' } +) +// Resulting span: db.tx { op: "fetch_user", table_count: 1 } +``` + +The span is created via the `withSpan` utility and exported by the configured `SpanProcessor` (ConsoleSpanExporter in dev; OTLP in production). + ## Outbox Publisher Observability (Issue #329) The outbox publisher now emits structured logs via `src/utils/logger.ts` instead of `console.*`, allowing aggregation with our centralized logging. diff --git a/src/db/transaction.ts b/src/db/transaction.ts index c3c0f15c..11d6879c 100644 --- a/src/db/transaction.ts +++ b/src/db/transaction.ts @@ -1,6 +1,7 @@ import type { Pool, PoolClient } from 'pg' import { RequestSnapshotsRepository } from './repositories/requestSnapshotsRepository.js' import { dbTxnDurationSeconds, dbTxnSavepoints } from '../observability/index.js' +import { withSpan, DbSpans } from '../tracing/tracer.js' /** PostgreSQL error code emitted when lock_timeout fires (lock_not_available). */ export const PG_LOCK_TIMEOUT_CODE = "55P03"; @@ -61,6 +62,8 @@ export interface TransactionOptions { retryDelayMs?: number; maxDurationMs?: number; maxSavepoints?: number; + /** Label for the db.tx span `op` attribute (e.g. "process_payment"). */ + op?: string; } const FALLBACK_TIMEOUTS: LockTimeoutConfig = { @@ -81,6 +84,7 @@ function createBudgetedClient( maxDurationMs: number, maxSavepoints: number, savepointCountRef: { count: number }, + tablesRef: { tables: Set }, ): PoolClient { const wrappedQuery = async (...args: any[]) => { // Check duration budget before executing query @@ -98,6 +102,15 @@ function createBudgetedClient( } } + // Track unique table names referenced in the query + if (sql) { + const tableRegex = /(?:FROM|INTO|UPDATE|TABLE|JOIN)\s+["']?(\w+)["']?\b/gi; + let match: RegExpExecArray | null; + while ((match = tableRegex.exec(sql)) !== null) { + tablesRef.tables.add(match[1].toLowerCase()); + } + } + // `client.query` is heavily overloaded; none of its overloads accept a // spread of `any[]`. Invoke through a rest-parameter call signature so the // proxied arguments forward verbatim to the underlying client. @@ -183,6 +196,7 @@ export class TransactionManager { retryDelayMs = 100, maxDurationMs = DEFAULT_MAX_DURATION_MS, maxSavepoints = DEFAULT_MAX_SAVEPOINTS, + op, } = options; const effectiveTimeoutMs = @@ -195,6 +209,7 @@ export class TransactionManager { const client = await this.pool.connect(); const startTime = Date.now(); const savepointCountRef = { count: 0 }; + const tablesRef = { tables: new Set() }; try { const beginSql = isolationLevel @@ -218,8 +233,18 @@ export class TransactionManager { // Swallow: setting may not be needed in some environments } - const budgetedClient = createBudgetedClient(client, startTime, maxDurationMs, maxSavepoints, savepointCountRef); - const result = await fn(budgetedClient); + const budgetedClient = createBudgetedClient(client, startTime, maxDurationMs, maxSavepoints, savepointCountRef, tablesRef); + + const initAttrs: Record = {}; + if (op) { + initAttrs.op = op; + } + + const result = await withSpan(DbSpans.TX, async (span) => { + const r = await fn(budgetedClient); + span.setAttribute('table_count', tablesRef.tables.size); + return r; + }, initAttrs); await client.query("COMMIT"); // Record metrics on successful commit diff --git a/src/tracing/tracer.ts b/src/tracing/tracer.ts index 477c5c55..7947595e 100644 --- a/src/tracing/tracer.ts +++ b/src/tracing/tracer.ts @@ -17,6 +17,13 @@ export const PaymentSpans = { SETTLE: 'payment.settle', } as const +/** + * Canonical span names for database operations. + */ +export const DbSpans = { + TX: 'db.tx', +} as const + /** * Initialize OpenTelemetry tracing for the application */