diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index 8a5a4ed63e51..efa2f279e17a 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -35,7 +35,7 @@ import { SENTRY_KIND, SENTRY_OP, } from '@sentry/conventions/attributes'; -import { getSqlQuerySummary, sanitizeSqlQuery } from '../../utils/sql'; +import { getSqlQuerySummary, sanitizeSqlQuery, type SqlDialect } from '../../utils/sql'; // Reading `process.env` can throw in runtimes that gate env access (e.g. Deno without `--allow-env`) // and `process` may be absent altogether (edge runtimes), so this degrades to `false` in those cases. @@ -117,12 +117,23 @@ 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] = getSqlQuerySummary(sanitizeSqlQuery(statement)); + merged[DB_QUERY_SUMMARY] = getSqlQuerySummary(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'] } }),