Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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"' },
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -136,16 +136,14 @@ 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,
summary: span.attributes['db.query.summary']?.value,
})),
).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"' },
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,14 @@ 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,
summary: span.attributes['db.query.summary']?.value,
})),
).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"' },
]);

Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/utils/sql.ts
Original file line number Diff line number Diff line change
@@ -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*(?<operation>(?:CREATE|DROP)\\s+(?:TABLE|INDEX)|ALTER\\s+TABLE)(?:\\s+IF\\s+(?:NOT\\s+)?EXISTS)?\\s+(?<table>${TABLE_NAME})`,
Expand All @@ -27,8 +33,8 @@ const SELECT_RE = /^\s*\(?\s*(?<operation>SELECT)\b/i;
const PRAGMA_RE = /^\s*(?<operation>PRAGMA)\s+(?<command>\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;

/**
Expand Down Expand Up @@ -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]);

Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/lib/utils/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions packages/server-utils/src/integrations/prisma/tracing-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
_INTERNAL_getSqlQuerySummary,
_INTERNAL_sanitizeSqlQuery,
Expand Down Expand Up @@ -118,12 +118,25 @@ function buildSpanAttributes(name: string, attributes: Record<string, unknown> |
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.
Expand Down
59 changes: 59 additions & 0 deletions packages/server-utils/test/integrations/prisma.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +13,35 @@ function getHelper(): (TracingHelper & { createEngineSpan?: unknown }) | undefin
return (globalThis as PrismaGlobal).PRISMA_INSTRUMENTATION?.helper;
}

class TestClient extends Client<any> {
public eventFromException(): PromiseLike<any> {
return resolvedSyncPromise({});
}
public eventFromMessage(): PromiseLike<any> {
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<string, unknown>): Span {
let span: Span | undefined;
getHelper()?.runInChildSpan({ name: 'db_query', attributes }, createdSpan => {
span = createdSpan;
});
return span!;
}

describe('instrumentPrisma', () => {
afterEach(() => {
const g = globalThis as PrismaGlobal;
Expand Down Expand Up @@ -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'] } }),
Expand Down
Loading