Skip to content
Merged
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
56 changes: 2 additions & 54 deletions packages/core/src/integrations/postgresjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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('(?<!\\$)-?\\b\\d+\\b', 'g');
}

return (
sqlQuery
// Remove comments first (they may contain newlines and extra spaces)
.replace(/--.*$/gm, '') // Single line comments (multiline mode)
.replace(/\/\*[\s\S]*?\*\//g, '') // Multi-line comments
.replace(/;\s*$/, '') // Remove trailing semicolons
// Collapse whitespace to a single space (after removing comments)
.replace(/\s+/g, ' ')
.trim() // Remove extra spaces and trim
// Sanitize hex/binary literals before string literals
.replace(/\bX'[0-9A-Fa-f]*'/gi, '?') // Hex string literals
.replace(/\bB'[01]*'/gi, '?') // Binary string literals
// Sanitize string literals (handles escaped quotes)
.replace(/'(?:[^']|'')*'/g, '?')
// Sanitize hex numbers
.replace(/\b0x[0-9A-Fa-f]+/gi, '?')
// Sanitize boolean literals
.replace(/\b(?:TRUE|FALSE)\b/gi, '?')
// Sanitize numeric literals (preserve $n placeholders via negative lookbehind)
.replace(/-?\b\d+\.?\d*[eE][+-]?\d+\b/g, '?') // Scientific notation
.replace(/-?\b\d+\.\d+\b/g, '?') // Decimals
.replace(/-?\.\d+\b/g, '?') // Decimals starting with dot
.replace(integerLiteralRE, '?') // Integers (NOT $n placeholders)
// Collapse IN clauses for cardinality (both ? and $n variants)
.replace(/\bIN\b\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/gi, 'IN (?)')
.replace(/\bIN\b\s*\(\s*\$\d+(?:\s*,\s*\$\d+)*\s*\)/gi, 'IN ($?)')
);
}

/**
* Returns connection context attributes.
*
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/server-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ export type {
/* oxlint-enable typescript/no-deprecated */
export {
instrumentPostgresJsSql,
_sanitizeSqlQuery as _INTERNAL_sanitizeSqlQuery,
_reconstructQuery as _INTERNAL_reconstructPostgresQuery,
_buildConnectionContext as _INTERNAL_buildPostgresConnectionContext,
_getConnectionAttributes as _INTERNAL_getConnectionAttributes,
_getOperationName as _INTERNAL_getPostgresOperationName,
} from './integrations/postgresjs';
export type { PostgresConnectionContext } from './integrations/postgresjs';
export { getSqlQuerySummary as _INTERNAL_getSqlQuerySummary } from './utils/sql';
export {
getSqlQuerySummary as _INTERNAL_getSqlQuerySummary,
sanitizeSqlQuery as _INTERNAL_sanitizeSqlQuery,
} from './utils/sql';
export type { SqlDialect } from './utils/sql';

export { patchHttpModuleClient } from './integrations/http/client-patch';
export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions';
Expand Down
152 changes: 152 additions & 0 deletions packages/core/src/utils/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,155 @@ function truncate(summary: string): string {
const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 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]!;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncommented non-null assertion

Low Severity

This new non-null assertion on sql[i] has no comment explaining why a safer type is not possible. I flagged it because the PR review guidelines require that every ! in SDK source documents why a tighter type cannot be used.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 6624b07. Configure here.

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('(?<!\\$)-?\\b\\d+\\b', 'g');
}

return (
// Strip comments and string literals first: everything below is a regex that cannot tell
// whether it is looking at SQL syntax or at a user-supplied value.
stripLiteralsAndComments(sqlQuery, dialect)
.replace(/;\s*$/, '') // Remove trailing semicolons
// Collapse whitespace to a single space (after removing comments)
.replace(/\s+/g, ' ')
.trim() // Remove extra spaces and trim
// Sanitize hex numbers
.replace(/\b0x[0-9A-Fa-f]+/gi, '?')
// Sanitize boolean literals
.replace(/\b(?:TRUE|FALSE)\b/gi, '?')
// Sanitize numeric literals (preserve $n placeholders via negative lookbehind)
.replace(/-?\b\d+\.?\d*[eE][+-]?\d+\b/g, '?') // Scientific notation
.replace(/-?\b\d+\.\d+\b/g, '?') // Decimals
.replace(/-?\.\d+\b/g, '?') // Decimals starting with dot
.replace(integerLiteralRE, '?') // Integers (NOT $n placeholders)
// Collapse IN clauses for cardinality (both ? and $n variants)
.replace(/\bIN\b\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/gi, 'IN (?)')
.replace(/\bIN\b\s*\(\s*\$\d+(?:\s*,\s*\$\d+)*\s*\)/gi, 'IN ($?)')
);
}
Loading
Loading