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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '123 KB',
limit: '124 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand Down
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 @@ -28,14 +28,17 @@ export type {
} from './integrations/express/types';
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]!;
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