diff --git a/.size-limit.js b/.size-limit.js index 9e07ab27adb6..50b0dc5d7bc0 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '123 KB', + limit: '124 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index 666d9e462b1d..49bb7ff38cc5 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -9,7 +9,7 @@ import { SPAN_STATUS_ERROR } from '../tracing'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { startSpanManual } from '../tracing/trace'; import type { Span, SpanAttributes } from '../types/span'; -import { getSqlQuerySummary } from '../utils/sql'; +import { getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql'; import { debug } from '../utils/debug-logger'; import { isObjectLike } from '../utils/is'; import { getActiveSpan } from '../utils/spanUtils'; @@ -242,7 +242,7 @@ function _wrapSingleQueryHandle( } const fullQuery = _reconstructQuery(query.strings); - const sanitizedSqlQuery = _sanitizeSqlQuery(fullQuery); + const sanitizedSqlQuery = sanitizeSqlQuery(fullQuery); const client = getClient(); const querySummary = getSqlQuerySummary(sanitizedSqlQuery); @@ -366,58 +366,6 @@ export function _reconstructQuery(strings: string[] | undefined): string | undef return strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), ''); } -let integerLiteralRE: RegExp | undefined; - -/** - * Sanitize SQL query as per the OTEL semantic conventions - * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext - * - * PostgreSQL $n placeholders are preserved per OTEL spec - they're parameterized queries, - * not sensitive literals. Only actual values (strings, numbers, booleans) are sanitized. - * - * @internal Exported for testing only - */ -export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { - if (!sqlQuery) { - return 'Unknown SQL Query'; - } - - // Lazy init: constructing this at module scope would evaluate the lookbehind - // on import and crash Safari <16.4 browser bundles that reach this file via - // the core barrel. Building it on first call keeps the cost off the import path. - if (!integerLiteralRE) { - integerLiteralRE = new RegExp('(? 0 ? truncated.substring(0, lastSpace) : truncated; } + +let integerLiteralRE: RegExp | undefined; + +/** + * SQL dialect variants that matter for finding the end of a string literal: + * - `standard` (PostgreSQL, SQLite): `"` quotes identifiers and `''` is the only in-string escape. + * - `mysql`: `"` quotes a string literal unless `ANSI_QUOTES` is set, and `\` escapes the next + * character unless `NO_BACKSLASH_ESCAPES` is set. Both default to off, and mysql/mysql2 escape + * inlined values with backslashes, so this is the mode their statements arrive in. + */ +export type SqlDialect = 'standard' | 'mysql'; + +/** + * Returns the index just past the run's closing `delimiter`, or the end of the query if the run is + * never closed — an unterminated literal must swallow the remainder rather than let it through. + * + * A doubled delimiter (`''`) escapes itself in every dialect; backslash escapes are dialect- and + * context-dependent, so the caller decides. + */ +function findQuotedRunEnd(sql: string, start: number, delimiter: string, backslashEscapes: boolean): number { + for (let i = start + 1; i < sql.length; i++) { + const char = sql[i]; + if (backslashEscapes && char === '\\') { + i++; + } else if (char === delimiter) { + if (sql[i + 1] !== delimiter) { + return i + 1; + } + i++; + } + } + return sql.length; +} + +/** + * Replaces every string literal with `?` and drops every comment, in one pass. + * + * Doing this by scanning rather than by regex is what keeps quote state and comment state from + * being decided independently: a regex for `'...'` cannot see that the quote it stopped at was + * backslash-escaped, and a regex for `--...` cannot see that the `--` sits inside a literal. Both + * mistakes end with user data surviving into `db.query.text` and `db.query.summary`. + * + * Quoted identifiers are preserved — they are the table and column names the query summary is + * built from. + */ +function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { + const isMysql = dialect === 'mysql'; + let out = ''; + let i = 0; + + while (i < sql.length) { + const char = sql[i]!; + const next = sql[i + 1]; + + if ((char === '-' && next === '-') || (isMysql && char === '#')) { + const lineEnd = sql.indexOf('\n', i); + i = lineEnd === -1 ? sql.length : lineEnd; + continue; + } + + if (char === '/' && next === '*') { + const commentEnd = sql.indexOf('*/', i + 2); + i = commentEnd === -1 ? sql.length : commentEnd + 2; + continue; + } + + // Quoted identifiers: backticks in MySQL, double quotes everywhere else + if (char === '`' || (char === '"' && !isMysql)) { + const runEnd = findQuotedRunEnd(sql, i, char, false); + out += sql.slice(i, runEnd); + i = runEnd; + continue; + } + + if (char === "'" || (char === '"' && isMysql)) { + // A prefix like `X'1A'`, `B'01'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has + // to collapse into the same `?` instead of being left behind as a bare identifier. + const prefix = char === "'" ? getLiteralPrefix(out, isMysql) : undefined; + out = prefix ? out.slice(0, -1) : out; + i = findQuotedRunEnd(sql, i, char, isMysql || prefix === 'E'); + out += '?'; + continue; + } + + out += char; + i++; + } + + return out; +} + +/** + * Returns the literal-prefix character immediately before a `'`, if there is one: `X`/`B` for + * hex/binary literals, or `E` for a PostgreSQL escape string (which honors backslash escapes). + */ +function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'E' | undefined { + // A prefix only counts when it stands alone — the `X` in `MAX'...'` belongs to the identifier + if (/[\w$]/.test(out.slice(-2, -1))) { + return undefined; + } + + const prefix = out.slice(-1).toUpperCase(); + if (prefix === 'X' || prefix === 'B') { + return prefix; + } + return prefix === 'E' && !isMysql ? 'E' : undefined; +} + +/** + * Sanitize SQL query as per the OTEL semantic conventions + * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext + * + * PostgreSQL $n placeholders are preserved per OTEL spec - they're parameterized queries, + * not sensitive literals. Only actual values (strings, numbers, booleans) are sanitized. + * + * Pass `dialect` when the statement comes from a driver whose literals are not standard-quoted; + * see {@link SqlDialect}. + */ +export function sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDialect = 'standard'): string { + if (!sqlQuery) { + return 'Unknown SQL Query'; + } + + // Lazy init: constructing this at module scope would evaluate the lookbehind + // on import and crash Safari <16.4 browser bundles that reach this file via + // the core barrel. Building it on first call keeps the cost off the import path. + if (!integerLiteralRE) { + integerLiteralRE = new RegExp('(? { }); }); - describe('integration with _sanitizeSqlQuery', () => { + describe('integration with sanitizeSqlQuery', () => { it('preserves $n placeholders per OTEL spec', () => { const strings = ['SELECT * FROM users WHERE id = ', ' AND name = ', '']; - expect(_sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1 AND name = $2'); + expect(sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1 AND name = $2'); }); it('collapses IN clause with $n to IN ($?)', () => { const strings = ['SELECT * FROM users WHERE id = ', ' AND status IN (', ', ', ', ', ')']; - expect(_sanitizeSqlQuery(_reconstructQuery(strings))).toBe( + expect(sanitizeSqlQuery(_reconstructQuery(strings))).toBe( 'SELECT * FROM users WHERE id = $1 AND status IN ($?)', ); }); it('returns Unknown SQL Query for undefined input', () => { - expect(_sanitizeSqlQuery(_reconstructQuery(undefined))).toBe('Unknown SQL Query'); + expect(sanitizeSqlQuery(_reconstructQuery(undefined))).toBe('Unknown SQL Query'); }); it('normalizes whitespace and removes trailing semicolon', () => { const strings = ['SELECT *\n FROM users\n WHERE id = ', ';']; - expect(_sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1'); - }); - }); - }); - - describe('_sanitizeSqlQuery', () => { - describe('passthrough (no literals)', () => { - it.each([ - ['SELECT * FROM users', 'SELECT * FROM users'], - ['INSERT INTO users (a, b) SELECT a, b FROM other', 'INSERT INTO users (a, b) SELECT a, b FROM other'], - [ - 'SELECT col1, col2 FROM table1 JOIN table2 ON table1.id = table2.id', - 'SELECT col1, col2 FROM table1 JOIN table2 ON table1.id = table2.id', - ], - ])('passes through %p unchanged', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('comment removal', () => { - it.each([ - ['SELECT * FROM users -- comment', 'SELECT * FROM users'], - ['SELECT * -- comment\nFROM users', 'SELECT * FROM users'], - ['SELECT /* comment */ * FROM users', 'SELECT * FROM users'], - ['SELECT /* multi\nline */ * FROM users', 'SELECT * FROM users'], - ['SELECT /* c1 */ * FROM /* c2 */ users -- c3', 'SELECT * FROM users'], - ])('removes comments: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('whitespace normalization', () => { - it.each([ - ['SELECT * FROM users', 'SELECT * FROM users'], - ['SELECT *\n\tFROM\n\tusers', 'SELECT * FROM users'], - [' SELECT * FROM users ', 'SELECT * FROM users'], - [' SELECT \n\t * \r\n FROM \t\t users ', 'SELECT * FROM users'], - ])('normalizes %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('trailing semicolon removal', () => { - it.each([ - ['SELECT * FROM users;', 'SELECT * FROM users'], - ['SELECT * FROM users; ', 'SELECT * FROM users'], - ])('removes trailing semicolon: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('$n placeholder preservation (OTEL compliance)', () => { - it.each([ - ['SELECT * FROM users WHERE id = $1', 'SELECT * FROM users WHERE id = $1'], - ['SELECT * FROM users WHERE id = $1 AND name = $2', 'SELECT * FROM users WHERE id = $1 AND name = $2'], - ['INSERT INTO t VALUES ($1, $10, $100)', 'INSERT INTO t VALUES ($1, $10, $100)'], - ['$1 UNION SELECT * FROM users', '$1 UNION SELECT * FROM users'], - ['SELECT * FROM users LIMIT $1', 'SELECT * FROM users LIMIT $1'], - ['SELECT $1$2$3', 'SELECT $1$2$3'], - ['SELECT generate_series($1, $2)', 'SELECT generate_series($1, $2)'], - ])('preserves $n: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('string literal sanitization', () => { - it.each([ - ["SELECT * FROM users WHERE name = 'John'", 'SELECT * FROM users WHERE name = ?'], - ["SELECT * FROM users WHERE a = 'x' AND b = 'y'", 'SELECT * FROM users WHERE a = ? AND b = ?'], - ["SELECT * FROM users WHERE name = ''", 'SELECT * FROM users WHERE name = ?'], - ["SELECT * FROM users WHERE name = 'it''s'", 'SELECT * FROM users WHERE name = ?'], - ["SELECT * FROM users WHERE data = 'a''b''c'", 'SELECT * FROM users WHERE data = ?'], - ["SELECT * FROM t WHERE desc = 'Use $1 for param'", 'SELECT * FROM t WHERE desc = ?'], - ["SELECT * FROM users WHERE name = '日本語'", 'SELECT * FROM users WHERE name = ?'], - ])('sanitizes string: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('numeric literal sanitization', () => { - it.each([ - ['SELECT * FROM users WHERE id = 123', 'SELECT * FROM users WHERE id = ?'], - ['SELECT * FROM users WHERE count = 0', 'SELECT * FROM users WHERE count = ?'], - ['SELECT * FROM products WHERE price = 19.99', 'SELECT * FROM products WHERE price = ?'], - ['SELECT * FROM products WHERE discount = .5', 'SELECT * FROM products WHERE discount = ?'], - ['SELECT * FROM accounts WHERE balance = -500', 'SELECT * FROM accounts WHERE balance = ?'], - ['SELECT * FROM accounts WHERE rate = -0.05', 'SELECT * FROM accounts WHERE rate = ?'], - ['SELECT * FROM data WHERE value = 1e10', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM data WHERE value = 1.5e-3', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM data WHERE value = 2.5E+10', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM data WHERE value = -1e10', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM users LIMIT 10 OFFSET 20', 'SELECT * FROM users LIMIT ? OFFSET ?'], - ])('sanitizes number: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - - it('preserves numbers in identifiers', () => { - expect(_sanitizeSqlQuery('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); - expect(_sanitizeSqlQuery('SELECT * FROM "table1" WHERE "col2" = 5')).toBe( - 'SELECT * FROM "table1" WHERE "col2" = ?', - ); - }); - }); - - describe('hex and binary literal sanitization', () => { - it.each([ - ["SELECT * FROM t WHERE data = X'1A2B'", 'SELECT * FROM t WHERE data = ?'], - ["SELECT * FROM t WHERE data = x'ff'", 'SELECT * FROM t WHERE data = ?'], - ["SELECT * FROM t WHERE data = X''", 'SELECT * FROM t WHERE data = ?'], - ['SELECT * FROM t WHERE flags = 0x1A2B', 'SELECT * FROM t WHERE flags = ?'], - ['SELECT * FROM t WHERE flags = 0XFF', 'SELECT * FROM t WHERE flags = ?'], - ["SELECT * FROM t WHERE bits = B'1010'", 'SELECT * FROM t WHERE bits = ?'], - ["SELECT * FROM t WHERE bits = b'1111'", 'SELECT * FROM t WHERE bits = ?'], - ["SELECT * FROM t WHERE bits = B''", 'SELECT * FROM t WHERE bits = ?'], - ])('sanitizes hex/binary: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('boolean literal sanitization', () => { - it.each([ - ['SELECT * FROM users WHERE active = TRUE', 'SELECT * FROM users WHERE active = ?'], - ['SELECT * FROM users WHERE active = FALSE', 'SELECT * FROM users WHERE active = ?'], - ['SELECT * FROM users WHERE a = true AND b = false', 'SELECT * FROM users WHERE a = ? AND b = ?'], - ['SELECT * FROM users WHERE a = True AND b = False', 'SELECT * FROM users WHERE a = ? AND b = ?'], - ])('sanitizes boolean: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - - it('does not affect identifiers containing TRUE/FALSE', () => { - expect(_sanitizeSqlQuery('SELECT TRUE_FLAG FROM users WHERE active = TRUE')).toBe( - 'SELECT TRUE_FLAG FROM users WHERE active = ?', - ); - }); - }); - - describe('IN clause collapsing', () => { - it.each([ - ['SELECT * FROM users WHERE id IN (?, ?, ?)', 'SELECT * FROM users WHERE id IN (?)'], - ['SELECT * FROM users WHERE id IN ($1, $2, $3)', 'SELECT * FROM users WHERE id IN ($?)'], - ['SELECT * FROM users WHERE id in ($1, $2)', 'SELECT * FROM users WHERE id IN ($?)'], - ['SELECT * FROM users WHERE id IN ( $1 , $2 , $3 )', 'SELECT * FROM users WHERE id IN ($?)'], - [ - 'SELECT * FROM users WHERE id IN ($1, $2) AND status IN ($3, $4)', - 'SELECT * FROM users WHERE id IN ($?) AND status IN ($?)', - ], - ['SELECT * FROM users WHERE id NOT IN ($1, $2)', 'SELECT * FROM users WHERE id NOT IN ($?)'], - ['SELECT * FROM users WHERE id NOT IN (?, ?)', 'SELECT * FROM users WHERE id NOT IN (?)'], - ['SELECT * FROM users WHERE id IN ($1)', 'SELECT * FROM users WHERE id IN ($?)'], - ['SELECT * FROM users WHERE id IN (1, 2, 3)', 'SELECT * FROM users WHERE id IN (?)'], - ])('collapses IN clause: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('mixed scenarios (params + literals)', () => { - it.each([ - ["SELECT * FROM users WHERE id = $1 AND status = 'active'", 'SELECT * FROM users WHERE id = $1 AND status = ?'], - ['SELECT * FROM users WHERE id = $1 AND limit = 100', 'SELECT * FROM users WHERE id = $1 AND limit = ?'], - [ - "SELECT * FROM t WHERE a = $1 AND b = 'foo' AND c = 123 AND d = TRUE AND e IN ($2, $3)", - 'SELECT * FROM t WHERE a = $1 AND b = ? AND c = ? AND d = ? AND e IN ($?)', - ], - ])('handles mixed: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('PostgreSQL-specific syntax', () => { - it.each([ - ['SELECT $1::integer', 'SELECT $1::integer'], - ['SELECT $1::text', 'SELECT $1::text'], - ['SELECT * FROM t WHERE tags = ARRAY[1, 2, 3]', 'SELECT * FROM t WHERE tags = ARRAY[?, ?, ?]'], - ['SELECT * FROM t WHERE tags = ARRAY[$1, $2]', 'SELECT * FROM t WHERE tags = ARRAY[$1, $2]'], - ["SELECT data->'key' FROM t WHERE id = $1", 'SELECT data->? FROM t WHERE id = $1'], - ["SELECT data->>'key' FROM t WHERE id = $1", 'SELECT data->>? FROM t WHERE id = $1'], - ["SELECT * FROM t WHERE data @> '{}'", 'SELECT * FROM t WHERE data @> ?'], - [ - "SELECT * FROM t WHERE created_at > NOW() - INTERVAL '7 days'", - 'SELECT * FROM t WHERE created_at > NOW() - INTERVAL ?', - ], - ['CREATE TABLE t (created_at TIMESTAMP(3))', 'CREATE TABLE t (created_at TIMESTAMP(?))'], - ['CREATE TABLE t (price NUMERIC(10, 2))', 'CREATE TABLE t (price NUMERIC(?, ?))'], - ])('handles PostgreSQL syntax: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('empty/undefined input', () => { - it.each([ - [undefined, 'Unknown SQL Query'], - ['', 'Unknown SQL Query'], - [' ', ''], - [' \n\t ', ''], - ])('handles empty input %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('complex real-world queries', () => { - it('handles query with comments, whitespace, and IN clause', () => { - const input = ` - SELECT * FROM users -- fetch all users - WHERE id = $1 - AND status IN ($2, $3, $4); - `; - expect(_sanitizeSqlQuery(input)).toBe('SELECT * FROM users WHERE id = $1 AND status IN ($?)'); - }); - - it('handles Prisma-style query', () => { - const input = ` - SELECT "User"."id", "User"."email", "User"."name" - FROM "User" - WHERE "User"."email" = $1 - AND "User"."deleted_at" IS NULL - LIMIT $2; - `; - expect(_sanitizeSqlQuery(input)).toBe( - 'SELECT "User"."id", "User"."email", "User"."name" FROM "User" WHERE "User"."email" = $1 AND "User"."deleted_at" IS NULL LIMIT $2', - ); - }); - - it('handles CREATE TABLE with various types', () => { - const input = ` - CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "email" TEXT NOT NULL, - "balance" NUMERIC(10, 2) DEFAULT 0.00, - CONSTRAINT "User_pkey" PRIMARY KEY ("id") - ); - `; - expect(_sanitizeSqlQuery(input)).toBe( - 'CREATE TABLE "User" ( "id" SERIAL NOT NULL, "createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" TEXT NOT NULL, "balance" NUMERIC(?, ?) DEFAULT ?, CONSTRAINT "User_pkey" PRIMARY KEY ("id") )', - ); - }); - - it('handles INSERT/UPDATE with mixed literals and params', () => { - expect(_sanitizeSqlQuery("INSERT INTO users (name, age, active) VALUES ('John', 30, TRUE)")).toBe( - 'INSERT INTO users (name, age, active) VALUES (?, ?, ?)', - ); - expect(_sanitizeSqlQuery("UPDATE users SET name = $1, updated_at = '2024-01-01' WHERE id = 123")).toBe( - 'UPDATE users SET name = $1, updated_at = ? WHERE id = ?', - ); - }); - }); - - describe('edge cases', () => { - it.each([ - ['SELECT * FROM "my-table" WHERE "my-column" = $1', 'SELECT * FROM "my-table" WHERE "my-column" = $1'], - ['SELECT * FROM t WHERE big_id = 99999999999999999999', 'SELECT * FROM t WHERE big_id = ?'], - ['SELECT * FROM t WHERE val > -5', 'SELECT * FROM t WHERE val > ?'], - ['SELECT * FROM t WHERE id IN (1, -2, 3)', 'SELECT * FROM t WHERE id IN (?)'], - ['SELECT 1+2*3', 'SELECT ?+?*?'], - ["SELECT * FROM users WHERE name LIKE '%john%'", 'SELECT * FROM users WHERE name LIKE ?'], - ['SELECT * FROM t WHERE age BETWEEN 18 AND 65', 'SELECT * FROM t WHERE age BETWEEN ? AND ?'], - ['SELECT * FROM t WHERE age BETWEEN $1 AND $2', 'SELECT * FROM t WHERE age BETWEEN $1 AND $2'], - [ - "SELECT CASE WHEN status = 'active' THEN 1 ELSE 0 END FROM users", - 'SELECT CASE WHEN status = ? THEN ? ELSE ? END FROM users', - ], - [ - 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)', - 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > ?)', - ], - [ - "WITH cte AS (SELECT * FROM users WHERE status = 'active') SELECT * FROM cte WHERE id = $1", - 'WITH cte AS (SELECT * FROM users WHERE status = ?) SELECT * FROM cte WHERE id = $1', - ], - [ - 'SELECT COUNT(*), SUM(amount), AVG(price) FROM orders WHERE status = $1', - 'SELECT COUNT(*), SUM(amount), AVG(price) FROM orders WHERE status = $1', - ], - [ - 'SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > 10', - 'SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > ?', - ], - [ - 'SELECT ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) FROM orders', - 'SELECT ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) FROM orders', - ], - ])('handles edge case: %p', (input, expected) => { - expect(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('regression tests', () => { - it('does not replace $n with ? (OTEL compliance)', () => { - const result = _sanitizeSqlQuery('SELECT * FROM users WHERE id = $1'); - expect(result).not.toContain('?'); - expect(result).toBe('SELECT * FROM users WHERE id = $1'); - }); - - it('does not split decimal numbers into ?.?', () => { - const result = _sanitizeSqlQuery('SELECT * FROM t WHERE price = 19.99'); - expect(result).not.toBe('SELECT * FROM t WHERE price = ?.?'); - expect(result).toBe('SELECT * FROM t WHERE price = ?'); - }); - - it('does not leave minus sign when sanitizing negative numbers', () => { - const result = _sanitizeSqlQuery('SELECT * FROM t WHERE val = -500'); - expect(result).not.toBe('SELECT * FROM t WHERE val = -?'); - expect(result).toBe('SELECT * FROM t WHERE val = ?'); - }); - - it('handles exact queries from integration tests', () => { - expect( - _sanitizeSqlQuery( - '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"))', - ), - ).toBe( - '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"))', - ); - expect(_sanitizeSqlQuery('SELECT * from generate_series(1,1000) as x')).toBe( - 'SELECT * from generate_series(?,?) as x', - ); + expect(sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1'); }); }); }); diff --git a/packages/core/test/lib/utils/sql.test.ts b/packages/core/test/lib/utils/sql.test.ts index 7f3c54fc1139..b41463645870 100644 --- a/packages/core/test/lib/utils/sql.test.ts +++ b/packages/core/test/lib/utils/sql.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getSqlQuerySummary } from '../../../src/utils/sql'; +import { getSqlQuerySummary, sanitizeSqlQuery } from '../../../src/utils/sql'; describe('getSqlQuerySummary', () => { it.each([undefined, ''])('returns undefined for %j', input => { @@ -235,3 +235,366 @@ describe('getSqlQuerySummary', () => { expect(getSqlQuerySummary(' ')).toBe(''); }); }); + +describe('sanitizeSqlQuery', () => { + describe('passthrough (no literals)', () => { + it.each([ + ['SELECT * FROM users', 'SELECT * FROM users'], + ['INSERT INTO users (a, b) SELECT a, b FROM other', 'INSERT INTO users (a, b) SELECT a, b FROM other'], + [ + 'SELECT col1, col2 FROM table1 JOIN table2 ON table1.id = table2.id', + 'SELECT col1, col2 FROM table1 JOIN table2 ON table1.id = table2.id', + ], + ])('passes through %p unchanged', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('comment removal', () => { + it.each([ + ['SELECT * FROM users -- comment', 'SELECT * FROM users'], + ['SELECT * -- comment\nFROM users', 'SELECT * FROM users'], + ['SELECT /* comment */ * FROM users', 'SELECT * FROM users'], + ['SELECT /* multi\nline */ * FROM users', 'SELECT * FROM users'], + ['SELECT /* c1 */ * FROM /* c2 */ users -- c3', 'SELECT * FROM users'], + ])('removes comments: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('whitespace normalization', () => { + it.each([ + ['SELECT * FROM users', 'SELECT * FROM users'], + ['SELECT *\n\tFROM\n\tusers', 'SELECT * FROM users'], + [' SELECT * FROM users ', 'SELECT * FROM users'], + [' SELECT \n\t * \r\n FROM \t\t users ', 'SELECT * FROM users'], + ])('normalizes %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('trailing semicolon removal', () => { + it.each([ + ['SELECT * FROM users;', 'SELECT * FROM users'], + ['SELECT * FROM users; ', 'SELECT * FROM users'], + ])('removes trailing semicolon: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('$n placeholder preservation (OTEL compliance)', () => { + it.each([ + ['SELECT * FROM users WHERE id = $1', 'SELECT * FROM users WHERE id = $1'], + ['SELECT * FROM users WHERE id = $1 AND name = $2', 'SELECT * FROM users WHERE id = $1 AND name = $2'], + ['INSERT INTO t VALUES ($1, $10, $100)', 'INSERT INTO t VALUES ($1, $10, $100)'], + ['$1 UNION SELECT * FROM users', '$1 UNION SELECT * FROM users'], + ['SELECT * FROM users LIMIT $1', 'SELECT * FROM users LIMIT $1'], + ['SELECT $1$2$3', 'SELECT $1$2$3'], + ['SELECT generate_series($1, $2)', 'SELECT generate_series($1, $2)'], + ])('preserves $n: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('string literal sanitization', () => { + it.each([ + ["SELECT * FROM users WHERE name = 'John'", 'SELECT * FROM users WHERE name = ?'], + ["SELECT * FROM users WHERE a = 'x' AND b = 'y'", 'SELECT * FROM users WHERE a = ? AND b = ?'], + ["SELECT * FROM users WHERE name = ''", 'SELECT * FROM users WHERE name = ?'], + ["SELECT * FROM users WHERE name = 'it''s'", 'SELECT * FROM users WHERE name = ?'], + ["SELECT * FROM users WHERE data = 'a''b''c'", 'SELECT * FROM users WHERE data = ?'], + ["SELECT * FROM t WHERE desc = 'Use $1 for param'", 'SELECT * FROM t WHERE desc = ?'], + ["SELECT * FROM users WHERE name = '日本語'", 'SELECT * FROM users WHERE name = ?'], + ])('sanitizes string: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('numeric literal sanitization', () => { + it.each([ + ['SELECT * FROM users WHERE id = 123', 'SELECT * FROM users WHERE id = ?'], + ['SELECT * FROM users WHERE count = 0', 'SELECT * FROM users WHERE count = ?'], + ['SELECT * FROM products WHERE price = 19.99', 'SELECT * FROM products WHERE price = ?'], + ['SELECT * FROM products WHERE discount = .5', 'SELECT * FROM products WHERE discount = ?'], + ['SELECT * FROM accounts WHERE balance = -500', 'SELECT * FROM accounts WHERE balance = ?'], + ['SELECT * FROM accounts WHERE rate = -0.05', 'SELECT * FROM accounts WHERE rate = ?'], + ['SELECT * FROM data WHERE value = 1e10', 'SELECT * FROM data WHERE value = ?'], + ['SELECT * FROM data WHERE value = 1.5e-3', 'SELECT * FROM data WHERE value = ?'], + ['SELECT * FROM data WHERE value = 2.5E+10', 'SELECT * FROM data WHERE value = ?'], + ['SELECT * FROM data WHERE value = -1e10', 'SELECT * FROM data WHERE value = ?'], + ['SELECT * FROM users LIMIT 10 OFFSET 20', 'SELECT * FROM users LIMIT ? OFFSET ?'], + ])('sanitizes number: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + + it('preserves numbers in identifiers', () => { + expect(sanitizeSqlQuery('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); + expect(sanitizeSqlQuery('SELECT * FROM "table1" WHERE "col2" = 5')).toBe( + 'SELECT * FROM "table1" WHERE "col2" = ?', + ); + }); + }); + + describe('hex and binary literal sanitization', () => { + it.each([ + ["SELECT * FROM t WHERE data = X'1A2B'", 'SELECT * FROM t WHERE data = ?'], + ["SELECT * FROM t WHERE data = x'ff'", 'SELECT * FROM t WHERE data = ?'], + ["SELECT * FROM t WHERE data = X''", 'SELECT * FROM t WHERE data = ?'], + ['SELECT * FROM t WHERE flags = 0x1A2B', 'SELECT * FROM t WHERE flags = ?'], + ['SELECT * FROM t WHERE flags = 0XFF', 'SELECT * FROM t WHERE flags = ?'], + ["SELECT * FROM t WHERE bits = B'1010'", 'SELECT * FROM t WHERE bits = ?'], + ["SELECT * FROM t WHERE bits = b'1111'", 'SELECT * FROM t WHERE bits = ?'], + ["SELECT * FROM t WHERE bits = B''", 'SELECT * FROM t WHERE bits = ?'], + ])('sanitizes hex/binary: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('boolean literal sanitization', () => { + it.each([ + ['SELECT * FROM users WHERE active = TRUE', 'SELECT * FROM users WHERE active = ?'], + ['SELECT * FROM users WHERE active = FALSE', 'SELECT * FROM users WHERE active = ?'], + ['SELECT * FROM users WHERE a = true AND b = false', 'SELECT * FROM users WHERE a = ? AND b = ?'], + ['SELECT * FROM users WHERE a = True AND b = False', 'SELECT * FROM users WHERE a = ? AND b = ?'], + ])('sanitizes boolean: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + + it('does not affect identifiers containing TRUE/FALSE', () => { + expect(sanitizeSqlQuery('SELECT TRUE_FLAG FROM users WHERE active = TRUE')).toBe( + 'SELECT TRUE_FLAG FROM users WHERE active = ?', + ); + }); + }); + + describe('IN clause collapsing', () => { + it.each([ + ['SELECT * FROM users WHERE id IN (?, ?, ?)', 'SELECT * FROM users WHERE id IN (?)'], + ['SELECT * FROM users WHERE id IN ($1, $2, $3)', 'SELECT * FROM users WHERE id IN ($?)'], + ['SELECT * FROM users WHERE id in ($1, $2)', 'SELECT * FROM users WHERE id IN ($?)'], + ['SELECT * FROM users WHERE id IN ( $1 , $2 , $3 )', 'SELECT * FROM users WHERE id IN ($?)'], + [ + 'SELECT * FROM users WHERE id IN ($1, $2) AND status IN ($3, $4)', + 'SELECT * FROM users WHERE id IN ($?) AND status IN ($?)', + ], + ['SELECT * FROM users WHERE id NOT IN ($1, $2)', 'SELECT * FROM users WHERE id NOT IN ($?)'], + ['SELECT * FROM users WHERE id NOT IN (?, ?)', 'SELECT * FROM users WHERE id NOT IN (?)'], + ['SELECT * FROM users WHERE id IN ($1)', 'SELECT * FROM users WHERE id IN ($?)'], + ['SELECT * FROM users WHERE id IN (1, 2, 3)', 'SELECT * FROM users WHERE id IN (?)'], + ])('collapses IN clause: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('mixed scenarios (params + literals)', () => { + it.each([ + ["SELECT * FROM users WHERE id = $1 AND status = 'active'", 'SELECT * FROM users WHERE id = $1 AND status = ?'], + ['SELECT * FROM users WHERE id = $1 AND limit = 100', 'SELECT * FROM users WHERE id = $1 AND limit = ?'], + [ + "SELECT * FROM t WHERE a = $1 AND b = 'foo' AND c = 123 AND d = TRUE AND e IN ($2, $3)", + 'SELECT * FROM t WHERE a = $1 AND b = ? AND c = ? AND d = ? AND e IN ($?)', + ], + ])('handles mixed: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('PostgreSQL-specific syntax', () => { + it.each([ + ['SELECT $1::integer', 'SELECT $1::integer'], + ['SELECT $1::text', 'SELECT $1::text'], + ['SELECT * FROM t WHERE tags = ARRAY[1, 2, 3]', 'SELECT * FROM t WHERE tags = ARRAY[?, ?, ?]'], + ['SELECT * FROM t WHERE tags = ARRAY[$1, $2]', 'SELECT * FROM t WHERE tags = ARRAY[$1, $2]'], + ["SELECT data->'key' FROM t WHERE id = $1", 'SELECT data->? FROM t WHERE id = $1'], + ["SELECT data->>'key' FROM t WHERE id = $1", 'SELECT data->>? FROM t WHERE id = $1'], + ["SELECT * FROM t WHERE data @> '{}'", 'SELECT * FROM t WHERE data @> ?'], + [ + "SELECT * FROM t WHERE created_at > NOW() - INTERVAL '7 days'", + 'SELECT * FROM t WHERE created_at > NOW() - INTERVAL ?', + ], + ['CREATE TABLE t (created_at TIMESTAMP(3))', 'CREATE TABLE t (created_at TIMESTAMP(?))'], + ['CREATE TABLE t (price NUMERIC(10, 2))', 'CREATE TABLE t (price NUMERIC(?, ?))'], + ])('handles PostgreSQL syntax: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('empty/undefined input', () => { + it.each([ + [undefined, 'Unknown SQL Query'], + ['', 'Unknown SQL Query'], + [' ', ''], + [' \n\t ', ''], + ])('handles empty input %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('complex real-world queries', () => { + it('handles query with comments, whitespace, and IN clause', () => { + const input = ` + SELECT * FROM users -- fetch all users + WHERE id = $1 + AND status IN ($2, $3, $4); + `; + expect(sanitizeSqlQuery(input)).toBe('SELECT * FROM users WHERE id = $1 AND status IN ($?)'); + }); + + it('handles Prisma-style query', () => { + const input = ` + SELECT "User"."id", "User"."email", "User"."name" + FROM "User" + WHERE "User"."email" = $1 + AND "User"."deleted_at" IS NULL + LIMIT $2; + `; + expect(sanitizeSqlQuery(input)).toBe( + 'SELECT "User"."id", "User"."email", "User"."name" FROM "User" WHERE "User"."email" = $1 AND "User"."deleted_at" IS NULL LIMIT $2', + ); + }); + + it('handles CREATE TABLE with various types', () => { + const input = ` + CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "email" TEXT NOT NULL, + "balance" NUMERIC(10, 2) DEFAULT 0.00, + CONSTRAINT "User_pkey" PRIMARY KEY ("id") + ); + `; + expect(sanitizeSqlQuery(input)).toBe( + 'CREATE TABLE "User" ( "id" SERIAL NOT NULL, "createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" TEXT NOT NULL, "balance" NUMERIC(?, ?) DEFAULT ?, CONSTRAINT "User_pkey" PRIMARY KEY ("id") )', + ); + }); + + it('handles INSERT/UPDATE with mixed literals and params', () => { + expect(sanitizeSqlQuery("INSERT INTO users (name, age, active) VALUES ('John', 30, TRUE)")).toBe( + 'INSERT INTO users (name, age, active) VALUES (?, ?, ?)', + ); + expect(sanitizeSqlQuery("UPDATE users SET name = $1, updated_at = '2024-01-01' WHERE id = 123")).toBe( + 'UPDATE users SET name = $1, updated_at = ? WHERE id = ?', + ); + }); + }); + + describe('edge cases', () => { + it.each([ + ['SELECT * FROM "my-table" WHERE "my-column" = $1', 'SELECT * FROM "my-table" WHERE "my-column" = $1'], + ['SELECT * FROM t WHERE big_id = 99999999999999999999', 'SELECT * FROM t WHERE big_id = ?'], + ['SELECT * FROM t WHERE val > -5', 'SELECT * FROM t WHERE val > ?'], + ['SELECT * FROM t WHERE id IN (1, -2, 3)', 'SELECT * FROM t WHERE id IN (?)'], + ['SELECT 1+2*3', 'SELECT ?+?*?'], + ["SELECT * FROM users WHERE name LIKE '%john%'", 'SELECT * FROM users WHERE name LIKE ?'], + ['SELECT * FROM t WHERE age BETWEEN 18 AND 65', 'SELECT * FROM t WHERE age BETWEEN ? AND ?'], + ['SELECT * FROM t WHERE age BETWEEN $1 AND $2', 'SELECT * FROM t WHERE age BETWEEN $1 AND $2'], + [ + "SELECT CASE WHEN status = 'active' THEN 1 ELSE 0 END FROM users", + 'SELECT CASE WHEN status = ? THEN ? ELSE ? END FROM users', + ], + [ + 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)', + 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > ?)', + ], + [ + "WITH cte AS (SELECT * FROM users WHERE status = 'active') SELECT * FROM cte WHERE id = $1", + 'WITH cte AS (SELECT * FROM users WHERE status = ?) SELECT * FROM cte WHERE id = $1', + ], + [ + 'SELECT COUNT(*), SUM(amount), AVG(price) FROM orders WHERE status = $1', + 'SELECT COUNT(*), SUM(amount), AVG(price) FROM orders WHERE status = $1', + ], + [ + 'SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > 10', + 'SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > ?', + ], + [ + 'SELECT ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) FROM orders', + 'SELECT ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) FROM orders', + ], + ])('handles edge case: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('regression tests', () => { + it('does not replace $n with ? (OTEL compliance)', () => { + const result = sanitizeSqlQuery('SELECT * FROM users WHERE id = $1'); + expect(result).not.toContain('?'); + expect(result).toBe('SELECT * FROM users WHERE id = $1'); + }); + + it('does not split decimal numbers into ?.?', () => { + const result = sanitizeSqlQuery('SELECT * FROM t WHERE price = 19.99'); + expect(result).not.toBe('SELECT * FROM t WHERE price = ?.?'); + expect(result).toBe('SELECT * FROM t WHERE price = ?'); + }); + + it('does not leave minus sign when sanitizing negative numbers', () => { + const result = sanitizeSqlQuery('SELECT * FROM t WHERE val = -500'); + expect(result).not.toBe('SELECT * FROM t WHERE val = -?'); + expect(result).toBe('SELECT * FROM t WHERE val = ?'); + }); + + it('handles exact queries from integration tests', () => { + expect( + sanitizeSqlQuery( + '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"))', + ), + ).toBe( + '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"))', + ); + expect(sanitizeSqlQuery('SELECT * from generate_series(1,1000) as x')).toBe( + 'SELECT * from generate_series(?,?) as x', + ); + }); + + it('does not let comment syntax inside a literal cut the literal short', () => { + expect(sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret--x'")).toBe('SELECT * FROM t WHERE a = ?'); + expect(sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret/*x*/'")).toBe('SELECT * FROM t WHERE a = ?'); + }); + + it('honors backslash escapes in PostgreSQL escape strings', () => { + expect(sanitizeSqlQuery(String.raw`SELECT * FROM t WHERE a = E'it\'s from secret' AND b = 1`)).toBe( + 'SELECT * FROM t WHERE a = ? AND b = ?', + ); + }); + }); + + describe("dialect: 'mysql'", () => { + it.each([ + // MySQL reads `"..."` as a string literal, not as an identifier, unless ANSI_QUOTES is set + ['SELECT * FROM users WHERE name = "John"', 'SELECT * FROM users WHERE name = ?'], + ['SELECT * FROM users WHERE a = "x" AND b = \'y\'', 'SELECT * FROM users WHERE a = ? AND b = ?'], + ['SELECT * FROM `users` WHERE `name` = "John"', 'SELECT * FROM `users` WHERE `name` = ?'], + ['SELECT * FROM t WHERE a = "x" # trailing comment', 'SELECT * FROM t WHERE a = ?'], + // backslash escapes — the shape mysql/mysql2 emit when they inline a value + [String.raw`SELECT * FROM users WHERE name = 'O\'Brien'`, 'SELECT * FROM users WHERE name = ?'], + [String.raw`SELECT * FROM users WHERE bio = 'a \"quote\" here'`, 'SELECT * FROM users WHERE bio = ?'], + [String.raw`SELECT * FROM t WHERE a = 'x\\' AND b = 'y'`, 'SELECT * FROM t WHERE a = ? AND b = ?'], + ])('sanitizes %p', (input, expected) => { + expect(sanitizeSqlQuery(input, 'mysql')).toBe(expected); + }); + + it('keeps a quote inside a backticked identifier from opening a literal', () => { + expect(sanitizeSqlQuery("SELECT `it's` FROM t WHERE a = 'x'", 'mysql')).toBe("SELECT `it's` FROM t WHERE a = ?"); + }); + }); + + describe('regression: values must not survive as summary targets', () => { + // A literal that survives sanitization and happens to contain `from`/`join`/`select` is read + // as a table name by getSqlQuerySummary, which puts it in `db.query.summary` and — with span + // streaming — in the span name. + it.each([ + ['SELECT * FROM users WHERE name = "from bob@secret.com"', 'bob@secret.com'], + ['SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"', 'Berlin'], + ['INSERT INTO t (c) VALUES ("select from s3cret-token")', 's3cret-token'], + [String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'`, 'ACME'], + [String.raw`UPDATE t SET a = 'x\'y from Z' WHERE id = 5`, 'from Z'], + ])('strips the value out of %p', (input, value) => { + const sanitized = sanitizeSqlQuery(input, 'mysql'); + expect(sanitized).not.toContain(value); + expect(getSqlQuerySummary(sanitized)).not.toContain(value); + }); + }); +}); diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts index ede932d84ba4..b3e254b0276c 100644 --- a/packages/server-utils/src/integrations/mysql.ts +++ b/packages/server-utils/src/integrations/mysql.ts @@ -87,7 +87,7 @@ function instrumentMysql(): void { // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. data._sentryCallerScope = getCurrentScope(); - const querySummary = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql)) : undefined; + const querySummary = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql, 'mysql')) : undefined; const client = getClient(); const name = diff --git a/packages/server-utils/src/integrations/mysql2/index.ts b/packages/server-utils/src/integrations/mysql2/index.ts index 559de1986a12..856331ff0ded 100644 --- a/packages/server-utils/src/integrations/mysql2/index.ts +++ b/packages/server-utils/src/integrations/mysql2/index.ts @@ -84,7 +84,9 @@ function subscribeQueryChannel(channelName: ChannelName): void { data => { const statement = getQueryText(data.arguments); const connectionAttributes = getConnectionAttributes(data.self?.config); - const querySummary = statement ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement)) : undefined; + const querySummary = statement + ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement, 'mysql')) + : undefined; const client = getClient(); const name = diff --git a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts index 0e65ed02231e..73f5a9ae1475 100644 --- a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts @@ -102,7 +102,7 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN // mysql2 does not sanitize its channel payload, so the statement may carry // raw user values (on the `query` channel they are inlined). Strip every // literal before it leaves the process; `values` is never attached. - const queryText = data.query ? _INTERNAL_sanitizeSqlQuery(data.query) : undefined; + const queryText = data.query ? _INTERNAL_sanitizeSqlQuery(data.query, 'mysql') : undefined; const operation = queryText?.match(SQL_OPERATION_RE)?.[1]?.toUpperCase(); const querySummary = _INTERNAL_getSqlQuerySummary(queryText); diff --git a/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts index 3a489ba00999..9eea2d8b03b8 100644 --- a/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts +++ b/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts @@ -225,6 +225,39 @@ describe('subscribeMysql2DiagnosticChannels', () => { expect(json.name).toBe('SELECT * FROM users WHERE email = ? AND age = ?'); }); + it('sanitizes backslash-escaped values, which is how mysql2 inlines them', async () => { + initTestClient('stream'); + + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + // `sqlstring` escapes `'` as `\'`, so this is what the channel publishes for + // `WHERE name = ?` with the value `O'Brien from ACME` + { query: String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'` }, + { result: [] }, + ); + + const json = spanToJSON(span!); + expect(json.attributes['db.query.text']).toBe('SELECT * FROM users WHERE name = ?'); + expect(json.attributes['db.query.summary']).toBe('SELECT users'); + // `from ACME'` would otherwise read as a second table and land in the name + expect(json.name).toBe('SELECT users'); + }); + + it('sanitizes double-quoted values, which MySQL reads as string literals', async () => { + initTestClient('stream'); + + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: 'SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"' }, + { result: [] }, + ); + + const json = spanToJSON(span!); + expect(json.attributes['db.query.text']).toBe('SELECT * FROM users WHERE bio = ?'); + expect(json.attributes['db.query.summary']).toBe('SELECT users'); + expect(json.name).toBe('SELECT users'); + }); + it('names the span after the query summary with span streaming enabled', async () => { initTestClient('stream');