From 542c56d85cec39c49c51436ed9d072eeb34bcf20 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 27 Feb 2026 14:37:57 +0100 Subject: [PATCH 1/2] feat(core,cloudflare,deno): Add instrumentPostgresJsSql instrumentation --- packages/cloudflare/src/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/integrations/postgresjs.ts | 448 +++++++++++++ packages/core/src/tracing/langchain/index.ts | 13 +- .../test/lib/integrations/postgresjs.test.ts | 602 ++++++++++++++++++ packages/deno/src/index.ts | 1 + .../src/integrations/tracing/postgresjs.ts | 327 +--------- 7 files changed, 1069 insertions(+), 324 deletions(-) create mode 100644 packages/core/src/integrations/postgresjs.ts create mode 100644 packages/core/test/lib/integrations/postgresjs.test.ts diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 33572c81714d..62263627aa24 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -85,6 +85,7 @@ export { moduleMetadataIntegration, supabaseIntegration, instrumentSupabaseClient, + instrumentPostgresJsSql, zodErrorsIntegration, consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fd6a4a9c8d5..5ff7aa6b7a59 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,7 @@ export { dedupeIntegration } from './integrations/dedupe'; export { extraErrorDataIntegration } from './integrations/extraerrordata'; export { rewriteFramesIntegration } from './integrations/rewriteframes'; export { supabaseIntegration, instrumentSupabaseClient } from './integrations/supabase'; +export { instrumentPostgresJsSql } from './integrations/postgresjs'; export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts new file mode 100644 index 000000000000..b01ac13b1708 --- /dev/null +++ b/packages/core/src/integrations/postgresjs.ts @@ -0,0 +1,448 @@ +// Portable instrumentation for https://github.com/porsager/postgres +// This can be used in any environment (Node.js, Cloudflare Workers, etc.) +// without depending on OpenTelemetry module hooking. + +import { DEBUG_BUILD } from '../debug-build'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import { SPAN_STATUS_ERROR, startSpanManual } from '../tracing'; +import type { Span } from '../types-hoist/span'; +import { debug } from '../utils/debug-logger'; +import { getActiveSpan } from '../utils/spanUtils'; + +const SQL_OPERATION_REGEX = /^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i; + +type PostgresConnectionContext = { + ATTR_DB_NAMESPACE?: string; + ATTR_SERVER_ADDRESS?: string; + ATTR_SERVER_PORT?: string; +}; + +interface PostgresJsSqlInstrumentationOptions { + /** + * Whether to require a parent span for the instrumentation. + * If set to true, the instrumentation will only create spans if there is a parent span + * available in the current scope. + * @default true + */ + requireParentSpan?: boolean; + /** + * Hook to modify the span before it is started. + * This can be used to set additional attributes or modify the span in any way. + */ + requestHook?: (span: Span, sanitizedSqlQuery: string, postgresConnectionContext?: PostgresConnectionContext) => void; +} + +const CONNECTION_CONTEXT_SYMBOL = Symbol('sentryPostgresConnectionContext'); + +// Use the same Symbol.for() markers as the Node.js OTel instrumentation +// so that both approaches recognize each other and prevent double-wrapping. +const INSTRUMENTED_MARKER = Symbol.for('sentry.instrumented.postgresjs'); +// Marker to track if a query was created from an instrumented sql instance. +// This prevents double-spanning when both the wrapper and the Node.js Query.prototype +// fallback patch are active simultaneously. +const QUERY_FROM_INSTRUMENTED_SQL = Symbol.for('sentry.query.from.instrumented.sql'); + +/** + * Instruments a postgres.js `sql` instance with Sentry tracing. + * + * This is a portable instrumentation function that works in any environment + * (Node.js, Cloudflare Workers, etc.) without depending on OpenTelemetry. + * + * @example + * ```javascript + * import postgres from 'postgres'; + * import * as Sentry from '@sentry/cloudflare'; // or '@sentry/deno' + * + * const sql = Sentry.instrumentPostgresJsSql( + * postgres({ host: 'localhost', database: 'mydb' }) + * ); + * + * // All queries now create Sentry spans + * await sql`SELECT * FROM users WHERE id = ${userId}`; + * ``` + */ +export function instrumentPostgresJsSql(sql: T, options?: PostgresJsSqlInstrumentationOptions): T { + if (!sql || typeof sql !== 'function') { + DEBUG_BUILD && debug.warn('instrumentPostgresJsSql: provided value is not a valid postgres.js sql instance'); + return sql; + } + + return _instrumentSqlInstance(sql, { requireParentSpan: true, ...options }) as T; +} + +/** + * Instruments a sql instance by wrapping its query execution methods. + */ +function _instrumentSqlInstance( + sql: unknown, + options: PostgresJsSqlInstrumentationOptions, + parentConnectionContext?: PostgresConnectionContext, +): unknown { + // Check if already instrumented to prevent double-wrapping + // Using Symbol.for() ensures the marker survives proxying + if ((sql as Record)[INSTRUMENTED_MARKER]) { + return sql; + } + + // Wrap the sql function to intercept query creation + const proxiedSql: unknown = new Proxy(sql as (...args: unknown[]) => unknown, { + apply(target, thisArg, argumentsList: unknown[]) { + const query = Reflect.apply(target, thisArg, argumentsList); + + if (query && typeof query === 'object' && 'handle' in query) { + _wrapSingleQueryHandle(query as { handle: unknown; strings?: string[] }, proxiedSql, options); + } + + return query; + }, + get(target, prop) { + const original = (target as unknown as Record)[prop]; + + if (typeof prop !== 'string' || typeof original !== 'function') { + return original; + } + + // Wrap methods that return PendingQuery objects (unsafe, file) + if (prop === 'unsafe' || prop === 'file') { + return _wrapQueryMethod(original as (...args: unknown[]) => unknown, target, proxiedSql, options); + } + + // Wrap begin and reserve (not savepoint to avoid duplicate spans) + if (prop === 'begin' || prop === 'reserve') { + return _wrapCallbackMethod(original as (...args: unknown[]) => unknown, target, proxiedSql, options); + } + + return original; + }, + }); + + // Use provided parent context if available, otherwise extract from sql.options + if (parentConnectionContext) { + (proxiedSql as Record)[CONNECTION_CONTEXT_SYMBOL] = parentConnectionContext; + } else { + _attachConnectionContext(sql, proxiedSql as Record); + } + + // Mark both the original and proxy as instrumented to prevent double-wrapping + (sql as Record)[INSTRUMENTED_MARKER] = true; + (proxiedSql as Record)[INSTRUMENTED_MARKER] = true; + + return proxiedSql; +} + +/** + * Wraps query-returning methods (unsafe, file) to ensure their queries are instrumented. + */ +function _wrapQueryMethod( + original: (...args: unknown[]) => unknown, + target: unknown, + proxiedSql: unknown, + options: PostgresJsSqlInstrumentationOptions, +): (...args: unknown[]) => unknown { + return function (this: unknown, ...args: unknown[]): unknown { + const query = Reflect.apply(original, target, args); + + if (query && typeof query === 'object' && 'handle' in query) { + _wrapSingleQueryHandle(query as { handle: unknown; strings?: string[] }, proxiedSql, options); + } + + return query; + }; +} + +/** + * Wraps callback-based methods (begin, reserve) to recursively instrument Sql instances. + * Note: These methods can also be used as tagged templates, which we pass through unchanged. + * + * Savepoint is not wrapped to avoid complex nested transaction instrumentation issues. + * Queries within savepoint callbacks are still instrumented through the parent transaction's Sql instance. + */ +function _wrapCallbackMethod( + original: (...args: unknown[]) => unknown, + target: unknown, + parentSqlInstance: unknown, + options: PostgresJsSqlInstrumentationOptions, +): (...args: unknown[]) => unknown { + return function (this: unknown, ...args: unknown[]): unknown { + // Extract parent context to propagate to child instances + const parentContext = (parentSqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as + | PostgresConnectionContext + | undefined; + + // Check if this is a callback-based call by verifying the last argument is a function + const isCallbackBased = typeof args[args.length - 1] === 'function'; + + if (!isCallbackBased) { + // Not a callback-based call - could be tagged template or promise-based + const result = Reflect.apply(original, target, args); + // If result is a Promise (e.g., reserve() without callback), instrument the resolved Sql instance + if (result && typeof (result as Promise).then === 'function') { + return (result as Promise).then((sqlInstance: unknown) => { + return _instrumentSqlInstance(sqlInstance, options, parentContext); + }); + } + return result; + } + + // Callback-based call: wrap the callback to instrument the Sql instance + const callback = (args.length === 1 ? args[0] : args[1]) as (sql: unknown) => unknown; + const wrappedCallback = function (sqlInstance: unknown): unknown { + const instrumentedSql = _instrumentSqlInstance(sqlInstance, options, parentContext); + return callback(instrumentedSql); + }; + + const newArgs = args.length === 1 ? [wrappedCallback] : [args[0], wrappedCallback]; + return Reflect.apply(original, target, newArgs); + }; +} + +/** + * Wraps a single query's handle method to create spans. + */ +function _wrapSingleQueryHandle( + query: { handle: unknown; strings?: string[]; __sentryWrapped?: boolean }, + sqlInstance: unknown, + options: PostgresJsSqlInstrumentationOptions, +): void { + // Prevent double wrapping - check if the handle itself is already wrapped + if ((query.handle as { __sentryWrapped?: boolean })?.__sentryWrapped) { + return; + } + + // Mark this query as coming from an instrumented sql instance. + // This prevents the Node.js Query.prototype fallback patch from double-spanning. + (query as Record)[QUERY_FROM_INSTRUMENTED_SQL] = true; + + const originalHandle = query.handle as (...args: unknown[]) => Promise; + + // IMPORTANT: We must replace the handle function directly, not use a Proxy, + // because Query.then() internally calls this.handle(), which would bypass a Proxy wrapper. + const wrappedHandle = async function (this: unknown, ...args: unknown[]): Promise { + if (!_shouldCreateSpans(options)) { + return originalHandle.apply(this, args); + } + + const fullQuery = _reconstructQuery(query.strings); + const sanitizedSqlQuery = _sanitizeSqlQuery(fullQuery); + + return startSpanManual( + { + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + }, + (span: Span) => { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.postgresjs'); + + span.setAttributes({ + 'db.system.name': 'postgres', + 'db.query.text': sanitizedSqlQuery, + }); + + const connectionContext = sqlInstance + ? ((sqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as + | PostgresConnectionContext + | undefined) + : undefined; + + _setConnectionAttributes(span, connectionContext); + + if (options.requestHook) { + try { + options.requestHook(span, sanitizedSqlQuery, connectionContext); + } catch (e) { + span.setAttribute('sentry.hook.error', 'requestHook failed'); + DEBUG_BUILD && debug.error('Error in requestHook for PostgresJs instrumentation:', e); + } + } + + const queryWithCallbacks = this as { + resolve: unknown; + reject: unknown; + }; + + queryWithCallbacks.resolve = new Proxy(queryWithCallbacks.resolve as (...args: unknown[]) => unknown, { + apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { + try { + _setOperationName(span, sanitizedSqlQuery, resolveArgs?.[0]?.command); + span.end(); + } catch (e) { + DEBUG_BUILD && debug.error('Error ending span in resolve callback:', e); + } + + return Reflect.apply(resolveTarget, resolveThisArg, resolveArgs); + }, + }); + + queryWithCallbacks.reject = new Proxy(queryWithCallbacks.reject as (...args: unknown[]) => unknown, { + apply: (rejectTarget, rejectThisArg, rejectArgs: { message?: string; code?: string; name?: string }[]) => { + try { + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: rejectArgs?.[0]?.message || 'unknown_error', + }); + + span.setAttribute('db.response.status_code', rejectArgs?.[0]?.code || 'unknown'); + span.setAttribute('error.type', rejectArgs?.[0]?.name || 'unknown'); + + _setOperationName(span, sanitizedSqlQuery); + span.end(); + } catch (e) { + DEBUG_BUILD && debug.error('Error ending span in reject callback:', e); + } + return Reflect.apply(rejectTarget, rejectThisArg, rejectArgs); + }, + }); + + // Handle synchronous errors that might occur before promise is created + try { + return originalHandle.apply(this, args); + } catch (e) { + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: e instanceof Error ? e.message : 'unknown_error', + }); + span.end(); + throw e; + } + }, + ); + }; + + (wrappedHandle as { __sentryWrapped?: boolean }).__sentryWrapped = true; + query.handle = wrappedHandle; +} + +/** + * Determines whether a span should be created based on the current context. + * If `requireParentSpan` is set to true in the options, a span will + * only be created if there is a parent span available. + */ +function _shouldCreateSpans(options: PostgresJsSqlInstrumentationOptions): boolean { + const hasParentSpan = getActiveSpan() !== undefined; + return hasParentSpan || !options.requireParentSpan; +} + +/** + * Reconstructs the full SQL query from template strings with PostgreSQL placeholders. + * + * For sql`SELECT * FROM users WHERE id = ${123} AND name = ${'foo'}`: + * strings = ["SELECT * FROM users WHERE id = ", " AND name = ", ""] + * returns: "SELECT * FROM users WHERE id = $1 AND name = $2" + * + * @internal Exported for testing only + */ +export function _reconstructQuery(strings: string[] | undefined): string | undefined { + if (!strings?.length) { + return undefined; + } + if (strings.length === 1) { + return strings[0] || undefined; + } + // Join template parts with PostgreSQL placeholders ($1, $2, etc.) + return strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), ''); +} + +/** + * 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'; + } + + 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(/(?): void { + const sqlInstance = sql as { options?: { host?: string[]; port?: number[]; database?: string } }; + if (!sqlInstance.options || typeof sqlInstance.options !== 'object') { + return; + } + + const opts = sqlInstance.options; + // postgres.js stores parsed options with host and port as arrays + // The library defaults to 'localhost' and 5432 if not specified, but we're defensive here + const host = opts.host?.[0] || 'localhost'; + const port = opts.port?.[0] || 5432; + + const connectionContext: PostgresConnectionContext = { + ATTR_DB_NAMESPACE: typeof opts.database === 'string' && opts.database !== '' ? opts.database : undefined, + ATTR_SERVER_ADDRESS: host, + ATTR_SERVER_PORT: String(port), + }; + + proxiedSql[CONNECTION_CONTEXT_SYMBOL] = connectionContext; +} diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 7484219d32d9..8cf12dfcb861 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -184,17 +184,8 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // Chain Start Handler - handleChainStart( - chain: { name?: string }, - inputs: Record, - runId: string, - _parentRunId?: string, - _tags?: string[], - _metadata?: Record, - _runType?: string, - runName?: string, - ) { - const chainName = runName || chain.name || 'unknown_chain'; + handleChainStart(chain: { name?: string }, inputs: Record, runId: string, _parentRunId?: string) { + const chainName = chain.name || 'unknown_chain'; const attributes: Record = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', 'langchain.chain.name': chainName, diff --git a/packages/core/test/lib/integrations/postgresjs.test.ts b/packages/core/test/lib/integrations/postgresjs.test.ts new file mode 100644 index 000000000000..dfc159808377 --- /dev/null +++ b/packages/core/test/lib/integrations/postgresjs.test.ts @@ -0,0 +1,602 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _reconstructQuery, _sanitizeSqlQuery, instrumentPostgresJsSql } from '../../../src/integrations/postgresjs'; +import * as spanUtils from '../../../src/utils/spanUtils'; + +describe('PostgresJs portable instrumentation', () => { + describe('_reconstructQuery', () => { + describe('empty input handling', () => { + it.each([ + [undefined, undefined], + [null as unknown as undefined, undefined], + [[], undefined], + [[''], undefined], + ])('returns undefined for %p', (input, expected) => { + expect(_reconstructQuery(input)).toBe(expected); + }); + + it('returns whitespace-only string as-is', () => { + expect(_reconstructQuery([' '])).toBe(' '); + }); + }); + + describe('single-element array (non-parameterized)', () => { + it.each([ + ['SELECT * FROM users', 'SELECT * FROM users'], + ['SELECT * FROM users WHERE id = $1', 'SELECT * FROM users WHERE id = $1'], + ['INSERT INTO users (email, name) VALUES ($1, $2)', 'INSERT INTO users (email, name) VALUES ($1, $2)'], + ])('returns %p as-is', (input, expected) => { + expect(_reconstructQuery([input])).toBe(expected); + }); + }); + + describe('multi-element array (parameterized)', () => { + it.each([ + [['SELECT * FROM users WHERE id = ', ''], 'SELECT * FROM users WHERE id = $1'], + [['SELECT * FROM users WHERE id = ', ' AND name = ', ''], 'SELECT * FROM users WHERE id = $1 AND name = $2'], + [['INSERT INTO t VALUES (', ', ', ', ', ')'], 'INSERT INTO t VALUES ($1, $2, $3)'], + [['', ' WHERE id = ', ''], '$1 WHERE id = $2'], + [ + ['SELECT * FROM ', ' WHERE id = ', ' AND status IN (', ', ', ') ORDER BY ', ''], + 'SELECT * FROM $1 WHERE id = $2 AND status IN ($3, $4) ORDER BY $5', + ], + ])('reconstructs %p to %p', (input, expected) => { + expect(_reconstructQuery(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it('handles 10+ parameters', () => { + const strings = ['INSERT INTO t VALUES (', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ')']; + expect(_reconstructQuery(strings)).toBe('INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)'); + }); + + it.each([ + [['SELECT * FROM users WHERE id = ', ' ', ''], 'SELECT * FROM users WHERE id = $1 $2'], + [['SELECT * FROM users WHERE id = ', ' LIMIT 10'], 'SELECT * FROM users WHERE id = $1 LIMIT 10'], + [['SELECT *\nFROM users\nWHERE id = ', ''], 'SELECT *\nFROM users\nWHERE id = $1'], + [['SELECT * FROM "User" WHERE "email" = ', ''], 'SELECT * FROM "User" WHERE "email" = $1'], + [['SELECT ', '', '', ''], 'SELECT $1$2$3'], + [['', ''], '$1'], + ])('handles edge case %p', (input, expected) => { + expect(_reconstructQuery(input)).toBe(expected); + }); + }); + + 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'); + }); + + it('collapses IN clause with $n to IN ($?)', () => { + const strings = ['SELECT * FROM users WHERE id = ', ' AND status IN (', ', ', ', ', ')']; + 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'); + }); + + 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', + ); + }); + }); + }); + + describe('instrumentPostgresJsSql', () => { + it('returns non-function values unchanged', () => { + expect(instrumentPostgresJsSql(null as any)).toBe(null); + expect(instrumentPostgresJsSql(undefined as any)).toBe(undefined); + expect(instrumentPostgresJsSql(42 as any)).toBe(42); + expect(instrumentPostgresJsSql('string' as any)).toBe('string'); + }); + + it('wraps sql function and intercepts tagged template calls', () => { + const mockQuery = { handle: vi.fn(), strings: ['SELECT * FROM users WHERE id = ', ''] }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql); + expect(instrumented).not.toBe(mockSql); + expect(typeof instrumented).toBe('function'); + + // Invoke the instrumented function + const result = instrumented(['SELECT * FROM users WHERE id = ', ''], 1); + expect(mockSql).toHaveBeenCalledWith(['SELECT * FROM users WHERE id = ', ''], 1); + expect(result).toBe(mockQuery); + // The handle should have been wrapped + expect((mockQuery.handle as any).__sentryWrapped).toBe(true); + }); + + it('wraps unsafe method', () => { + const mockQuery = { handle: vi.fn(), strings: undefined }; + const mockSql = vi.fn(); + mockSql.unsafe = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql as any); + const result = instrumented.unsafe('SELECT 1'); + expect(mockSql.unsafe).toHaveBeenCalledWith('SELECT 1'); + expect(result).toBe(mockQuery); + expect((mockQuery.handle as any).__sentryWrapped).toBe(true); + }); + + it('wraps file method', () => { + const mockQuery = { handle: vi.fn(), strings: undefined }; + const mockSql = vi.fn(); + mockSql.file = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql as any); + const result = instrumented.file('test.sql'); + expect(mockSql.file).toHaveBeenCalledWith('test.sql'); + expect(result).toBe(mockQuery); + expect((mockQuery.handle as any).__sentryWrapped).toBe(true); + }); + + it('wraps begin method with callback', () => { + const mockSql = vi.fn(); + const innerSql = vi.fn(); + mockSql.begin = vi.fn().mockImplementation((cb: (sql: unknown) => unknown) => { + return cb(innerSql); + }); + + const instrumented = instrumentPostgresJsSql(mockSql as any); + let receivedSql: unknown; + instrumented.begin((sql: unknown) => { + receivedSql = sql; + return 'result'; + }); + + // The callback should receive an instrumented sql instance (a proxy, not the raw innerSql) + expect(receivedSql).not.toBe(innerSql); + expect(typeof receivedSql).toBe('function'); + }); + + it('wraps reserve method with promise', async () => { + const innerSql = vi.fn(); + const mockSql = vi.fn(); + mockSql.reserve = vi.fn().mockResolvedValue(innerSql); + + const instrumented = instrumentPostgresJsSql(mockSql as any); + const result = await instrumented.reserve(); + + // The resolved instance should be instrumented + expect(result).not.toBe(innerSql); + expect(typeof result).toBe('function'); + }); + + it('prevents double-instrumentation via Symbol marker', () => { + const mockSql = vi.fn(); + const instrumented1 = instrumentPostgresJsSql(mockSql); + const instrumented2 = instrumentPostgresJsSql(instrumented1); + + // Should return the same proxy, not double-wrap + expect(instrumented2).toBe(instrumented1); + }); + + it('extracts connection context from sql.options', () => { + const mockQuery = { handle: vi.fn(), strings: ['SELECT 1'] }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + mockSql.options = { + host: ['db.example.com'], + port: [5433], + database: 'testdb', + }; + + const instrumented = instrumentPostgresJsSql(mockSql as any); + + // We can't access the connection context directly via a new Symbol, + // but we can verify the proxy was created + expect(instrumented).not.toBe(mockSql); + }); + + describe('span creation', () => { + beforeEach(() => { + // By default, mock getActiveSpan to return undefined (no parent) + vi.spyOn(spanUtils, 'getActiveSpan').mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('skips span creation when requireParentSpan is true (default) and no parent span', async () => { + const originalHandle = vi.fn().mockResolvedValue([]); + const mockQuery = { + handle: originalHandle, + strings: ['SELECT * FROM users'], + resolve: vi.fn(), + reject: vi.fn(), + }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql); + instrumented(['SELECT * FROM users']); + + // handle is wrapped but when called, since there's no parent span, + // it should delegate to the original + const wrappedHandle = mockQuery.handle as (...args: unknown[]) => Promise; + await wrappedHandle.call(mockQuery); + + // The original handle should have been called directly (no span creation) + expect(originalHandle).toHaveBeenCalled(); + }); + + it('creates spans when requireParentSpan is false', async () => { + const handleFn = vi.fn().mockResolvedValue([]); + const mockQuery = { + handle: handleFn, + strings: ['SELECT * FROM users'], + resolve: vi.fn(), + reject: vi.fn(), + }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + + const instrumented = instrumentPostgresJsSql(mockSql, { requireParentSpan: false }); + instrumented(['SELECT * FROM users']); + + // handle was wrapped + expect((mockQuery.handle as any).__sentryWrapped).toBe(true); + }); + }); + + it('does not wrap non-query results from sql call', () => { + const nonQueryResult = { notAQuery: true }; + const mockSql = vi.fn().mockReturnValue(nonQueryResult); + + const instrumented = instrumentPostgresJsSql(mockSql); + const result = instrumented(); + + // Should pass through without trying to wrap + expect(result).toBe(nonQueryResult); + }); + + it('passes through non-function properties', () => { + const mockSql = vi.fn(); + (mockSql as any).someProperty = 'value'; + (mockSql as any).someNumber = 42; + + const instrumented = instrumentPostgresJsSql(mockSql as any); + expect(instrumented.someProperty).toBe('value'); + expect(instrumented.someNumber).toBe(42); + }); + + it('handles requestHook errors gracefully', () => { + const handleFn = vi.fn().mockResolvedValue([]); + const mockQuery = { + handle: handleFn, + strings: ['SELECT 1'], + resolve: vi.fn(), + reject: vi.fn(), + }; + const mockSql = vi.fn().mockReturnValue(mockQuery); + + const badHook = vi.fn().mockImplementation(() => { + throw new Error('hook error'); + }); + + // Should not throw + const instrumented = instrumentPostgresJsSql(mockSql, { requestHook: badHook }); + instrumented(['SELECT 1']); + + // The handle was wrapped despite the bad hook + expect((mockQuery.handle as any).__sentryWrapped).toBe(true); + }); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 91c1ea77f8e7..e6fdde530c81 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -78,6 +78,7 @@ export { rewriteFramesIntegration, supabaseIntegration, instrumentSupabaseClient, + instrumentPostgresJsSql, zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index ddb588b90585..86f7952712d2 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ // Instrumentation for https://github.com/porsager/postgres import { context, trace } from '@opentelemetry/api'; @@ -10,19 +9,17 @@ import { safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import { - ATTR_DB_NAMESPACE, ATTR_DB_OPERATION_NAME, ATTR_DB_QUERY_TEXT, ATTR_DB_RESPONSE_STATUS_CODE, ATTR_DB_SYSTEM_NAME, ATTR_ERROR_TYPE, - ATTR_SERVER_ADDRESS, - ATTR_SERVER_PORT, } from '@opentelemetry/semantic-conventions'; import type { IntegrationFn, Span } from '@sentry/core'; import { debug, defineIntegration, + instrumentPostgresJsSql, replaceExports, SDK_VERSION, SPAN_STATUS_ERROR, @@ -41,8 +38,6 @@ type PostgresConnectionContext = { ATTR_SERVER_PORT?: string; // Port number of the database server }; -const CONNECTION_CONTEXT_SYMBOL = Symbol('sentryPostgresConnectionContext'); -const INSTRUMENTED_MARKER = Symbol.for('sentry.instrumented.postgresjs'); // Marker to track if a query was created from an instrumented sql instance // This prevents double-spanning when both wrapper and prototype patches are active const QUERY_FROM_INSTRUMENTED_SQL = Symbol.for('sentry.query.from.instrumented.sql'); @@ -146,7 +141,12 @@ export class PostgresJsInstrumentation extends InstrumentationBase unknown, - target: unknown, - proxiedSql: unknown, - ): (...args: unknown[]) => unknown { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - return function (this: unknown, ...args: unknown[]): unknown { - const query = Reflect.apply(original, target, args); - - if (query && typeof query === 'object' && 'handle' in query) { - self._wrapSingleQueryHandle(query as { handle: unknown; strings?: string[] }, proxiedSql); - } - - return query; - }; - } - - /** - * Wraps callback-based methods (begin, reserve) to recursively instrument Sql instances. - * Note: These methods can also be used as tagged templates, which we pass through unchanged. - * - * Savepoint is not wrapped to avoid complex nested transaction instrumentation issues. - * Queries within savepoint callbacks are still instrumented through the parent transaction's Sql instance. - */ - private _wrapCallbackMethod( - original: (...args: unknown[]) => unknown, - target: unknown, - parentSqlInstance: unknown, - ): (...args: unknown[]) => unknown { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - return function (this: unknown, ...args: unknown[]): unknown { - // Extract parent context to propagate to child instances - const parentContext = (parentSqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as - | PostgresConnectionContext - | undefined; - - // Check if this is a callback-based call by verifying the last argument is a function - const isCallbackBased = typeof args[args.length - 1] === 'function'; - - if (!isCallbackBased) { - // Not a callback-based call - could be tagged template or promise-based - const result = Reflect.apply(original, target, args); - // If result is a Promise (e.g., reserve() without callback), instrument the resolved Sql instance - if (result && typeof (result as Promise).then === 'function') { - return (result as Promise).then((sqlInstance: unknown) => { - return self._instrumentSqlInstance(sqlInstance, parentContext); - }); - } - return result; - } - - // Callback-based call: wrap the callback to instrument the Sql instance - const callback = (args.length === 1 ? args[0] : args[1]) as (sql: unknown) => unknown; - const wrappedCallback = function (sqlInstance: unknown): unknown { - const instrumentedSql = self._instrumentSqlInstance(sqlInstance, parentContext); - return callback(instrumentedSql); - }; - - const newArgs = args.length === 1 ? [wrappedCallback] : [args[0], wrappedCallback]; - return Reflect.apply(original, target, newArgs); - }; - } - - /** - * Sets connection context attributes on a span. + * Determines whether a span should be created based on the current context. + * If `requireParentSpan` is set to true in the configuration, a span will + * only be created if there is a parent span available. */ - private _setConnectionAttributes(span: Span, connectionContext: PostgresConnectionContext | undefined): void { - if (!connectionContext) { - return; - } - if (connectionContext.ATTR_DB_NAMESPACE) { - span.setAttribute(ATTR_DB_NAMESPACE, connectionContext.ATTR_DB_NAMESPACE); - } - if (connectionContext.ATTR_SERVER_ADDRESS) { - span.setAttribute(ATTR_SERVER_ADDRESS, connectionContext.ATTR_SERVER_ADDRESS); - } - if (connectionContext.ATTR_SERVER_PORT !== undefined) { - // Port is stored as string in PostgresConnectionContext for requestHook backwards compatibility, - // but OTEL semantic conventions expect port as a number for span attributes - const portNumber = parseInt(connectionContext.ATTR_SERVER_PORT, 10); - if (!isNaN(portNumber)) { - span.setAttribute(ATTR_SERVER_PORT, portNumber); - } - } + private _shouldCreateSpans(): boolean { + const config = this.getConfig(); + const hasParentSpan = trace.getSpan(context.active()) !== undefined; + return hasParentSpan || !config.requireParentSpan; } /** @@ -277,225 +197,6 @@ export class PostgresJsInstrumentation extends InstrumentationBase): void { - const sqlInstance = sql as { options?: { host?: string[]; port?: number[]; database?: string } }; - if (!sqlInstance.options || typeof sqlInstance.options !== 'object') { - return; - } - - const opts = sqlInstance.options; - // postgres.js stores parsed options with host and port as arrays - // The library defaults to 'localhost' and 5432 if not specified, but we're defensive here - const host = opts.host?.[0] || 'localhost'; - const port = opts.port?.[0] || 5432; - - const connectionContext: PostgresConnectionContext = { - ATTR_DB_NAMESPACE: typeof opts.database === 'string' && opts.database !== '' ? opts.database : undefined, - ATTR_SERVER_ADDRESS: host, - ATTR_SERVER_PORT: String(port), - }; - - proxiedSql[CONNECTION_CONTEXT_SYMBOL] = connectionContext; - } - - /** - * Instruments a sql instance by wrapping its query execution methods. - */ - private _instrumentSqlInstance(sql: unknown, parentConnectionContext?: PostgresConnectionContext): unknown { - // Check if already instrumented to prevent double-wrapping - // Using Symbol.for() ensures the marker survives proxying - if ((sql as Record)[INSTRUMENTED_MARKER]) { - return sql; - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - // Wrap the sql function to intercept query creation - const proxiedSql: unknown = new Proxy(sql as (...args: unknown[]) => unknown, { - apply(target, thisArg, argumentsList: unknown[]) { - const query = Reflect.apply(target, thisArg, argumentsList); - - if (query && typeof query === 'object' && 'handle' in query) { - self._wrapSingleQueryHandle(query as { handle: unknown; strings?: string[] }, proxiedSql); - } - - return query; - }, - get(target, prop) { - const original = (target as unknown as Record)[prop]; - - if (typeof prop !== 'string' || typeof original !== 'function') { - return original; - } - - // Wrap methods that return PendingQuery objects (unsafe, file) - if (prop === 'unsafe' || prop === 'file') { - return self._wrapQueryMethod(original as (...args: unknown[]) => unknown, target, proxiedSql); - } - - // Wrap begin and reserve (not savepoint to avoid duplicate spans) - if (prop === 'begin' || prop === 'reserve') { - return self._wrapCallbackMethod(original as (...args: unknown[]) => unknown, target, proxiedSql); - } - - return original; - }, - }); - - // Use provided parent context if available, otherwise extract from sql.options - if (parentConnectionContext) { - (proxiedSql as Record)[CONNECTION_CONTEXT_SYMBOL] = parentConnectionContext; - } else { - this._attachConnectionContext(sql, proxiedSql as Record); - } - - // Mark both the original and proxy as instrumented to prevent double-wrapping - // The proxy might be passed to other methods, or the original - // might be accessed directly, so we need to mark both - (sql as Record)[INSTRUMENTED_MARKER] = true; - (proxiedSql as Record)[INSTRUMENTED_MARKER] = true; - - return proxiedSql; - } - - /** - * Wraps a single query's handle method to create spans. - */ - private _wrapSingleQueryHandle( - query: { handle: unknown; strings?: string[]; __sentryWrapped?: boolean }, - sqlInstance: unknown, - ): void { - // Prevent double wrapping - check if the handle itself is already wrapped - if ((query.handle as { __sentryWrapped?: boolean })?.__sentryWrapped) { - return; - } - - // Mark this query as coming from an instrumented sql instance - // This prevents the Query.prototype fallback patch from double-spanning - (query as Record)[QUERY_FROM_INSTRUMENTED_SQL] = true; - - const originalHandle = query.handle as (...args: unknown[]) => Promise; - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - // IMPORTANT: We must replace the handle function directly, not use a Proxy, - // because Query.then() internally calls this.handle(), which would bypass a Proxy wrapper. - const wrappedHandle = async function (this: unknown, ...args: unknown[]): Promise { - if (!self._shouldCreateSpans()) { - return originalHandle.apply(this, args); - } - - const fullQuery = self._reconstructQuery(query.strings); - const sanitizedSqlQuery = self._sanitizeSqlQuery(fullQuery); - - return startSpanManual( - { - name: sanitizedSqlQuery || 'postgresjs.query', - op: 'db', - }, - (span: Span) => { - addOriginToSpan(span, 'auto.db.postgresjs'); - - span.setAttributes({ - [ATTR_DB_SYSTEM_NAME]: 'postgres', - [ATTR_DB_QUERY_TEXT]: sanitizedSqlQuery, - }); - - const connectionContext = sqlInstance - ? ((sqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as - | PostgresConnectionContext - | undefined) - : undefined; - - self._setConnectionAttributes(span, connectionContext); - - const config = self.getConfig(); - const { requestHook } = config; - if (requestHook) { - safeExecuteInTheMiddle( - () => requestHook(span, sanitizedSqlQuery, connectionContext), - e => { - if (e) { - span.setAttribute('sentry.hook.error', 'requestHook failed'); - DEBUG_BUILD && debug.error(`Error in requestHook for ${INTEGRATION_NAME} integration:`, e); - } - }, - true, - ); - } - - const queryWithCallbacks = this as { - resolve: unknown; - reject: unknown; - }; - - queryWithCallbacks.resolve = new Proxy(queryWithCallbacks.resolve as (...args: unknown[]) => unknown, { - apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { - try { - self._setOperationName(span, sanitizedSqlQuery, resolveArgs?.[0]?.command); - span.end(); - } catch (e) { - DEBUG_BUILD && debug.error('Error ending span in resolve callback:', e); - } - - return Reflect.apply(resolveTarget, resolveThisArg, resolveArgs); - }, - }); - - queryWithCallbacks.reject = new Proxy(queryWithCallbacks.reject as (...args: unknown[]) => unknown, { - apply: (rejectTarget, rejectThisArg, rejectArgs: { message?: string; code?: string; name?: string }[]) => { - try { - span.setStatus({ - code: SPAN_STATUS_ERROR, - message: rejectArgs?.[0]?.message || 'unknown_error', - }); - - span.setAttribute(ATTR_DB_RESPONSE_STATUS_CODE, rejectArgs?.[0]?.code || 'unknown'); - span.setAttribute(ATTR_ERROR_TYPE, rejectArgs?.[0]?.name || 'unknown'); - - self._setOperationName(span, sanitizedSqlQuery); - span.end(); - } catch (e) { - DEBUG_BUILD && debug.error('Error ending span in reject callback:', e); - } - return Reflect.apply(rejectTarget, rejectThisArg, rejectArgs); - }, - }); - - // Handle synchronous errors that might occur before promise is created - try { - return originalHandle.apply(this, args); - } catch (e) { - span.setStatus({ - code: SPAN_STATUS_ERROR, - message: e instanceof Error ? e.message : 'unknown_error', - }); - span.end(); - throw e; - } - }, - ); - }; - - (wrappedHandle as { __sentryWrapped?: boolean }).__sentryWrapped = true; - query.handle = wrappedHandle; - } - - /** - * Determines whether a span should be created based on the current context. - * If `requireParentSpan` is set to true in the configuration, a span will - * only be created if there is a parent span available. - */ - private _shouldCreateSpans(): boolean { - const config = this.getConfig(); - const hasParentSpan = trace.getSpan(context.active()) !== undefined; - return hasParentSpan || !config.requireParentSpan; - } - /** * Reconstructs the full SQL query from template strings with PostgreSQL placeholders. * From 6f8db7df4256665da2041c7a094d79b717109a13 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 27 Feb 2026 14:38:00 +0100 Subject: [PATCH 2/2] Add changelog entry --- CHANGELOG.md | 23 ++++++++++++++++++++ packages/core/src/tracing/langchain/index.ts | 13 +++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e340c1f207..c4be25a861b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ ### Important Changes +- **feat(core,cloudflare,deno): Add `instrumentPostgresJsSql` instrumentation** + + Added a new instrumentation helper for the [`postgres`](https://github.com/porsager/postgres) (postgres.js) library, designed for + SDKs that are not based on OpenTelemetry (e.g. Cloudflare, Deno). This wraps a postgres.js `sql` tagged template instance so that + all queries automatically create Sentry spans. + + ```javascript + import postgres from 'postgres'; + import * as Sentry from '@sentry/cloudflare'; // or '@sentry/deno' + + export default Sentry.withSentry(env => ({ dsn: '__DSN__' }), { + async fetch(request, env, ctx) { + const sql = Sentry.instrumentPostgresJsSql(postgres(env.DATABASE_URL)); + + // All queries now create Sentry spans + const users = await sql`SELECT * FROM users WHERE id = ${userId}`; + return Response.json(users); + }, + }); + ``` + + The instrumentation is available in `@sentry/core`, `@sentry/cloudflare`, and `@sentry/deno`. + - **feat(nextjs): Add Turbopack support for `thirdPartyErrorFilterIntegration` ([#19542](https://github.com/getsentry/sentry-javascript/pull/19542))** We added experimental support for the `thirdPartyErrorFilterIntegration` with Turbopack builds. diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 8cf12dfcb861..7484219d32d9 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -184,8 +184,17 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // Chain Start Handler - handleChainStart(chain: { name?: string }, inputs: Record, runId: string, _parentRunId?: string) { - const chainName = chain.name || 'unknown_chain'; + handleChainStart( + chain: { name?: string }, + inputs: Record, + runId: string, + _parentRunId?: string, + _tags?: string[], + _metadata?: Record, + _runType?: string, + runName?: string, + ) { + const chainName = runName || chain.name || 'unknown_chain'; const attributes: Record = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', 'langchain.chain.name': chainName,