diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts index 322ffa4c4e4b..efb5b373b705 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentD1.ts @@ -11,7 +11,11 @@ import { SPAN_STATUS_ERROR, startSpan, } from '@sentry/core'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, +} from '@sentry/core/server'; import { ensureInstrumented } from '../../instrument'; // Patching is based on internal Cloudflare D1 API @@ -134,7 +138,8 @@ function createStartSpanOptions(query: string, type: D1QueryType): StartSpanOpti const querySummary = query ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(query)) : undefined; const client = getClient(); - const name = client && hasSpanStreamingEnabled(client) ? querySummary || 'cloudflare-d1' : query; + const queryText = filterCollectedDbQueryText(query, undefined, client); + const name = client && hasSpanStreamingEnabled(client) ? querySummary || 'cloudflare-d1' : queryText; return { name, @@ -142,7 +147,7 @@ function createStartSpanOptions(query: string, type: D1QueryType): StartSpanOpti [SENTRY_OP]: DB_QUERY, 'db.system.name': 'cloudflare-d1', 'db.operation.name': type, - 'db.query.text': query, + 'db.query.text': queryText, 'db.query.summary': querySummary, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', }, diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts index b59f0fc5918a..7a42f748cabd 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentD1.test.ts @@ -116,6 +116,7 @@ describe('instrumentD1', () => { beforeEach(() => { getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }), + getDataCollectionOptions: () => ({ databaseQueryData: true }), } as unknown as ReturnType); }); diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 2d2d76127f6a..965f7404c98b 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -31,6 +31,7 @@ export { getSqlQuerySummary as _INTERNAL_getSqlQuerySummary, sanitizeSqlQuery as _INTERNAL_sanitizeSqlQuery, } from './utils/sql'; +export { filterCollectedDbQueryText } from './utils/data-collection/filterCollectedDbQueryText'; export type { SqlDialect } from './utils/sql'; export { patchHttpModuleClient } from './integrations/http/client-patch'; diff --git a/packages/core/src/utils/data-collection/filterCollectedDbQueryText.ts b/packages/core/src/utils/data-collection/filterCollectedDbQueryText.ts new file mode 100644 index 000000000000..6788add2b2e9 --- /dev/null +++ b/packages/core/src/utils/data-collection/filterCollectedDbQueryText.ts @@ -0,0 +1,35 @@ +import type { Client } from '../../client'; +import { getClient } from '../../currentScopes'; +import type { SqlDialect } from '../sql'; +import { sanitizeSqlQuery } from '../sql'; + +/** + * Applies `dataCollection.databaseQueryData` to a SQL statement the SDK collected itself, for use as + * `db.query.text`. + * + * A statement can carry inline literal values (`WHERE email = 'jane@example.com'`), which the spec + * counts as database query data. Sanitized statements are not gated, so with the option off the + * literals are replaced with `?` rather than the attribute being dropped — the shape of the query + * stays available for debugging. + * + * Pass the `client` the statement belongs to whenever one is at hand; falling back to `getClient()` + * resolves against the current scope, which is the wrong client in a multi-client setup. + */ +export function filterCollectedDbQueryText(query: string, dialect?: SqlDialect, client?: Client): string; +export function filterCollectedDbQueryText( + query: string | undefined, + dialect?: SqlDialect, + client?: Client, +): string | undefined; +export function filterCollectedDbQueryText( + query: string | undefined, + dialect?: SqlDialect, + client?: Client, +): string | undefined { + if (query === undefined) { + return undefined; + } + // Instrumentation can run before a client exists; collecting is the documented default. + const collect = (client ?? getClient())?.getDataCollectionOptions().databaseQueryData !== false; + return collect ? query : sanitizeSqlQuery(query, dialect); +} diff --git a/packages/core/test/lib/utils/data-collection/filterCollectedDbQueryText.test.ts b/packages/core/test/lib/utils/data-collection/filterCollectedDbQueryText.test.ts new file mode 100644 index 000000000000..13e42a36e92f --- /dev/null +++ b/packages/core/test/lib/utils/data-collection/filterCollectedDbQueryText.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import type { Client } from '../../../../src/client'; +import type { DataCollection } from '../../../../src/types/datacollection'; +import { filterCollectedDbQueryText } from '../../../../src/utils/data-collection/filterCollectedDbQueryText'; +import { resolveDataCollectionOptions } from '../../../../src/utils/data-collection/resolveDataCollectionOptions'; + +function mockClient(dataCollection?: DataCollection): Client { + return { getDataCollectionOptions: () => resolveDataCollectionOptions({ dataCollection }) } as unknown as Client; +} + +describe('filterCollectedDbQueryText', () => { + it('returns undefined for an absent statement', () => { + expect(filterCollectedDbQueryText(undefined, undefined, mockClient())).toBeUndefined(); + }); + + it('keeps inline literals by default', () => { + expect( + filterCollectedDbQueryText("SELECT * FROM users WHERE email = 'jane@example.com'", undefined, mockClient()), + ).toBe("SELECT * FROM users WHERE email = 'jane@example.com'"); + }); + + it('sanitizes inline literals when databaseQueryData is off', () => { + expect( + filterCollectedDbQueryText("SELECT * FROM users WHERE email = 'jane@example.com'", undefined, { + ...mockClient({ databaseQueryData: false }), + }), + ).toBe('SELECT * FROM users WHERE email = ?'); + }); +}); diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts index 9bb3d25e4fe1..6532ba9cbbad 100644 --- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -13,7 +13,12 @@ import { startSpan, type StartSpanOptions, } from '@sentry/core'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, flushIfServerless } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, + flushIfServerless, +} from '@sentry/core/server'; import type { Database, PreparedStatement } from 'db0'; import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data'; import { DB_NAMESPACE, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME } from '@sentry/conventions/attributes'; @@ -260,7 +265,7 @@ function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpa return { name, attributes: { - [DB_QUERY_TEXT]: query, + [DB_QUERY_TEXT]: filterCollectedDbQueryText(query), [DB_QUERY_SUMMARY]: querySummary, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, [SENTRY_OP]: DB_QUERY, diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index 74f925a8d4ca..88feb533e144 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -33,7 +33,11 @@ import { DB } from '@sentry/conventions/op'; import { DEBUG_BUILD } from '../debug-build'; import { CHANNELS } from '../orchestrion/channels'; import { bindTracingChannelToSpan } from '../tracing-channel'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, +} from '@sentry/core/server'; // NOTE: this uses the same name as the OTel integration by design. `@sentry/node`'s `knexIntegration` // picks this subscriber over the vendored OTel path when orchestrion injection is active. @@ -174,8 +178,9 @@ function subscribeQuery(): void { connection?.filename || connection?.database || extractDatabaseFromConnectionString(connectionString); const dbSystem = mapSystem(client?.driverName); - const dbStatement = query?.sql != null ? truncate(query.sql, MAX_QUERY_LENGTH) : undefined; const dialect = client?.driverName === 'mysql' || client?.driverName === 'mysql2' ? 'mysql' : undefined; + const dbStatement = + query?.sql != null ? filterCollectedDbQueryText(truncate(query.sql, MAX_QUERY_LENGTH), dialect) : undefined; const querySummary = dbStatement ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(dbStatement, dialect)) : undefined; diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts index 3876df576a0a..42879573882e 100644 --- a/packages/server-utils/src/integrations/mysql.ts +++ b/packages/server-utils/src/integrations/mysql.ts @@ -22,7 +22,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, +} from '@sentry/core/server'; import { CHANNELS } from '../orchestrion/channels'; import { bindTracingChannelToSpan } from '../tracing-channel'; import { mysqlModuleNames } from '../orchestrion/config/mysql'; @@ -91,10 +95,11 @@ function instrumentMysql(): void { const querySummary = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql, 'mysql')) : undefined; const client = getClient(); + const queryText = filterCollectedDbQueryText(sql, 'mysql', client); const name = client && hasSpanStreamingEnabled(client) ? querySummary || database || DB_SYSTEM_NAME_VALUE_MYSQL - : (sql ?? 'mysql.query'); + : (queryText ?? 'mysql.query'); return startInactiveSpan({ name, @@ -106,7 +111,7 @@ function instrumentMysql(): void { [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), ...(database ? { [DB_NAMESPACE]: database } : {}), ...(user ? { [DB_USER]: user } : {}), - ...(sql ? { [DB_QUERY_TEXT]: sql } : {}), + ...(queryText ? { [DB_QUERY_TEXT]: queryText } : {}), [DB_QUERY_SUMMARY]: querySummary, [SERVER_ADDRESS]: host, [SERVER_PORT]: portIsNumber ? portNumber : undefined, diff --git a/packages/server-utils/src/integrations/mysql2/index.ts b/packages/server-utils/src/integrations/mysql2/index.ts index e00783fc27bb..453dbb813ba7 100644 --- a/packages/server-utils/src/integrations/mysql2/index.ts +++ b/packages/server-utils/src/integrations/mysql2/index.ts @@ -9,7 +9,11 @@ import { startInactiveSpan, waitForTracingChannelBinding, } from '@sentry/core'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, +} from '@sentry/core/server'; import { subscribeMysql2DiagnosticChannels } from './mysql2-dc-subscriber'; import type { ChannelName } from '../../orchestrion/channels'; import { CHANNELS } from '../../orchestrion/channels'; @@ -89,10 +93,11 @@ function subscribeQueryChannel(channelName: ChannelName): void { : undefined; const client = getClient(); + const queryText = filterCollectedDbQueryText(statement, 'mysql', client); const name = client && hasSpanStreamingEnabled(client) ? querySummary || (connectionAttributes[DB_NAMESPACE] as string | undefined) || DB_SYSTEM_VALUE_MYSQL - : (statement ?? 'mysql2.query'); + : (queryText ?? 'mysql2.query'); return startInactiveSpan({ name, @@ -101,7 +106,7 @@ function subscribeQueryChannel(channelName: ChannelName): void { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SENTRY_OP]: DB, [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_MYSQL, - [DB_QUERY_TEXT]: statement || undefined, + [DB_QUERY_TEXT]: queryText || undefined, [DB_QUERY_SUMMARY]: querySummary, ...connectionAttributes, }, diff --git a/packages/server-utils/src/integrations/postgres.ts b/packages/server-utils/src/integrations/postgres.ts index 49ee295249a1..1633aedc327d 100644 --- a/packages/server-utils/src/integrations/postgres.ts +++ b/packages/server-utils/src/integrations/postgres.ts @@ -22,7 +22,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, +} from '@sentry/core/server'; import { CHANNELS } from '../orchestrion/channels'; import { bindTracingChannelToSpan } from '../tracing-channel'; import { pgModuleNames } from '../orchestrion/config/pg'; @@ -186,10 +190,11 @@ function querySpanOptions(ctx: PgChannelContext): { name: string; attributes: Sp ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(queryConfig.text)) : undefined; + const queryText = filterCollectedDbQueryText(queryConfig?.text, undefined, client); const name = client && hasSpanStreamingEnabled(client) ? querySummary || params.database || DB_SYSTEM_POSTGRESQL - : (queryConfig?.text ?? SPAN_QUERY_FALLBACK); + : (queryText ?? SPAN_QUERY_FALLBACK); return { name, @@ -197,7 +202,7 @@ function querySpanOptions(ctx: PgChannelContext): { name: string; attributes: Sp [SENTRY_OP]: DB, ...getConnectionAttributes(params), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [DB_QUERY_TEXT]: queryConfig?.text || undefined, + [DB_QUERY_TEXT]: queryText || undefined, [DB_QUERY_SUMMARY]: querySummary, [ATTR_PG_PLAN]: typeof queryConfig?.name === 'string' ? queryConfig.name : undefined, }, diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index b61e8a935242..9acd19d587fc 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -28,7 +28,11 @@ import { DB } from '@sentry/conventions/op'; import { CHANNELS } from '../orchestrion/channels'; import { tediousModuleNames } from '../orchestrion/config/tedious'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; -import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; +import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + filterCollectedDbQueryText, +} from '@sentry/core/server'; // NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active, // `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name). @@ -143,7 +147,7 @@ function subscribeQuery(channelName: string, operation: string): void { [DB_NAMESPACE]: databaseName, // `>=4` uses the `authentication` object; older versions expose `userName` directly. [DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName, - [DB_QUERY_TEXT]: sql, + [DB_QUERY_TEXT]: filterCollectedDbQueryText(sql), [DB_QUERY_SUMMARY]: querySummary, [ATTR_DB_SQL_TABLE]: request.table, [SERVER_ADDRESS]: connection.config?.server,