From 5f7d0d4adabf9d7c39fe5609f1d8750bb383487a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:36:13 +0200 Subject: [PATCH 1/8] feat(server-utils)!: Emit low cardinality postgres db span names With span streaming, `pg` and `postgres.js` query spans are named after their `db.query.summary` (`SELECT "User"`) instead of the full SQL statement, and report that summary as a new `db.query.summary` attribute. The statement is sanitized before it is summarized, so a string literal containing `from`/`join` cannot leak a value into the name. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/postgres-streamed/test.ts | 26 ++++++++++++------- packages/core/src/integrations/postgresjs.ts | 14 +++++++++- .../src/integrations/postgres-js.ts | 22 ++++++++++++++-- .../server-utils/src/integrations/postgres.ts | 21 +++++++++++++-- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 8675b9ff008d..5eb717d80ed5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -88,6 +88,9 @@ const COMMON_DB_ATTRIBUTES = { * * `origin` defaults to `QUERY_ORIGIN`; blocks that force the OTel path (explicit `postgresIntegration()`) * pass `auto.db.otel.postgres` explicitly. + * + * With span streaming, query spans are named after the low-cardinality query summary + * (`{operation} {table}`) rather than the full statement, so `name` and `statement` differ. */ function expectedDbSpan({ name, @@ -100,6 +103,7 @@ function expectedDbSpan({ host?: string; origin?: string; }): unknown { + // The name of a query span is its `db.query.summary`, which is reported as an attribute too. const attributes: Record = { ...COMMON_DB_ATTRIBUTES, 'server.address': { @@ -117,6 +121,10 @@ function expectedDbSpan({ type: 'string', value: statement, }; + attributes['db.query.summary'] = { + type: 'string', + value: name, + }; attributes['sentry.origin'] = { type: 'string', value: origin, @@ -170,12 +178,12 @@ describeWithDockerCompose('postgres auto instrumentation (streamed)', { workingD expect(dbSpans).toEqual([ expectedDbSpan({ name: 'pg.connect' }), - expectedDbSpan({ name: CREATE_USER_TABLE_STATEMENT, statement: CREATE_USER_TABLE_STATEMENT }), + expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }), expectedDbSpan({ - name: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', }), - expectedDbSpan({ name: 'SELECT * FROM "User"', statement: 'SELECT * FROM "User"' }), + expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User"' }), expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }), ]); }, @@ -201,13 +209,13 @@ describeWithDockerCompose('postgres auto instrumentation (streamed)', { workingD // `ignoreConnectSpans`. const origin = 'auto.db.postgres'; expect(dbSpans).toEqual([ - expectedDbSpan({ name: CREATE_USER_TABLE_STATEMENT, statement: CREATE_USER_TABLE_STATEMENT, origin }), + expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT, origin }), expectedDbSpan({ - name: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', origin, }), - expectedDbSpan({ name: 'SELECT * FROM "User"', statement: 'SELECT * FROM "User"', origin }), + expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User"', origin }), expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"', origin }), ]); }, @@ -240,17 +248,17 @@ describeWithDockerCompose('postgres auto instrumentation (streamed)', { workingD expect(dbSpans).toEqual([ expectedDbSpan({ name: 'pg.connect', host: '127.0.0.1' }), expectedDbSpan({ - name: CREATE_NATIVE_USER_TABLE_STATEMENT, + name: 'CREATE TABLE "NativeUser"', statement: CREATE_NATIVE_USER_TABLE_STATEMENT, host: '127.0.0.1', }), expectedDbSpan({ - name: 'INSERT INTO "NativeUser" ("email", "name") VALUES ($1, $2)', + name: 'INSERT "NativeUser"', statement: 'INSERT INTO "NativeUser" ("email", "name") VALUES ($1, $2)', host: '127.0.0.1', }), expectedDbSpan({ - name: 'SELECT * FROM "NativeUser"', + name: 'SELECT "NativeUser"', statement: 'SELECT * FROM "NativeUser"', host: '127.0.0.1', }), diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index ecde1dbe3f63..bb3bdffac797 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -2,11 +2,14 @@ // This can be used in any environment (Node.js, Cloudflare Workers, etc.) // without depending on OpenTelemetry module hooking. +import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../tracing'; +import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { startSpanManual } from '../tracing/trace'; import type { Span } from '../types/span'; +import { getSqlQuerySummary } from '../utils/sql'; import { debug } from '../utils/debug-logger'; import { isObjectLike } from '../utils/is'; import { getActiveSpan } from '../utils/spanUtils'; @@ -229,9 +232,17 @@ function _wrapSingleQueryHandle( const fullQuery = _reconstructQuery(query.strings); const sanitizedSqlQuery = _sanitizeSqlQuery(fullQuery); + const client = getClient(); + const querySummary = getSqlQuerySummary(sanitizedSqlQuery); + return startSpanManual( { - name: sanitizedSqlQuery || 'postgresjs.query', + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.system.name}`. + name: + client && hasSpanStreamingEnabled(client) + ? querySummary || 'postgres' + : sanitizedSqlQuery || 'postgresjs.query', op: 'db', }, (span: Span) => { @@ -240,6 +251,7 @@ function _wrapSingleQueryHandle( span.setAttributes({ 'db.system.name': 'postgres', 'db.query.text': sanitizedSqlQuery, + 'db.query.summary': querySummary, }); const connectionContext = sqlInstance diff --git a/packages/server-utils/src/integrations/postgres-js.ts b/packages/server-utils/src/integrations/postgres-js.ts index ea373a6af158..15426f75c7cd 100644 --- a/packages/server-utils/src/integrations/postgres-js.ts +++ b/packages/server-utils/src/integrations/postgres-js.ts @@ -1,14 +1,23 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import { DB_QUERY_TEXT, DB_SYSTEM_NAME, ERROR_TYPE, SENTRY_KIND } from '@sentry/conventions/attributes'; +import { + DB_QUERY_SUMMARY, + DB_QUERY_TEXT, + DB_SYSTEM_NAME, + ERROR_TYPE, + SENTRY_KIND, +} from '@sentry/conventions/attributes'; import type { IntegrationFn, PostgresConnectionContext, Span } from '@sentry/core'; import { _INTERNAL_buildPostgresConnectionContext, + _INTERNAL_getSqlQuerySummary, _INTERNAL_reconstructPostgresQuery, _INTERNAL_sanitizeSqlQuery, _INTERNAL_setPostgresConnectionAttributes, _INTERNAL_setPostgresOperationName, debug, defineIntegration, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, @@ -262,15 +271,24 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { const fullQuery = _INTERNAL_reconstructPostgresQuery(query.strings); const sanitizedSqlQuery = _INTERNAL_sanitizeSqlQuery(fullQuery); + const client = getClient(); + // The query is already sanitized, so a string literal containing `from`/`join` can't leak a + // value into the summary. + const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedSqlQuery); + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.system.name}`. + const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || 'postgres' : undefined; + // `sentry.kind: client` matches the mysql/pg channel subscribers. const span = startInactiveSpan({ - name: sanitizedSqlQuery || 'postgresjs.query', + name: streamedName || sanitizedSqlQuery || 'postgresjs.query', op: 'db', attributes: { [SENTRY_KIND]: 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [DB_SYSTEM_NAME]: 'postgres', [DB_QUERY_TEXT]: sanitizedSqlQuery, + [DB_QUERY_SUMMARY]: querySummary, }, }); diff --git a/packages/server-utils/src/integrations/postgres.ts b/packages/server-utils/src/integrations/postgres.ts index 567423a614d3..3ee5b81458aa 100644 --- a/packages/server-utils/src/integrations/postgres.ts +++ b/packages/server-utils/src/integrations/postgres.ts @@ -1,6 +1,7 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { DB_NAMESPACE, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -10,10 +11,14 @@ import { } from '@sentry/conventions/attributes'; import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, isObjectLike, bindScopeToEmitter, defineIntegration, + getClient, getCurrentScope, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; @@ -173,14 +178,26 @@ function subscribeQueryLikeChannel( function querySpanOptions(ctx: PgChannelContext): { name: string; op: string; attributes: SpanAttributes } { const params = (ctx.self as { connectionParameters?: PgConnectionParams } | undefined)?.connectionParameters ?? {}; const queryConfig = extractQueryConfig(ctx.arguments); + const client = getClient(); + // The statement is sanitized before it is summarized, so that a string literal containing + // `from`/`join` can't leak a value into the summary. + const querySummary = queryConfig?.text + ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(queryConfig.text)) + : undefined; + // The description is the SQL statement. With span streaming, span names have to be low cardinality, + // so `{db.query.summary}` is used instead, falling back to `{db.namespace}` and then + // `{db.system.name}` when there is no statement to summarize. + const streamedName = + client && hasSpanStreamingEnabled(client) ? querySummary || params.database || DB_SYSTEM_POSTGRESQL : undefined; + return { - // The description is the SQL statement - name: queryConfig?.text ?? SPAN_QUERY_FALLBACK, + name: streamedName ?? queryConfig?.text ?? SPAN_QUERY_FALLBACK, op: 'db', attributes: { ...getConnectionAttributes(params), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [DB_QUERY_TEXT]: queryConfig?.text || undefined, + [DB_QUERY_SUMMARY]: querySummary, [ATTR_PG_PLAN]: typeof queryConfig?.name === 'string' ? queryConfig.name : undefined, }, }; From ecadebba366e512ed907e38a48f12197814f2b0b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 10:32:17 +0200 Subject: [PATCH 2/8] add test, add db.operation.name eagerly, remove fluff --- .../suites/tracing/postgres-streamed/test.ts | 3 --- packages/core/src/integrations/postgresjs.ts | 16 +++++++--------- packages/core/src/server-exports.ts | 2 +- packages/core/test/lib/utils/sql.test.ts | 4 ++++ .../server-utils/src/integrations/postgres-js.ts | 15 +++++++-------- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 5eb717d80ed5..8921cd09a418 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -88,9 +88,6 @@ const COMMON_DB_ATTRIBUTES = { * * `origin` defaults to `QUERY_ORIGIN`; blocks that force the OTel path (explicit `postgresIntegration()`) * pass `auto.db.otel.postgres` explicitly. - * - * With span streaming, query spans are named after the low-cardinality query summary - * (`{operation} {table}`) rather than the full statement, so `name` and `statement` differ. */ function expectedDbSpan({ name, diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index bb3bdffac797..2b8a06adc436 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -252,6 +252,7 @@ function _wrapSingleQueryHandle( 'db.system.name': 'postgres', 'db.query.text': sanitizedSqlQuery, 'db.query.summary': querySummary, + 'db.operation.name': _getOperationName(sanitizedSqlQuery), }); const connectionContext = sqlInstance @@ -279,7 +280,8 @@ function _wrapSingleQueryHandle( queryWithCallbacks.resolve = new Proxy(queryWithCallbacks.resolve as (...args: unknown[]) => unknown, { apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { try { - _setOperationName(span, sanitizedSqlQuery, resolveArgs?.[0]?.command); + // Reset with the server-reported command, which is more reliable than the query text. + span.setAttribute('db.operation.name', _getOperationName(sanitizedSqlQuery, resolveArgs?.[0]?.command)); span.end(); } catch (e) { DEBUG_BUILD && debug.error('Error ending span in resolve callback:', e); @@ -300,7 +302,6 @@ function _wrapSingleQueryHandle( span.setAttribute('db.response.status_code', rejectArgs?.[0]?.code || 'unknown'); span.setAttribute('error.type', rejectArgs?.[0]?.name || 'unknown'); - _setOperationName(span, sanitizedSqlQuery); span.end(); } catch (e) { DEBUG_BUILD && debug.error('Error ending span in reject callback:', e); @@ -436,20 +437,17 @@ export function _setConnectionAttributes(span: Span, connectionContext: Postgres } /** - * Extracts DB operation name from SQL query and sets it on the span. + * Extracts the DB operation name from a SQL query, preferring the server-reported `command`. * * @internal Exported for the orchestrion (diagnostics-channel) integration. */ -export function _setOperationName(span: Span, sanitizedQuery: string | undefined, command?: string): void { +export function _getOperationName(sanitizedQuery: string | undefined, command?: string): string | undefined { if (command) { - span.setAttribute('db.operation.name', command); - return; + return command; } // Fallback: extract operation from the SQL query const operationMatch = sanitizedQuery?.match(SQL_OPERATION_REGEX); - if (operationMatch?.[1]) { - span.setAttribute('db.operation.name', operationMatch[1].toUpperCase()); - } + return operationMatch?.[1]?.toUpperCase(); } /** diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 6288e5b01307..2bdb46e4a78a 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -32,7 +32,7 @@ export { _reconstructQuery as _INTERNAL_reconstructPostgresQuery, _buildConnectionContext as _INTERNAL_buildPostgresConnectionContext, _setConnectionAttributes as _INTERNAL_setPostgresConnectionAttributes, - _setOperationName as _INTERNAL_setPostgresOperationName, + _getOperationName as _INTERNAL_getPostgresOperationName, } from './integrations/postgresjs'; export type { PostgresConnectionContext } from './integrations/postgresjs'; export { getSqlQuerySummary as _INTERNAL_getSqlQuerySummary } from './utils/sql'; diff --git a/packages/core/test/lib/utils/sql.test.ts b/packages/core/test/lib/utils/sql.test.ts index 7e5bb2140c38..7f3c54fc1139 100644 --- a/packages/core/test/lib/utils/sql.test.ts +++ b/packages/core/test/lib/utils/sql.test.ts @@ -230,4 +230,8 @@ describe('getSqlQuerySummary', () => { expect(getSqlQuerySummary(query)).toBe(`SELECT ${table}`); }); }); + + it('returns empty srting for whitespace-only queries', () => { + expect(getSqlQuerySummary(' ')).toBe(''); + }); }); diff --git a/packages/server-utils/src/integrations/postgres-js.ts b/packages/server-utils/src/integrations/postgres-js.ts index 15426f75c7cd..c806400b3ab7 100644 --- a/packages/server-utils/src/integrations/postgres-js.ts +++ b/packages/server-utils/src/integrations/postgres-js.ts @@ -1,5 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -13,7 +14,7 @@ import { _INTERNAL_reconstructPostgresQuery, _INTERNAL_sanitizeSqlQuery, _INTERNAL_setPostgresConnectionAttributes, - _INTERNAL_setPostgresOperationName, + _INTERNAL_getPostgresOperationName, debug, defineIntegration, getClient, @@ -178,7 +179,8 @@ function wrapQuerySettlement(data: PostgresJsQueryContext, span: Span, sanitized markEnded(); try { const command = (resolveArgs[0] as { command?: string } | undefined)?.command; - _INTERNAL_setPostgresOperationName(span, sanitizedSqlQuery, command); + // Re-set the operation name with the server-reported command, which is more reliable than the query text. + span.setAttribute(DB_OPERATION_NAME, _INTERNAL_getPostgresOperationName(sanitizedSqlQuery, command)); span.end(); } catch (e) { DEBUG_BUILD && debug.error('[orchestrion:postgresjs] error ending span in resolve:', e); @@ -196,7 +198,6 @@ function wrapQuerySettlement(data: PostgresJsQueryContext, span: Span, sanitized span.setStatus({ code: SPAN_STATUS_ERROR, message: err?.message || 'unknown_error' }); span.setAttribute(DB_RESPONSE_STATUS_CODE, err?.code || 'unknown'); span.setAttribute(ERROR_TYPE, err?.name || 'unknown'); - _INTERNAL_setPostgresOperationName(span, sanitizedSqlQuery); span.end(); } catch (e) { DEBUG_BUILD && debug.error('[orchestrion:postgresjs] error ending span in reject:', e); @@ -271,12 +272,9 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { const fullQuery = _INTERNAL_reconstructPostgresQuery(query.strings); const sanitizedSqlQuery = _INTERNAL_sanitizeSqlQuery(fullQuery); - const client = getClient(); - // The query is already sanitized, so a string literal containing `from`/`join` can't leak a - // value into the summary. const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedSqlQuery); - // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used - // instead of the full statement, falling back to `{db.system.name}`. + + const client = getClient(); const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || 'postgres' : undefined; // `sentry.kind: client` matches the mysql/pg channel subscribers. @@ -289,6 +287,7 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { [DB_SYSTEM_NAME]: 'postgres', [DB_QUERY_TEXT]: sanitizedSqlQuery, [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: _INTERNAL_getPostgresOperationName(sanitizedSqlQuery), }, }); From 508ecc90d13c401982b8ede18315154f9fa20f6d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 10:34:26 +0200 Subject: [PATCH 3/8] make name code better readable --- packages/server-utils/src/integrations/postgres-js.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/integrations/postgres-js.ts b/packages/server-utils/src/integrations/postgres-js.ts index c806400b3ab7..b7b30fd8ee47 100644 --- a/packages/server-utils/src/integrations/postgres-js.ts +++ b/packages/server-utils/src/integrations/postgres-js.ts @@ -275,11 +275,15 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedSqlQuery); const client = getClient(); - const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || 'postgres' : undefined; + + const name = + client && hasSpanStreamingEnabled(client) + ? querySummary || 'postgres' + : sanitizedSqlQuery || 'postgresjs.query'; // `sentry.kind: client` matches the mysql/pg channel subscribers. const span = startInactiveSpan({ - name: streamedName || sanitizedSqlQuery || 'postgresjs.query', + name, op: 'db', attributes: { [SENTRY_KIND]: 'client', From dc4b39fb400e535cd9d2c6e2d761a27819a7c2c6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 10:49:28 +0200 Subject: [PATCH 4/8] another round of cleanup and setting attributes early --- packages/core/src/integrations/postgresjs.ts | 85 +++++++++---------- packages/core/src/server-exports.ts | 2 +- .../src/integrations/postgres-js.ts | 6 +- 3 files changed, 45 insertions(+), 48 deletions(-) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index 2b8a06adc436..c35d442fda48 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -8,11 +8,23 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../tracing'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { startSpanManual } from '../tracing/trace'; -import type { Span } from '../types/span'; +import type { Span, SpanAttributes } from '../types/span'; import { getSqlQuerySummary } from '../utils/sql'; import { debug } from '../utils/debug-logger'; import { isObjectLike } from '../utils/is'; import { getActiveSpan } from '../utils/spanUtils'; +import { + DB_NAMESPACE, + DB_OPERATION_NAME, + DB_QUERY_SUMMARY, + DB_QUERY_TEXT, + DB_SYSTEM_NAME, + SENTRY_OP, + SENTRY_ORIGIN, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import { DB } from '@sentry/conventions/op'; const SQL_OPERATION_REGEX = /^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i; @@ -235,34 +247,27 @@ function _wrapSingleQueryHandle( const client = getClient(); const querySummary = getSqlQuerySummary(sanitizedSqlQuery); + const name = + client && hasSpanStreamingEnabled(client) ? querySummary || 'postgres' : sanitizedSqlQuery || 'postgresjs.query'; + + const connectionContext = sqlInstance + ? ((sqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as PostgresConnectionContext | undefined) + : undefined; + return startSpanManual( { - // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used - // instead of the full statement, falling back to `{db.system.name}`. - name: - client && hasSpanStreamingEnabled(client) - ? querySummary || 'postgres' - : sanitizedSqlQuery || 'postgresjs.query', - op: 'db', + name, + attributes: { + [SENTRY_OP]: DB, + [SENTRY_ORIGIN]: 'auto.db.postgresjs', + [DB_SYSTEM_NAME]: 'postgres', + [DB_QUERY_TEXT]: sanitizedSqlQuery, + [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: _getOperationName(sanitizedSqlQuery), + ...(connectionContext && _getConnectionAttributes(connectionContext)), + }, }, (span: Span) => { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.postgresjs'); - - span.setAttributes({ - 'db.system.name': 'postgres', - 'db.query.text': sanitizedSqlQuery, - 'db.query.summary': querySummary, - 'db.operation.name': _getOperationName(sanitizedSqlQuery), - }); - - const connectionContext = sqlInstance - ? ((sqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as - | PostgresConnectionContext - | undefined) - : undefined; - - _setConnectionAttributes(span, connectionContext); - if (options.requestHook) { try { options.requestHook(span, sanitizedSqlQuery, connectionContext); @@ -280,7 +285,7 @@ function _wrapSingleQueryHandle( queryWithCallbacks.resolve = new Proxy(queryWithCallbacks.resolve as (...args: unknown[]) => unknown, { apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { try { - // Reset with the server-reported command, which is more reliable than the query text. + // Re-set the operation name with the server-reported command, which is more reliable than the query text. span.setAttribute('db.operation.name', _getOperationName(sanitizedSqlQuery, resolveArgs?.[0]?.command)); span.end(); } catch (e) { @@ -412,28 +417,18 @@ export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { } /** - * Sets connection context attributes on a span. + * Returns connection context attributes. * * @internal Exported for the orchestrion (diagnostics-channel) integration. */ -export function _setConnectionAttributes(span: Span, connectionContext: PostgresConnectionContext | undefined): void { - if (!connectionContext) { - return; - } - if (connectionContext.ATTR_DB_NAMESPACE) { - span.setAttribute('db.namespace', connectionContext.ATTR_DB_NAMESPACE); - } - if (connectionContext.ATTR_SERVER_ADDRESS) { - span.setAttribute('server.address', connectionContext.ATTR_SERVER_ADDRESS); - } - if (connectionContext.ATTR_SERVER_PORT !== undefined) { - // Port is stored as string in PostgresConnectionContext for requestHook backwards compatibility, - // but semantic conventions expect port as a number for span attributes - const portNumber = parseInt(connectionContext.ATTR_SERVER_PORT, 10); - if (!isNaN(portNumber)) { - span.setAttribute('server.port', portNumber); - } - } +export function _getConnectionAttributes(connectionContext: PostgresConnectionContext): SpanAttributes { + const portNumber = connectionContext.ATTR_SERVER_PORT ? parseInt(connectionContext.ATTR_SERVER_PORT, 10) : undefined; + + return { + [DB_NAMESPACE]: connectionContext.ATTR_DB_NAMESPACE, + [SERVER_ADDRESS]: connectionContext.ATTR_SERVER_ADDRESS, + ...(portNumber !== undefined && !isNaN(portNumber) && { [SERVER_PORT]: portNumber }), + }; } /** diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 2bdb46e4a78a..ed17b27df890 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -31,7 +31,7 @@ export { _sanitizeSqlQuery as _INTERNAL_sanitizeSqlQuery, _reconstructQuery as _INTERNAL_reconstructPostgresQuery, _buildConnectionContext as _INTERNAL_buildPostgresConnectionContext, - _setConnectionAttributes as _INTERNAL_setPostgresConnectionAttributes, + _getConnectionAttributes as _INTERNAL_getConnectionAttributes, _getOperationName as _INTERNAL_getPostgresOperationName, } from './integrations/postgresjs'; export type { PostgresConnectionContext } from './integrations/postgresjs'; diff --git a/packages/server-utils/src/integrations/postgres-js.ts b/packages/server-utils/src/integrations/postgres-js.ts index b7b30fd8ee47..60ef81b9f946 100644 --- a/packages/server-utils/src/integrations/postgres-js.ts +++ b/packages/server-utils/src/integrations/postgres-js.ts @@ -13,7 +13,7 @@ import { _INTERNAL_getSqlQuerySummary, _INTERNAL_reconstructPostgresQuery, _INTERNAL_sanitizeSqlQuery, - _INTERNAL_setPostgresConnectionAttributes, + _INTERNAL_getConnectionAttributes, _INTERNAL_getPostgresOperationName, debug, defineIntegration, @@ -129,7 +129,9 @@ function setConnectionAttributes(span: Span, query: PostgresQuery, context: Post return; } queryRecord[CONNECTION_ATTRS_SET] = true; - _INTERNAL_setPostgresConnectionAttributes(span, context); + if (context) { + span.setAttributes(_INTERNAL_getConnectionAttributes(context)); + } } /** From 9898230ffc23ec96f4ef64af461317552b9e7807 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 11:02:03 +0200 Subject: [PATCH 5/8] lint and cleanup --- .../suites/tracing/postgres-streamed/test.ts | 2 +- packages/core/src/integrations/postgresjs.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 8921cd09a418..8684a4122518 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -100,7 +100,6 @@ function expectedDbSpan({ host?: string; origin?: string; }): unknown { - // The name of a query span is its `db.query.summary`, which is reported as an attribute too. const attributes: Record = { ...COMMON_DB_ATTRIBUTES, 'server.address': { @@ -118,6 +117,7 @@ function expectedDbSpan({ type: 'string', value: statement, }; + // The name of a db query span is its `db.query.summary` attribute attributes['db.query.summary'] = { type: 'string', value: name, diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index c35d442fda48..3f30d0e3e04a 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -4,7 +4,6 @@ import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../tracing'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { startSpanManual } from '../tracing/trace'; From 6feaf5544a477770e132a994fdcaa791f357fbd6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 12:28:05 +0200 Subject: [PATCH 6/8] more cleanup, add postgres.js streamed span tests --- .../postgresjs-streamed/docker-compose.yml | 18 ++ .../instrument-requestHook.mjs | 26 ++ .../postgresjs-streamed/instrument.mjs | 9 + .../scenario-requestHook.mjs | 39 +++ .../postgresjs-streamed/scenario-unsafe.mjs | 37 +++ .../postgresjs-streamed/scenario-url.mjs | 75 +++++ .../tracing/postgresjs-streamed/scenario.mjs | 74 +++++ .../tracing/postgresjs-streamed/test.ts | 305 ++++++++++++++++++ packages/core/src/integrations/postgresjs.ts | 10 +- packages/core/src/tracing/spans/spanNames.ts | 6 - .../src/integrations/postgres-js.ts | 13 +- .../server-utils/src/integrations/postgres.ts | 12 +- 12 files changed, 603 insertions(+), 21 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument-requestHook.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-requestHook.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-unsafe.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-url.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/docker-compose.yml new file mode 100644 index 000000000000..e3e13e347bf6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.9' + +services: + db: + image: postgres:13 + restart: always + ports: + - '5446:5432' + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U test -d test_db'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument-requestHook.mjs new file mode 100644 index 000000000000..a95f4861f159 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument-requestHook.mjs @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +const requestHook = (span, sanitizedSqlQuery, connectionContext) => { + // Add custom attributes to demonstrate requestHook functionality. + // Streamed spans carry no `extra`, so the connection context is asserted via span attributes + // rather than `Sentry.setExtra` (as the static-lifecycle suite does). + span.setAttributes({ + 'custom.requestHook': 'called', + 'custom.requestHook.query': sanitizedSqlQuery, + 'custom.requestHook.database': connectionContext?.ATTR_DB_NAMESPACE, + 'custom.requestHook.host': connectionContext?.ATTR_SERVER_ADDRESS, + 'custom.requestHook.port': connectionContext?.ATTR_SERVER_PORT, + }); +}; + +// `postgresJsIntegration()` is the diagnostics-channel implementation by default; it forwards the +// `requestHook` to the channel subscriber. +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.postgresJsIntegration({ requestHook })], + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-requestHook.mjs new file mode 100644 index 000000000000..d6c2afe40e13 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-requestHook.mjs @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/node'; +import { uuid4 } from '@sentry/core/server'; +import postgres from 'postgres'; +import { waitForConnection } from '@sentry-internal/node-integration-tests'; + +const sql = postgres({ port: 5446, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await waitForConnection(() => sql`SELECT 1`); + await sql` + CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); + `; + + const email = `${uuid4()}@domain.com`; + await sql` + INSERT INTO "User" ("email", "name") VALUES (${email}, 'tim'); + `; + + await sql` + SELECT * FROM "User" WHERE "email" = ${email}; + `; + } finally { + await sql` + DROP TABLE "User"; + `; + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-unsafe.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-unsafe.mjs new file mode 100644 index 000000000000..84e65e911b20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-unsafe.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import { uuid4 } from '@sentry/core/server'; +import postgres from 'postgres'; +import { waitForConnection } from '@sentry-internal/node-integration-tests'; + +// Test with plain object options +const sql = postgres({ port: 5446, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await waitForConnection(() => sql`SELECT 1`); + // Test sql.unsafe() - this was not being instrumented before the fix + await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); + + const email = `${uuid4()}@domain.com`; + await sql.unsafe('INSERT INTO "User" ("email") VALUES ($1)', [email]); + + await sql.unsafe('SELECT * FROM "User" WHERE "email" = $1', [email]); + + await sql.unsafe('DROP TABLE "User"'); + + // This will be captured as an error as the table no longer exists + await sql.unsafe('SELECT * FROM "User"'); + } finally { + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-url.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-url.mjs new file mode 100644 index 000000000000..6df0f104e215 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario-url.mjs @@ -0,0 +1,75 @@ +import * as Sentry from '@sentry/node'; +import { uuid4 } from '@sentry/core/server'; +import postgres from 'postgres'; +import { waitForConnection } from '@sentry-internal/node-integration-tests'; + +// Test URL-based initialization - this is the common pattern that was causing the regression +const sql = postgres('postgres://test:test@localhost:5446/test_db'); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await waitForConnection(() => sql`SELECT 1`); + await sql` + CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); + `; + + const email = `${uuid4()}@domain.com`; + await sql` + INSERT INTO "User" ("email", "name") VALUES (${email}, 'tim'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = ${email}; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = ${email}; + `; + + // Test parameterized queries + await sql` + SELECT * FROM "User" WHERE "email" = ${email} AND "name" = ${'Foo'}; + `; + + // Test DELETE operation + await sql` + DELETE FROM "User" WHERE "email" = ${email}; + `; + + // Test INSERT with RETURNING + await sql` + INSERT INTO "User" ("email", "name") VALUES (${email}, 'Test User') RETURNING *; + `; + + // Test cursor-based queries + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + // Test multiple rows at once + await sql` + SELECT * FROM "User" LIMIT 10; + `; + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = ${email}; + `; + } finally { + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario.mjs new file mode 100644 index 000000000000..f011356e1710 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/scenario.mjs @@ -0,0 +1,74 @@ +import * as Sentry from '@sentry/node'; +import { uuid4 } from '@sentry/core/server'; +import postgres from 'postgres'; +import { waitForConnection } from '@sentry-internal/node-integration-tests'; + +const sql = postgres({ port: 5446, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await waitForConnection(() => sql`SELECT 1`); + await sql` + CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); + `; + + const email = `${uuid4()}@domain.com`; + await sql` + INSERT INTO "User" ("email", "name") VALUES (${email}, 'tim'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = ${email}; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = ${email}; + `; + + // Test parameterized queries + await sql` + SELECT * FROM "User" WHERE "email" = ${email} AND "name" = ${'Foo'}; + `; + + // Test DELETE operation + await sql` + DELETE FROM "User" WHERE "email" = ${email}; + `; + + // Test INSERT with RETURNING + await sql` + INSERT INTO "User" ("email", "name") VALUES (${email}, 'Test User') RETURNING *; + `; + + // Test cursor-based queries + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + // Test multiple rows at once + await sql` + SELECT * FROM "User" LIMIT 10; + `; + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = ${email}; + `; + } finally { + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts new file mode 100644 index 000000000000..e77702f8242b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs-streamed/test.ts @@ -0,0 +1,305 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; + +/** + * Streamed span attributes are `{ value, type }` objects, unlike transaction span `data`, + * which stores values directly. + */ +function attr(value: unknown): unknown { + return expect.objectContaining({ value }); +} + +/** + * The attributes every query span carries, regardless of the statement. + */ +const COMMON_DB_ATTRIBUTES = { + 'db.namespace': attr('test_db'), + 'db.system.name': attr('postgres'), + 'sentry.op': attr('db'), + 'sentry.origin': attr('auto.db.postgresjs'), + 'server.address': attr('localhost'), + 'server.port': attr(5446), +}; + +/** + * Builds the expectation for one streamed query span. + * + * `name` is asserted separately from `db.query.summary` even though the two always match: the point + * of this suite is that the span name is the summary and never the statement, so both sides of that + * equality have to be pinned. `statement` is the sanitized `db.query.text`, which does keep the full + * (parameterized) SQL. + */ +function expectedQuerySpan({ + name, + statement, + operation, + extraAttributes = {}, +}: { + name: string; + statement: string; + operation: string; + extraAttributes?: Record; +}): unknown { + return expect.objectContaining({ + name, + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + ...COMMON_DB_ATTRIBUTES, + 'db.operation.name': attr(operation), + 'db.query.text': attr(statement), + 'db.query.summary': attr(name), + ...extraAttributes, + }), + }); +} + +const CREATE_USER_TABLE_STATEMENT = + 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))'; + +function getDbSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpanContainer['items'] { + return container.items.filter(item => item.attributes['sentry.op']?.value === 'db'); +} + +describeWithDockerCompose('postgresjs auto instrumentation (streamed)', { workingDirectory: [__dirname] }, () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('basic', () => { + const EXPECTED_SPANS = { + items: expect.arrayContaining([ + expect.objectContaining({ name: 'Test Transaction', is_segment: true }), + expectedQuerySpan({ + name: 'CREATE TABLE "User"', + statement: CREATE_USER_TABLE_STATEMENT, + operation: 'CREATE TABLE', + }), + expectedQuerySpan({ + name: 'INSERT "User"', + statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)', + operation: 'INSERT', + }), + expectedQuerySpan({ + name: 'UPDATE "User"', + statement: 'UPDATE "User" SET "name" = ? WHERE "email" = $1', + operation: 'UPDATE', + }), + expectedQuerySpan({ + name: 'SELECT "User"', + statement: 'SELECT * FROM "User" WHERE "email" = $1', + operation: 'SELECT', + }), + // Parameterized query test - verifies that tagged template queries with interpolations + // are properly reconstructed with $1, $2 placeholders which are PRESERVED per OTEL spec + // (PostgreSQL $n placeholders indicate parameterized queries that don't leak sensitive data) + expectedQuerySpan({ + name: 'SELECT "User"', + statement: 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', + operation: 'SELECT', + }), + expectedQuerySpan({ + name: 'DELETE "User"', + statement: 'DELETE FROM "User" WHERE "email" = $1', + operation: 'DELETE', + }), + expectedQuerySpan({ + name: 'INSERT "User"', + statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?) RETURNING *', + operation: 'INSERT', + }), + // The cursor query summarizes to the set-returning function it selects from. + expectedQuerySpan({ + name: 'SELECT generate_series', + statement: 'SELECT * from generate_series(?,?) as x', + operation: 'SELECT', + }), + expectedQuerySpan({ + name: 'DROP TABLE "User"', + statement: 'DROP TABLE "User"', + operation: 'DROP TABLE', + }), + // The table is gone by now, so this one fails. + expect.objectContaining({ + name: 'SELECT "User"', + is_segment: false, + status: 'error', + attributes: expect.objectContaining({ + ...COMMON_DB_ATTRIBUTES, + 'db.operation.name': attr('SELECT'), + 'db.query.text': attr('SELECT * FROM "User" WHERE "email" = $1'), + 'db.query.summary': attr('SELECT "User"'), + 'db.response.status_code': attr('42P01'), + 'error.type': attr('PostgresError'), + 'sentry.status.message': attr('relation "User" does not exist'), + }), + }), + ]), + }; + + const EXPECTED_ERROR_EVENT = { + event_id: expect.any(String), + contexts: { + trace: { + trace_id: expect.any(String), + span_id: expect.any(String), + }, + }, + exception: { + values: [ + { + type: 'PostgresError', + value: 'relation "User" does not exist', + stacktrace: expect.objectContaining({ + frames: expect.arrayContaining([ + expect.objectContaining({ + function: 'handle', + // Module differs between CJS (`postgres.cjs.src:connection`) and ESM (`postgres.src:connection`) + module: expect.stringMatching(/^postgres(\.cjs)?\.src:connection$/), + filename: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }), + }, + ], + }, + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('should auto-instrument `postgres` package', { timeout: 90_000 }, async () => { + await createTestRunner() + .expect({ + span: container => { + expect(container).toMatchObject(EXPECTED_SPANS); + + // The assertions above only cover the queries the scenario issues itself. postgres.js + // also runs internal ones (e.g. the `pg_catalog` type lookup), so guard the invariant + // across every query span: the name is the summary, never the statement. + const dbSpans = getDbSpans(container); + expect(dbSpans.length).toBeGreaterThan(0); + for (const span of dbSpans) { + expect(span.name).toBe(span.attributes['db.query.summary']?.value); + } + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + // The error event is captured via an unhandled rejection processed on a later tick than + // the spans, so the two envelopes can reach the transport in either order. + .unordered() + .start() + .completed(); + }); + }); + }); + + describe('requestHook', () => { + const EXPECTED_SPANS = { + items: expect.arrayContaining( + [ + { name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT, operation: 'CREATE TABLE' }, + { + name: 'INSERT "User"', + statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)', + operation: 'INSERT', + }, + { name: 'SELECT "User"', statement: 'SELECT * FROM "User" WHERE "email" = $1', operation: 'SELECT' }, + { name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"', operation: 'DROP TABLE' }, + ].map(({ name, statement, operation }) => + expectedQuerySpan({ + name, + statement, + operation, + extraAttributes: { + 'custom.requestHook': attr('called'), + 'custom.requestHook.query': attr(statement), + 'custom.requestHook.database': attr('test_db'), + 'custom.requestHook.host': attr('localhost'), + 'custom.requestHook.port': attr('5446'), + }, + }), + ), + ), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-requestHook.mjs', + 'instrument-requestHook.mjs', + (createTestRunner, test) => { + test('should call requestHook when provided', { timeout: 90_000 }, async () => { + await createTestRunner().expect({ span: EXPECTED_SPANS }).start().completed(); + }); + }, + ); + }); + + describe('url initialization', () => { + const EXPECTED_SPANS = { + items: expect.arrayContaining([ + expectedQuerySpan({ + name: 'CREATE TABLE "User"', + statement: CREATE_USER_TABLE_STATEMENT, + operation: 'CREATE TABLE', + }), + expectedQuerySpan({ + name: 'INSERT "User"', + statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, ?)', + operation: 'INSERT', + }), + expectedQuerySpan({ + name: 'SELECT "User"', + statement: 'SELECT * FROM "User" WHERE "email" = $1', + operation: 'SELECT', + }), + expectedQuerySpan({ + name: 'DELETE "User"', + statement: 'DELETE FROM "User" WHERE "email" = $1', + operation: 'DELETE', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-url.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('should instrument postgres package with URL initialization', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed(); + }); + }); + }); + + describe('sql.unsafe()', () => { + const EXPECTED_SPANS = { + items: expect.arrayContaining([ + expectedQuerySpan({ + name: 'CREATE TABLE "User"', + statement: 'CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))', + operation: 'CREATE TABLE', + }), + // sql.unsafe() with $1 placeholders - preserved per OTEL spec + expectedQuerySpan({ + name: 'INSERT "User"', + statement: 'INSERT INTO "User" ("email") VALUES ($1)', + operation: 'INSERT', + }), + expectedQuerySpan({ + name: 'SELECT "User"', + statement: 'SELECT * FROM "User" WHERE "email" = $1', + operation: 'SELECT', + }), + expectedQuerySpan({ + name: 'DROP TABLE "User"', + statement: 'DROP TABLE "User"', + operation: 'DROP TABLE', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('should instrument sql.unsafe() queries', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ span: EXPECTED_SPANS }).start().completed(); + }); + }); + }); +}); diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index 3f30d0e3e04a..52b828572ccb 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -246,13 +246,15 @@ function _wrapSingleQueryHandle( const client = getClient(); const querySummary = getSqlQuerySummary(sanitizedSqlQuery); - const name = - client && hasSpanStreamingEnabled(client) ? querySummary || 'postgres' : sanitizedSqlQuery || 'postgresjs.query'; - const connectionContext = sqlInstance ? ((sqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as PostgresConnectionContext | undefined) : undefined; + const name = + client && hasSpanStreamingEnabled(client) + ? querySummary || connectionContext?.ATTR_DB_NAMESPACE || 'postgres' + : sanitizedSqlQuery || 'postgresjs.query'; + return startSpanManual( { name, @@ -285,7 +287,7 @@ function _wrapSingleQueryHandle( apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { try { // Re-set the operation name with the server-reported command, which is more reliable than the query text. - span.setAttribute('db.operation.name', _getOperationName(sanitizedSqlQuery, resolveArgs?.[0]?.command)); + span.setAttribute(DB_OPERATION_NAME, _getOperationName(sanitizedSqlQuery, resolveArgs?.[0]?.command)); span.end(); } catch (e) { DEBUG_BUILD && debug.error('Error ending span in resolve callback:', e); diff --git a/packages/core/src/tracing/spans/spanNames.ts b/packages/core/src/tracing/spans/spanNames.ts index f8c3fd086d1d..88eb200ae973 100644 --- a/packages/core/src/tracing/spans/spanNames.ts +++ b/packages/core/src/tracing/spans/spanNames.ts @@ -14,12 +14,6 @@ export const PAGELOAD_SPAN_NAME_FALLBACK = 'Pageload'; */ export const NAVIGATION_SPAN_NAME_FALLBACK = 'Navigation'; -/** - * Fallback name for db spans when no better-suited span name is available. - * @see https://getsentry.github.io/sentry-conventions/names/#db-queries - */ -export const DB_SPAN_NAME_FALLBACK = 'Database operation'; - /** * Fallback name for gen_ai agent spans when no better-suited span name is available. * @see https://getsentry.github.io/sentry-conventions/names/#gen_ai-agent diff --git a/packages/server-utils/src/integrations/postgres-js.ts b/packages/server-utils/src/integrations/postgres-js.ts index 60ef81b9f946..2a6551d30728 100644 --- a/packages/server-utils/src/integrations/postgres-js.ts +++ b/packages/server-utils/src/integrations/postgres-js.ts @@ -35,6 +35,8 @@ const INTEGRATION_NAME = 'PostgresJs' as const; const ORIGIN = 'auto.db.postgresjs'; +const DB_SYSTEM_NAME_POSTGRES = 'postgres'; + // Not part of `@sentry/conventions`, so we keep it inline (matches older OTel // `PostgresJsInstrumentation`). const DB_RESPONSE_STATUS_CODE = 'db.response.status_code'; @@ -278,9 +280,13 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { const client = getClient(); + // Single-endpoint fallback: resolve context now so the span name and `requestHook` have + // it, and the first-query-per-connection (bare `execute`) path still gets attrs. + const context = resolveSingleEndpoint(); + const name = client && hasSpanStreamingEnabled(client) - ? querySummary || 'postgres' + ? querySummary || context?.ATTR_DB_NAMESPACE || DB_SYSTEM_NAME_POSTGRES : sanitizedSqlQuery || 'postgresjs.query'; // `sentry.kind: client` matches the mysql/pg channel subscribers. @@ -290,7 +296,7 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { attributes: { [SENTRY_KIND]: 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [DB_SYSTEM_NAME]: 'postgres', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_POSTGRES, [DB_QUERY_TEXT]: sanitizedSqlQuery, [DB_QUERY_SUMMARY]: querySummary, [DB_OPERATION_NAME]: _INTERNAL_getPostgresOperationName(sanitizedSqlQuery), @@ -300,9 +306,6 @@ function instrumentPostgresJs(options: PostgresJsIntegrationOptions): void { // Stash for the `execute`/`connect` channels to attach per-connection attributes. (query as Record)[QUERY_SPAN] = span; - // Single-endpoint fallback: resolve context now so `requestHook` has it - // and the first-query-per-connection (bare `execute`) path still gets attrs. - const context = resolveSingleEndpoint(); if (context) { setConnectionAttributes(span, query, context); } diff --git a/packages/server-utils/src/integrations/postgres.ts b/packages/server-utils/src/integrations/postgres.ts index 3ee5b81458aa..6dc52a70c93f 100644 --- a/packages/server-utils/src/integrations/postgres.ts +++ b/packages/server-utils/src/integrations/postgres.ts @@ -184,14 +184,14 @@ function querySpanOptions(ctx: PgChannelContext): { name: string; op: string; at const querySummary = queryConfig?.text ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(queryConfig.text)) : undefined; - // The description is the SQL statement. With span streaming, span names have to be low cardinality, - // so `{db.query.summary}` is used instead, falling back to `{db.namespace}` and then - // `{db.system.name}` when there is no statement to summarize. - const streamedName = - client && hasSpanStreamingEnabled(client) ? querySummary || params.database || DB_SYSTEM_POSTGRESQL : undefined; + + const name = + client && hasSpanStreamingEnabled(client) + ? querySummary || params.database || DB_SYSTEM_POSTGRESQL + : (queryConfig?.text ?? SPAN_QUERY_FALLBACK); return { - name: streamedName ?? queryConfig?.text ?? SPAN_QUERY_FALLBACK, + name, op: 'db', attributes: { ...getConnectionAttributes(params), From 3bb7e14c184eaf2234cc2a1347f0f67c978f6d2c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 13:38:40 +0200 Subject: [PATCH 7/8] avoid accidentally deleting attributes --- packages/core/src/integrations/postgresjs.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index 52b828572ccb..16d931b44725 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -423,13 +423,23 @@ export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { * @internal Exported for the orchestrion (diagnostics-channel) integration. */ export function _getConnectionAttributes(connectionContext: PostgresConnectionContext): SpanAttributes { + const attributes: SpanAttributes = {}; + const portNumber = connectionContext.ATTR_SERVER_PORT ? parseInt(connectionContext.ATTR_SERVER_PORT, 10) : undefined; + const dbNamespace = connectionContext.ATTR_DB_NAMESPACE; + const serverAddress = connectionContext.ATTR_SERVER_ADDRESS; - return { - [DB_NAMESPACE]: connectionContext.ATTR_DB_NAMESPACE, - [SERVER_ADDRESS]: connectionContext.ATTR_SERVER_ADDRESS, - ...(portNumber !== undefined && !isNaN(portNumber) && { [SERVER_PORT]: portNumber }), - }; + if (dbNamespace) { + attributes[DB_NAMESPACE] = dbNamespace; + } + if (serverAddress) { + attributes[SERVER_ADDRESS] = serverAddress; + } + if (portNumber !== undefined && !isNaN(portNumber)) { + attributes[SERVER_PORT] = portNumber; + } + + return attributes; } /** From 058f89878da33f761bdd73bb0ef965bb0099af60 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 14:36:54 +0200 Subject: [PATCH 8/8] fix lint --- packages/core/src/integrations/postgresjs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index 16d931b44725..a9074e3ddd44 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -1,6 +1,7 @@ // Portable instrumentation for https://github.com/porsager/postgres // This can be used in any environment (Node.js, Cloudflare Workers, etc.) // without depending on OpenTelemetry module hooking. +/* eslint-disable max-lines */ import { getClient } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build';