From 90c3bec888149380102d5995244c3fbd7ab2c012 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 1 Sep 2026 16:26:08 +0200 Subject: [PATCH 1/2] fix(core): Keep qualified table names in `db.query.summary` `getSqlQuerySummary` matched a quoted table reference with a regex that stopped after the first quoted identifier, so a schema-qualified name lost its table part (`SELECT ... FROM "public"."User"` summarized as `SELECT "public"`) and a JOIN of two tables in the same schema collapsed into two identical targets. The INSERT/UPDATE/DELETE/DDL branches used a different, whitespace-delimited pattern that kept the qualified name but split any quoted identifier containing a space (`INSERT INTO "my table"` summarized as `INSERT "my`). Both branches now build on one identifier pattern that treats each quoted or bare part as a unit and allows dot-qualification. This matters more now that the value is promoted into span names, where two tables that differ only by table part are otherwise indistinguishable. Refs #23676 Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/utils/sql.ts | 16 +++++++---- packages/core/test/lib/utils/sql.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/sql.ts b/packages/core/src/utils/sql.ts index cc6b5a672a8a..f9f2c02600f1 100644 --- a/packages/core/src/utils/sql.ts +++ b/packages/core/src/utils/sql.ts @@ -1,7 +1,13 @@ const MAX_SUMMARY_LENGTH = 255; -const TABLE_NAME_CHARS = /[^\s(,;)]+/; -const TABLE_NAME = TABLE_NAME_CHARS.source; +// A single identifier: quoted (`"..."`, `'...'`, or MySQL backticks) or bare. The quoted forms have +// to be matched as a unit, otherwise an identifier containing a space or a dot is cut in half. +const IDENTIFIER = '(?:"[^"]*"|\'[^\']*\'|`[^`]*`|[^\\s(,;).\'"`]+)'; + +// A table reference can be schema-qualified (`"public"."User"`, `db.schema.table`), with each part +// quoted independently. The whole qualified name is the summary target, since the schema is what +// distinguishes two same-named tables. +const TABLE_NAME = `${IDENTIFIER}(?:\\.${IDENTIFIER})*`; const DDL_RE = new RegExp( `^\\s*(?(?:CREATE|DROP)\\s+(?:TABLE|INDEX)|ALTER\\s+TABLE)(?:\\s+IF\\s+(?:NOT\\s+)?EXISTS)?\\s+(?${TABLE_NAME})`, @@ -27,8 +33,8 @@ const SELECT_RE = /^\s*\(?\s*(?SELECT)\b/i; const PRAGMA_RE = /^\s*(?PRAGMA)\s+(?\S+)/i; const TOKEN_RE = /\b(?:FROM|JOIN)\s+|\(\s*(SELECT)\b|\b(?:UNION|INTERSECT|EXCEPT|MINUS)\s+(?:ALL\s+)?(SELECT)\b/gi; -const QUOTED_OR_PLAIN_TABLE_RE = /^(?:"[^"]*"|'[^']*'|[^\s(,;)]+)/; -const COMMA_TABLE_RE = /^\s*,\s*((?:"[^"]*"|'[^']*'|[^\s(,;)]+))/; +const TABLE_REF_RE = new RegExp(`^${TABLE_NAME}`); +const COMMA_TABLE_RE = new RegExp(`^\\s*,\\s*(${TABLE_NAME})`); const SUBQUERY_SELECT_RE = /^\(\s*(SELECT)\b/i; /** @@ -117,7 +123,7 @@ function extractTableNames(sql: string): string[] { continue; } - const tableMatch = QUOTED_OR_PLAIN_TABLE_RE.exec(rest); + const tableMatch = TABLE_REF_RE.exec(rest); if (!tableMatch) continue; tables.push(tableMatch[0]); diff --git a/packages/core/test/lib/utils/sql.test.ts b/packages/core/test/lib/utils/sql.test.ts index b41463645870..3b33a2400c0c 100644 --- a/packages/core/test/lib/utils/sql.test.ts +++ b/packages/core/test/lib/utils/sql.test.ts @@ -213,6 +213,40 @@ describe('getSqlQuerySummary', () => { }); }); + describe('quoted and schema-qualified table names', () => { + it.each([ + ['SELECT * FROM "public"."User"', 'SELECT "public"."User"'], + ['DELETE FROM "public"."User"', 'DELETE "public"."User"'], + ['INSERT INTO "public"."User" (name) VALUES (?)', 'INSERT "public"."User"'], + ['UPDATE "public"."User" SET name = ?', 'UPDATE "public"."User"'], + ['CREATE TABLE "public"."User" (id INTEGER)', 'CREATE TABLE "public"."User"'], + ['SELECT * FROM public.User', 'SELECT public.User'], + ['SELECT * FROM `mydb`.`users`', 'SELECT `mydb`.`users`'], + ['SELECT * FROM "catalog"."public"."User"', 'SELECT "catalog"."public"."User"'], + ['SELECT * FROM "public".User', 'SELECT "public".User'], + ['SELECT * FROM public."User"', 'SELECT public."User"'], + ])('keeps the whole qualified name: %j => %j', (input, expected) => { + expect(getSqlQuerySummary(input)).toBe(expected); + }); + + it('keeps schema-qualified JOIN targets distinguishable', () => { + expect(getSqlQuerySummary('SELECT * FROM "public"."A" JOIN "public"."B" ON "A".id = "B"."a_id"')).toBe( + 'SELECT "public"."A" "public"."B"', + ); + }); + + it.each([ + ['SELECT * FROM "my table"', 'SELECT "my table"'], + ['INSERT INTO "my table" (id) VALUES (?)', 'INSERT "my table"'], + ['UPDATE "my table" SET id = ?', 'UPDATE "my table"'], + ['DELETE FROM "my table"', 'DELETE "my table"'], + ['CREATE TABLE "my table" (id INTEGER)', 'CREATE TABLE "my table"'], + ['SELECT * FROM "my schema"."my table"', 'SELECT "my schema"."my table"'], + ])('does not split identifiers containing spaces: %j => %j', (input, expected) => { + expect(getSqlQuerySummary(input)).toBe(expected); + }); + }); + describe('truncation', () => { it('truncates at 255 characters on a word boundary', () => { const longTable = 'a'.repeat(300); From 22dcddf03ecca8350c1026bab8828b8e068f7dbe Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 1 Sep 2026 16:37:56 +0200 Subject: [PATCH 2/2] fix(server-utils): Pass the SQL dialect when summarizing Prisma queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma is multi-connector but sanitized every statement as standard SQL. On MySQL and MariaDB a `"..."` run is a string literal rather than a quoted identifier, so it survived sanitization; a literal containing `FROM` or `JOIN` then read as a table name and landed in `db.query.summary` — and, with span streaming, in the span name. Derive the dialect from the `db.system.name` / `db.system` Prisma reports, matching what knex already does. Also updates the Prisma integration test expectations for the schema-qualified table names that the core query-summary fix now keeps intact. Refs #23676 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/prisma-orm-v5/test.ts | 6 +- .../suites/tracing/prisma-orm-v6/test.ts | 6 +- .../suites/tracing/prisma-orm-v7/test.ts | 4 +- .../src/integrations/prisma/tracing-helper.ts | 17 +++++- .../test/integrations/prisma.test.ts | 59 +++++++++++++++++++ 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts index b5a3ef7dbe7c..33b8c89d0ec3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts @@ -85,7 +85,7 @@ function expectPrismaV5Spans(transaction: TransactionEvent): void { expect.objectContaining({ data: { 'db.statement': expect.stringContaining('SELECT'), - 'db.query.summary': 'SELECT "public"', + 'db.query.summary': 'SELECT "public"."User"', 'db.system': 'postgresql', 'sentry.kind': 'client', 'sentry.op': 'db', @@ -157,10 +157,10 @@ describeWithDockerCompose('Prisma ORM v5', { workingDirectory: [__dirname] }, () })), ).toEqual([ { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, - { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"' }, { name: 'BEGIN', summary: 'BEGIN' }, { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, - { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"' }, { name: 'COMMIT', summary: 'COMMIT' }, { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' }, ]); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts index 0b01d4cbaa3e..542c482a0fa1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts @@ -88,7 +88,7 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] 'sentry.op': 'db', 'db.query.text': 'SELECT "public"."User"."id", "public"."User"."createdAt", "public"."User"."email", "public"."User"."name" FROM "public"."User" WHERE 1=1 OFFSET $1', - 'db.query.summary': 'SELECT "public"', + 'db.query.summary': 'SELECT "public"."User"', 'db.system': 'postgresql', 'sentry.kind': 'client', }, @@ -136,8 +136,6 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] span: container => { const querySpans = container.items.filter(item => item.attributes['db.query.text']); - // `SELECT "public"` is what the core query-summary helper derives from a schema-qualified, - // quoted table (it stops at the first quoted identifier). expect( querySpans.map(span => ({ name: span.name, @@ -145,7 +143,7 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] })), ).toEqual([ { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, - { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"' }, { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' }, ]); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts index b436b0f96035..b9fd94230350 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts @@ -112,8 +112,6 @@ describe('Prisma ORM v7 Tests', () => { item.attributes['sentry.origin']?.value === 'auto.db.prisma' && item.attributes['db.query.text'], ); - // `SELECT "public"` is what the core query-summary helper derives from a schema-qualified, - // quoted table (it stops at the first quoted identifier). expect( querySpans.map(span => ({ name: span.name, @@ -121,7 +119,7 @@ describe('Prisma ORM v7 Tests', () => { })), ).toEqual([ { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, - { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'SELECT "public"."User"', summary: 'SELECT "public"."User"' }, { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' }, ]); diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index b3e774c96903..a1643caa244d 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -13,7 +13,7 @@ * `createEngineSpan`) and v6/v7 (which call `dispatchEngineSpans`) */ -import type { Span, SpanAttributes } from '@sentry/core'; +import type { Span, SpanAttributes, SqlDialect } from '@sentry/core'; import { debug, getActiveSpan, @@ -117,12 +117,25 @@ function buildSpanAttributes(name: string, attributes: Record | if (statement) { // Sanitized before summarizing, so that a string literal containing `from`/`join` can't leak a // value into the summary. - merged[DB_QUERY_SUMMARY] = _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement)); + merged[DB_QUERY_SUMMARY] = _INTERNAL_getSqlQuerySummary( + _INTERNAL_sanitizeSqlQuery(statement, getSqlDialect(merged)), + ); } return merged; } +/** + * The dialect the reported SQL is written in. Prisma is multi-connector, and on MySQL a `"..."` run is + * a string literal rather than a quoted identifier, so sanitizing it as standard SQL leaves the value + * in place — and a literal containing `FROM`/`JOIN` then reads as a table name in the summary. + */ +function getSqlDialect(attributes: SpanAttributes): SqlDialect | undefined { + // oxlint-disable-next-line typescript/no-deprecated + const system = attributes[DB_SYSTEM_NAME] ?? attributes[DB_SYSTEM]; + return system === 'mysql' || system === 'mariadb' ? 'mysql' : undefined; +} + /** * The SQL a span reports, if any. Prisma emits it as the deprecated `db.statement` on older versions * and as `db.query.text` on the `db_query` spans of newer ones. diff --git a/packages/server-utils/test/integrations/prisma.test.ts b/packages/server-utils/test/integrations/prisma.test.ts index ef05eacc8b85..fba6e1706aa1 100644 --- a/packages/server-utils/test/integrations/prisma.test.ts +++ b/packages/server-utils/test/integrations/prisma.test.ts @@ -1,3 +1,5 @@ +import type { Span } from '@sentry/core'; +import { Client, createTransport, initAndBind, resolvedSyncPromise, spanToJSON } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { instrumentPrisma } from '../../src/integrations/prisma'; import type { TracingHelper } from '../../src/integrations/prisma/types'; @@ -11,6 +13,35 @@ function getHelper(): (TracingHelper & { createEngineSpan?: unknown }) | undefin return (globalThis as PrismaGlobal).PRISMA_INSTRUMENTATION?.helper; } +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +/** Runs a `db_query` span through the installed helper and returns the span it created. */ +function runDbQuerySpan(attributes: Record): Span { + let span: Span | undefined; + getHelper()?.runInChildSpan({ name: 'db_query', attributes }, createdSpan => { + span = createdSpan; + }); + return span!; +} + describe('instrumentPrisma', () => { afterEach(() => { const g = globalThis as PrismaGlobal; @@ -39,6 +70,34 @@ describe('instrumentPrisma', () => { expect(helper?.isEnabled()).toBe(true); }); + describe('db.query.summary', () => { + it('summarizes a standard-dialect statement', () => { + initTestClient(); + instrumentPrisma(); + + const span = runDbQuerySpan({ + 'db.system.name': 'postgresql', + 'db.query.text': 'SELECT * FROM "public"."User" WHERE "bio" = $1', + }); + + expect(spanToJSON(span).attributes['db.query.summary']).toBe('SELECT "public"."User"'); + }); + + it.each(['mysql', 'mariadb'])('sanitizes double-quoted string literals as literals on %s', (system: string) => { + initTestClient(); + instrumentPrisma(); + + // On MySQL `"..."` is a string literal, so treating it as a quoted identifier would let the + // `FROM` inside a user-supplied value read as a second table. + const span = runDbQuerySpan({ + 'db.system.name': system, + 'db.query.text': 'SELECT * FROM `User` WHERE bio = "x FROM secret_table"', + }); + + expect(spanToJSON(span).attributes['db.query.summary']).toBe('SELECT `User`'); + }); + }); + it('accepts the instrumentationConfig option', () => { expect(() => instrumentPrisma({ instrumentationConfig: { ignoreSpanTypes: ['prisma:client:operation'] } }),