diff --git a/.size-limit.js b/.size-limit.js index 6e6ee0f68303..00b4bdbfd4d8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '160 KB', + limit: '161 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.cjs new file mode 100644 index 000000000000..0cf4c6185ef3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.cjs @@ -0,0 +1,25 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.postgresJsIntegration({ + requestHook: (span, sanitizedSqlQuery, connectionContext) => { + // Add custom attributes to demonstrate requestHook functionality + span.setAttribute('custom.requestHook', 'called'); + + // Set context information as extras for test validation + Sentry.setExtra('requestHookCalled', { + sanitizedQuery: sanitizedSqlQuery, + database: connectionContext?.ATTR_DB_NAMESPACE, + host: connectionContext?.ATTR_SERVER_ADDRESS, + port: connectionContext?.ATTR_SERVER_PORT, + }); + }, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.mjs new file mode 100644 index 000000000000..885c6198100b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.mjs @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.postgresJsIntegration({ + requestHook: (span, sanitizedSqlQuery, connectionContext) => { + // Add custom attributes to demonstrate requestHook functionality + span.setAttribute('custom.requestHook', 'called'); + + // Set context information as extras for test validation + Sentry.setExtra('requestHookCalled', { + sanitizedQuery: sanitizedSqlQuery, + database: connectionContext?.ATTR_DB_NAMESPACE, + host: connectionContext?.ATTR_SERVER_ADDRESS, + port: connectionContext?.ATTR_SERVER_PORT, + }); + }, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.cjs new file mode 100644 index 000000000000..6aec5f1f9384 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.cjs @@ -0,0 +1,9 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js new file mode 100644 index 000000000000..71da795216c0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js @@ -0,0 +1,40 @@ +const Sentry = require('@sentry/node'); +const postgres = require('postgres'); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + 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")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + await sql` + DROP TABLE "User"; + `; + } finally { + await sql.end(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs new file mode 100644 index 000000000000..154f9374ef0f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import postgres from 'postgres'; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + 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")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + await sql` + DROP TABLE "User"; + `; + } finally { + await sql.end(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs new file mode 100644 index 000000000000..0ee537052a4a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs @@ -0,0 +1,46 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// Import postgres AFTER Sentry.init() so instrumentation is set up +const postgres = require('postgres'); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +// Test with plain object options +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + // Test sql.unsafe() - this was not being instrumented before the fix + await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); + + await sql.unsafe('INSERT INTO "User" ("email") VALUES ($1)', ['test@example.com']); + + await sql.unsafe('SELECT * FROM "User" WHERE "email" = $1', ['test@example.com']); + + await sql.unsafe('DROP TABLE "User"'); + + // This will be captured as an error as the table no longer exists + await sql.unsafe('SELECT * FROM "User"'); + } finally { + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs new file mode 100644 index 000000000000..a6db6d7b0fec --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import postgres from 'postgres'; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +// Test with plain object options +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + // Test sql.unsafe() - this was not being instrumented before the fix + await sql.unsafe('CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))'); + + await sql.unsafe('INSERT INTO "User" ("email") VALUES ($1)', ['test@example.com']); + + await sql.unsafe('SELECT * FROM "User" WHERE "email" = $1', ['test@example.com']); + + await sql.unsafe('DROP TABLE "User"'); + + // This will be captured as an error as the table no longer exists + await sql.unsafe('SELECT * FROM "User"'); + } finally { + await sql.end(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs new file mode 100644 index 000000000000..1a5cc93e2261 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs @@ -0,0 +1,63 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// Import postgres AFTER Sentry.init() so instrumentation is set up +const postgres = require('postgres'); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +// Test URL-based initialization - this is the common pattern that was causing the regression +const sql = postgres('postgres://test:test@localhost:5444/test_db'); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + 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")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = 'bar@baz.com'; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = 'foo@baz.com'; + `; + } finally { + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs new file mode 100644 index 000000000000..181e264b8de6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs @@ -0,0 +1,75 @@ +import * as Sentry from '@sentry/node'; +import postgres from 'postgres'; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +// Test URL-based initialization - this is the common pattern that was causing the regression +const sql = postgres('postgres://test:test@localhost:5444/test_db'); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + 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")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = 'bar@baz.com'; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + // Test parameterized queries + await sql` + SELECT * FROM "User" WHERE "email" = ${'bar@baz.com'} AND "name" = ${'Foo'}; + `; + + // Test DELETE operation + await sql` + DELETE FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + // Test INSERT with RETURNING + await sql` + INSERT INTO "User" ("email", "name") VALUES ('test@example.com', 'Test User') RETURNING *; + `; + + // Test cursor-based queries + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + // Test multiple rows at once + await sql` + SELECT * FROM "User" LIMIT 10; + `; + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = 'foo@baz.com'; + `; + } finally { + await sql.end(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.cjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.cjs new file mode 100644 index 000000000000..d19b412dcbec --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.cjs @@ -0,0 +1,62 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// Import postgres AFTER Sentry.init() so instrumentation is set up +const postgres = require('postgres'); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + 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")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = 'bar@baz.com'; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = 'foo@baz.com'; + `; + } finally { + await sql.end(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js index f1010281f904..d9049353f6eb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js @@ -39,10 +39,31 @@ async function run() { SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; `; + // Test parameterized queries + await sql` + SELECT * FROM "User" WHERE "email" = ${'bar@baz.com'} AND "name" = ${'Foo'}; + `; + + // Test DELETE operation + await sql` + DELETE FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + // Test INSERT with RETURNING + await sql` + INSERT INTO "User" ("email", "name") VALUES ('test@example.com', 'Test User') RETURNING *; + `; + + // Test cursor-based queries await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { await Promise.all(rows); }); + // Test multiple rows at once + await sql` + SELECT * FROM "User" LIMIT 10; + `; + await sql` DROP TABLE "User"; `; diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs new file mode 100644 index 000000000000..28a0c384b21f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs @@ -0,0 +1,74 @@ +import * as Sentry from '@sentry/node'; +import postgres from 'postgres'; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + 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")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = 'bar@baz.com'; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + // Test parameterized queries + await sql` + SELECT * FROM "User" WHERE "email" = ${'bar@baz.com'} AND "name" = ${'Foo'}; + `; + + // Test DELETE operation + await sql` + DELETE FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + // Test INSERT with RETURNING + await sql` + INSERT INTO "User" ("email", "name") VALUES ('test@example.com', 'Test User') RETURNING *; + `; + + // Test cursor-based queries + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + // Test multiple rows at once + await sql` + SELECT * FROM "User" LIMIT 10; + `; + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = 'foo@baz.com'; + `; + } finally { + await sql.end(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts index 99203fd75ae6..d4ef892fd86d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -1,11 +1,15 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; const EXISTING_TEST_EMAIL = 'bar@baz.com'; const NON_EXISTING_TEST_EMAIL = 'foo@baz.com'; describe('postgresjs auto instrumentation', () => { - test('should auto-instrument `postgres` package', { timeout: 60_000 }, async () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should auto-instrument `postgres` package (CJS)', { timeout: 60_000 }, async () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ @@ -17,7 +21,7 @@ describe('postgresjs auto instrumentation', () => { 'db.query.text': '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"))', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), @@ -25,7 +29,136 @@ describe('postgresjs auto instrumentation', () => { '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"))', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + 'sentry.origin': 'auto.db.postgresjs', + 'sentry.op': 'db', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'UPDATE', + 'db.query.text': 'UPDATE "User" SET "name" = ? WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'UPDATE "User" SET "name" = ? WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + // Parameterized query test - verifies that tagged template queries with interpolations + // are properly reconstructed with $1, $2 placeholders which are PRESERVED per OTEL spec + // (PostgreSQL $n placeholders indicate parameterized queries that don't leak sensitive data) + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * from generate_series(?,?) as x', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * from generate_series(?,?) as x', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DROP TABLE', + 'db.query.text': 'DROP TABLE "User"', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -37,18 +170,85 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'SELECT', + 'db.response.status_code': '42P01', + 'error.type': 'PostgresError', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = ?', + op: 'db', + status: 'internal_error', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + ]), + }; + + const EXPECTED_ERROR_EVENT = { + event_id: expect.any(String), + contexts: { + trace: { + trace_id: expect.any(String), + span_id: expect.any(String), + }, + }, + exception: { + values: [ + { + type: 'PostgresError', + value: 'relation "User" does not exist', + stacktrace: expect.objectContaining({ + frames: expect.arrayContaining([ + expect.objectContaining({ + function: 'handle', + module: 'postgres.cjs.src:connection', + filename: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }), + }, + ], + }, + }; + + await createRunner(__dirname, 'scenario.js') + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start() + .completed(); + }); + + test('should auto-instrument `postgres` package (ESM)', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', 'db.query.text': - "select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem where a.typcategory = 'A' group by b.oid, b.typarray order by b.oid", + '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"))', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), description: - "select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem where a.typcategory = 'A' group by b.oid, b.typarray order by b.oid", + '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"))', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -60,16 +260,16 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'INSERT', - 'db.query.text': `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, - 'sentry.origin': 'auto.db.otel.postgres', + 'db.query.text': 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + 'sentry.origin': 'auto.db.postgresjs', 'sentry.op': 'db', 'server.address': 'localhost', 'server.port': 5444, }), - description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -81,37 +281,61 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'UPDATE', - 'db.query.text': `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + 'db.query.text': 'UPDATE "User" SET "name" = ? WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'UPDATE "User" SET "name" = ? WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), - description: `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + description: 'SELECT * FROM "User" WHERE "email" = ?', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), trace_id: expect.any(String), }), + // Parameterized query test - verifies that tagged template queries with interpolations + // are properly reconstructed with $1, $2 placeholders which are PRESERVED per OTEL spec + // (PostgreSQL $n placeholders indicate parameterized queries that don't leak sensitive data) expect.objectContaining({ data: expect.objectContaining({ 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'SELECT', - 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), - description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + description: 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -125,14 +349,14 @@ describe('postgresjs auto instrumentation', () => { 'db.operation.name': 'SELECT', 'db.query.text': 'SELECT * from generate_series(?,?) as x', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), description: 'SELECT * from generate_series(?,?) as x', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -146,14 +370,14 @@ describe('postgresjs auto instrumentation', () => { 'db.operation.name': 'DROP TABLE', 'db.query.text': 'DROP TABLE "User"', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), description: 'DROP TABLE "User"', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -164,19 +388,19 @@ describe('postgresjs auto instrumentation', () => { data: expect.objectContaining({ 'db.namespace': 'test_db', 'db.system.name': 'postgres', - // No db.operation.name here, as this is an errored span + 'db.operation.name': 'SELECT', 'db.response.status_code': '42P01', 'error.type': 'PostgresError', - 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_EMAIL}'`, + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), - description: `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_EMAIL}'`, + description: 'SELECT * FROM "User" WHERE "email" = ?', op: 'db', status: 'internal_error', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -203,7 +427,7 @@ describe('postgresjs auto instrumentation', () => { frames: expect.arrayContaining([ expect.objectContaining({ function: 'handle', - module: 'postgres.cjs.src:connection', + module: 'postgres.src:connection', filename: expect.any(String), lineno: expect.any(Number), colno: expect.any(Number), @@ -215,11 +439,519 @@ describe('postgresjs auto instrumentation', () => { }, }; - await createRunner(__dirname, 'scenario.js') + await createRunner(__dirname, 'scenario.mjs') + .withFlags('--import', `${__dirname}/instrument.mjs`) .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) .expect({ transaction: EXPECTED_TRANSACTION }) .expect({ event: EXPECTED_ERROR_EVENT }) .start() .completed(); }); + + test('should call requestHook when provided (CJS)', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': + '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"))', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: + '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"))', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DROP TABLE', + 'db.query.text': 'DROP TABLE "User"', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + ]), + extra: expect.objectContaining({ + requestHookCalled: expect.objectContaining({ + database: 'test_db', + host: 'localhost', + port: '5444', + sanitizedQuery: expect.any(String), + }), + }), + }; + + await createRunner(__dirname, 'scenario-requestHook.js') + .withFlags('--require', `${__dirname}/instrument-requestHook.cjs`) + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + + test('should call requestHook when provided (ESM)', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': + '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"))', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: + '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"))', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DROP TABLE', + 'db.query.text': 'DROP TABLE "User"', + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + ]), + extra: expect.objectContaining({ + requestHookCalled: expect.objectContaining({ + database: 'test_db', + host: 'localhost', + port: '5444', + sanitizedQuery: expect.any(String), + }), + }), + }; + + await createRunner(__dirname, 'scenario-requestHook.mjs') + .withFlags('--import', `${__dirname}/instrument-requestHook.mjs`) + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + + // Tests for URL-based initialization pattern (regression prevention) + test('should instrument postgres package with URL initialization (CJS)', { timeout: 90_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': + '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"))', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: + '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"))', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'UPDATE', + 'db.query.text': 'UPDATE "User" SET "name" = ? WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'UPDATE "User" SET "name" = ? WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + ]), + }; + + await createRunner(__dirname, 'scenario-url.cjs') + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + + test('should instrument postgres package with URL initialization (ESM)', { timeout: 90_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': + '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"))', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: + '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"))', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DELETE', + 'db.query.text': 'DELETE FROM "User" WHERE "email" = ?', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DELETE FROM "User" WHERE "email" = ?', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + ]), + }; + + await createRunner(__dirname, 'scenario-url.mjs') + .withFlags('--import', `${__dirname}/instrument.mjs`) + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + + test('should instrument sql.unsafe() queries (CJS)', { timeout: 90_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': 'CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + // sql.unsafe() with $1 placeholders - preserved per OTEL spec + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email") VALUES ($1)', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email") VALUES ($1)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = $1', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = $1', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DROP TABLE', + 'db.query.text': 'DROP TABLE "User"', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + ]), + }; + + await createRunner(__dirname, 'scenario-unsafe.cjs') + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + + test('should instrument sql.unsafe() queries (ESM)', { timeout: 90_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': 'CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'CREATE TABLE "User" ("id" SERIAL NOT NULL, "email" TEXT NOT NULL, PRIMARY KEY ("id"))', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + // sql.unsafe() with $1 placeholders - preserved per OTEL spec + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': 'INSERT INTO "User" ("email") VALUES ($1)', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email") VALUES ($1)', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = $1', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * FROM "User" WHERE "email" = $1', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DROP TABLE', + 'db.query.text': 'DROP TABLE "User"', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), + ]), + }; + + await createRunner(__dirname, 'scenario-unsafe.mjs') + .withFlags('--import', `${__dirname}/instrument.mjs`) + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); }); diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index 438a63c804c6..55ee90444b47 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ // Instrumentation for https://github.com/porsager/postgres import { context, trace } from '@opentelemetry/api'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; @@ -21,15 +22,17 @@ import type { IntegrationFn, Span } from '@sentry/core'; import { debug, defineIntegration, - getCurrentScope, + replaceExports, SDK_VERSION, SPAN_STATUS_ERROR, startSpanManual, } from '@sentry/core'; import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../../debug-build'; const INTEGRATION_NAME = 'PostgresJs'; const SUPPORTED_VERSIONS = ['>=3.0.0 <4']; +const SQL_OPERATION_REGEX = /^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i; type PostgresConnectionContext = { ATTR_DB_NAMESPACE?: string; // Database name @@ -37,6 +40,12 @@ 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'); + type PostgresJsInstrumentationConfig = InstrumentationConfig & { /** * Whether to require a parent span for the instrumentation. @@ -63,7 +72,9 @@ export const instrumentPostgresJs = generateInstrumentOnce( /** * Instrumentation for the [postgres](https://www.npmjs.com/package/postgres) library. - * This instrumentation captures postgresjs queries and their attributes, + * This instrumentation captures postgresjs queries and their attributes. + * + * Uses internal Sentry patching patterns to support both CommonJS and ESM environments. */ export class PostgresJsInstrumentation extends InstrumentationBase { public constructor(config: PostgresJsInstrumentationConfig) { @@ -71,210 +82,443 @@ export class PostgresJsInstrumentation extends InstrumentationBase { + try { + return this._patchPostgres(exports); + } catch (e) { + DEBUG_BUILD && debug.error('Failed to patch postgres module:', e); + return exports; + } + }, + exports => exports, + ); + // Add fallback Query.prototype patching for pre-existing sql instances (CJS only) + // This catches queries from sql instances created before Sentry was initialized ['src', 'cf/src', 'cjs/src'].forEach(path => { - instrumentationModule.files.push( - new InstrumentationNodeModuleFile( - `postgres/${path}/connection.js`, - ['*'], - this._patchConnection.bind(this), - this._unwrap.bind(this), - ), - ); - - instrumentationModule.files.push( + module.files.push( new InstrumentationNodeModuleFile( `postgres/${path}/query.js`, SUPPORTED_VERSIONS, - this._patchQuery.bind(this), - this._unwrap.bind(this), + this._patchQueryPrototype.bind(this), + this._unpatchQueryPrototype.bind(this), ), ); }); - return [instrumentationModule]; + return module; } /** - * 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. + * Patches the postgres module by wrapping the main export function. + * This intercepts the creation of sql instances and instruments them. */ - private _shouldCreateSpans(): boolean { - const config = this.getConfig(); - const hasParentSpan = trace.getSpan(context.active()) !== undefined; - return hasParentSpan || !config.requireParentSpan; + private _patchPostgres(exports: { [key: string]: unknown }): { [key: string]: unknown } { + // In CJS: exports is the function itself + // In ESM: exports.default is the function + const isFunction = typeof exports === 'function'; + const Original = isFunction ? exports : exports.default; + + if (typeof Original !== 'function') { + DEBUG_BUILD && debug.warn('postgres module does not export a function. Skipping instrumentation.'); + return exports; + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + const WrappedPostgres = function (this: unknown, ...args: unknown[]): unknown { + const sql = Reflect.construct(Original as (...args: unknown[]) => unknown, args); + + // Validate that construction succeeded and returned a valid function object + if (!sql || typeof sql !== 'function') { + DEBUG_BUILD && debug.warn('postgres() did not return a valid instance'); + return sql; + } + + return self._instrumentSqlInstance(sql); + }; + + Object.setPrototypeOf(WrappedPostgres, Original); + Object.setPrototypeOf(WrappedPostgres.prototype, (Original as { prototype: object }).prototype); + + for (const key of Object.getOwnPropertyNames(Original)) { + if (!['length', 'name', 'prototype'].includes(key)) { + const descriptor = Object.getOwnPropertyDescriptor(Original, key); + if (descriptor) { + Object.defineProperty(WrappedPostgres, key, descriptor); + } + } + } + + // For CJS: the exports object IS the function, so return the wrapped function + // For ESM: replace the default export + if (isFunction) { + return WrappedPostgres as unknown as { [key: string]: unknown }; + } else { + replaceExports(exports, 'default', WrappedPostgres); + return exports; + } } /** - * Patches the reject method of the Query class to set the span status and end it + * Wraps query-returning methods (unsafe, file) to ensure their queries are instrumented. */ - private _patchReject(rejectTarget: any, span: Span): any { - return new Proxy(rejectTarget, { - apply: ( - rejectTarget, - rejectThisArg, - rejectArgs: { - message?: string; - code?: string; - name?: string; - }[], - ) => { - span.setStatus({ - code: SPAN_STATUS_ERROR, - // This message is the error message from the rejectArgs, when available - // e.g "relation 'User' does not exist" - message: rejectArgs?.[0]?.message || 'internal_error', - }); - - const result = Reflect.apply(rejectTarget, rejectThisArg, rejectArgs); - - // This status code is PG error code, e.g. '42P01' for "relation does not exist" - // https://www.postgresql.org/docs/current/errcodes-appendix.html - span.setAttribute(ATTR_DB_RESPONSE_STATUS_CODE, rejectArgs?.[0]?.code || 'Unknown error'); - // This is the error type, e.g. 'PostgresError' for a Postgres error - span.setAttribute(ATTR_ERROR_TYPE, rejectArgs?.[0]?.name || 'Unknown error'); - - span.end(); - return result; - }, - }); + private _wrapQueryMethod( + original: (...args: unknown[]) => 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; + }; } /** - * Patches the resolve method of the Query class to end the span when the query is resolved. + * 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 _patchResolve(resolveTarget: any, span: Span): any { - return new Proxy(resolveTarget, { - apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { - const result = Reflect.apply(resolveTarget, resolveThisArg, resolveArgs); - const sqlCommand = resolveArgs?.[0]?.command; - - if (sqlCommand) { - // SQL command is only available when the query is resolved successfully - span.setAttribute(ATTR_DB_OPERATION_NAME, sqlCommand); + 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); + }); } - span.end(); 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); + }; } /** - * Patches the Query class to instrument the handle method. + * Sets connection context attributes on a span. */ - private _patchQuery(moduleExports: { - Query: { - prototype: { - handle: any; - }; + 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); + } + } + } + + /** + * Extracts DB operation name from SQL query and sets it on the span. + */ + private _setOperationName(span: Span, sanitizedQuery: string | undefined, command?: string): void { + if (command) { + span.setAttribute(ATTR_DB_OPERATION_NAME, command); + return; + } + // Fallback: extract operation from the SQL query + const operationMatch = sanitizedQuery?.match(SQL_OPERATION_REGEX); + if (operationMatch?.[1]) { + span.setAttribute(ATTR_DB_OPERATION_NAME, operationMatch[1].toUpperCase()); + } + } + + /** + * Extracts and stores connection context from sql.options. + */ + private _attachConnectionContext(sql: unknown, proxiedSql: Record): 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), }; - }): any { - moduleExports.Query.prototype.handle = new Proxy(moduleExports.Query.prototype.handle, { - apply: async ( - handleTarget, - handleThisArg: { - resolve: any; - reject: any; - strings?: string[]; - }, - handleArgs, - ) => { - if (!this._shouldCreateSpans()) { - // If we don't need to create spans, just call the original method - return Reflect.apply(handleTarget, handleThisArg, handleArgs); + + 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); } - const sanitizedSqlQuery = this._sanitizeSqlQuery(handleThisArg.strings?.[0]); - - return startSpanManual( - { - name: sanitizedSqlQuery || 'postgresjs.query', - op: 'db', - }, - (span: Span) => { - const scope = getCurrentScope(); - const postgresConnectionContext = scope.getScopeData().contexts['postgresjsConnection'] as - | PostgresConnectionContext - | undefined; - - addOriginToSpan(span, 'auto.db.otel.postgres'); - - const { requestHook } = this.getConfig(); - - if (requestHook) { - safeExecuteInTheMiddle( - () => requestHook(span, sanitizedSqlQuery, postgresConnectionContext), - error => { - if (error) { - debug.error(`Error in requestHook for ${INTEGRATION_NAME} integration:`, error); - } - }, - ); - } - - // ATTR_DB_NAMESPACE is used to indicate the database name and the schema name - // It's only the database name as we don't have the schema information - const databaseName = postgresConnectionContext?.ATTR_DB_NAMESPACE || ''; - const databaseHost = postgresConnectionContext?.ATTR_SERVER_ADDRESS || ''; - const databasePort = postgresConnectionContext?.ATTR_SERVER_PORT || ''; - - span.setAttribute(ATTR_DB_SYSTEM_NAME, 'postgres'); - span.setAttribute(ATTR_DB_NAMESPACE, databaseName); - span.setAttribute(ATTR_SERVER_ADDRESS, databaseHost); - span.setAttribute(ATTR_SERVER_PORT, databasePort); - span.setAttribute(ATTR_DB_QUERY_TEXT, sanitizedSqlQuery); - - handleThisArg.resolve = this._patchResolve(handleThisArg.resolve, span); - handleThisArg.reject = this._patchReject(handleThisArg.reject, span); - - try { - return Reflect.apply(handleTarget, handleThisArg, handleArgs); - } catch (error) { - span.setStatus({ - code: SPAN_STATUS_ERROR, - }); - span.end(); - throw error; // Re-throw the error to propagate it - } - }, - ); + 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; }, }); - return moduleExports; + // 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; } /** - * Patches the Connection class to set the database, host, and port attributes - * when a new connection is created. + * Wraps a single query's handle method to create spans. */ - private _patchConnection(Connection: any): any { - return new Proxy(Connection, { - apply: (connectionTarget, thisArg, connectionArgs: { database: string; host: string[]; port: number[] }[]) => { - const databaseName = connectionArgs[0]?.database || ''; - const databaseHost = connectionArgs[0]?.host?.[0] || ''; - const databasePort = connectionArgs[0]?.port?.[0] || ''; - - const scope = getCurrentScope(); - scope.setContext('postgresjsConnection', { - ATTR_DB_NAMESPACE: databaseName, - ATTR_SERVER_ADDRESS: databaseHost, - ATTR_SERVER_PORT: databasePort, - }); - - return Reflect.apply(connectionTarget, thisArg, connectionArgs); - }, - }); + 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. + * + * 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" + */ + private _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. */ private _sanitizeSqlQuery(sqlQuery: string | undefined): string { if (!sqlQuery) { @@ -283,27 +527,183 @@ export class PostgresJsInstrumentation extends InstrumentationBase Promise) & { + __sentry_original__?: (...args: unknown[]) => Promise; + }; + }; + }; + }): typeof moduleExports { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const originalHandle = moduleExports.Query.prototype.handle; + + moduleExports.Query.prototype.handle = async function ( + this: { + resolve: unknown; + reject: unknown; + strings?: string[]; + }, + ...args: unknown[] + ): Promise { + // Skip if this query came from an instrumented sql instance (already handled by wrapper) + if ((this as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { + return originalHandle.apply(this, args); + } + + // Skip if we shouldn't create spans + if (!self._shouldCreateSpans()) { + return originalHandle.apply(this, args); + } + + const fullQuery = self._reconstructQuery(this.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, + }); + + // Note: No connection context available for pre-existing instances + // because the sql instance wasn't created through our instrumented wrapper + + const config = self.getConfig(); + const { requestHook } = config; + if (requestHook) { + safeExecuteInTheMiddle( + () => requestHook(span, sanitizedSqlQuery, undefined), + e => { + if (e) { + span.setAttribute('sentry.hook.error', 'requestHook failed'); + DEBUG_BUILD && debug.error(`Error in requestHook for ${INTEGRATION_NAME} integration:`, e); + } + }, + true, + ); + } + + // Wrap resolve to end span on success + const originalResolve = this.resolve; + this.resolve = new Proxy(originalResolve 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); + }, + }); + + // Wrap reject to end span on error + const originalReject = this.reject; + this.reject = new Proxy(originalReject 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); + }, + }); + + 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; + } + }, + ); + }; + + // Store original for unpatch - must be set on the NEW patched function + moduleExports.Query.prototype.handle.__sentry_original__ = originalHandle; + + return moduleExports; + } + + /** + * Restores the original Query.prototype.handle method. + */ + private _unpatchQueryPrototype(moduleExports: { + Query: { + prototype: { + handle: ((...args: unknown[]) => Promise) & { + __sentry_original__?: (...args: unknown[]) => Promise; + }; + }; + }; + }): typeof moduleExports { + if (moduleExports.Query.prototype.handle.__sentry_original__) { + moduleExports.Query.prototype.handle = moduleExports.Query.prototype.handle.__sentry_original__; + } + return moduleExports; + } } -const _postgresJsIntegration = (() => { +const _postgresJsIntegration = ((options?: PostgresJsInstrumentationConfig) => { return { name: INTEGRATION_NAME, setupOnce() { - instrumentPostgresJs(); + instrumentPostgresJs(options); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/test/integrations/tracing/postgresjs.test.ts b/packages/node/test/integrations/tracing/postgresjs.test.ts new file mode 100644 index 000000000000..a20b1941bb28 --- /dev/null +++ b/packages/node/test/integrations/tracing/postgresjs.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest'; +import { PostgresJsInstrumentation } from '../../../src/integrations/tracing/postgresjs'; + +describe('PostgresJs', () => { + const instrumentation = new PostgresJsInstrumentation({ requireParentSpan: true }); + + describe('_reconstructQuery', () => { + const reconstruct = (strings: string[] | undefined) => + ( + instrumentation as unknown as { _reconstructQuery: (s: string[] | undefined) => string | undefined } + )._reconstructQuery(strings); + + describe('empty input handling', () => { + it.each([ + [undefined, undefined], + [null as unknown as undefined, undefined], + [[], undefined], + [[''], undefined], + ])('returns undefined for %p', (input, expected) => { + expect(reconstruct(input)).toBe(expected); + }); + + it('returns whitespace-only string as-is', () => { + expect(reconstruct([' '])).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(reconstruct([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(reconstruct(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it('handles 10+ parameters', () => { + const strings = ['INSERT INTO t VALUES (', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ')']; + expect(reconstruct(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(reconstruct(input)).toBe(expected); + }); + }); + + describe('integration with _sanitizeSqlQuery', () => { + const sanitize = (query: string | undefined) => + (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery( + query, + ); + + it('preserves $n placeholders per OTEL spec', () => { + const strings = ['SELECT * FROM users WHERE id = ', ' AND name = ', '']; + expect(sanitize(reconstruct(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(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1 AND status IN ($?)'); + }); + + it('returns Unknown SQL Query for undefined input', () => { + expect(sanitize(reconstruct(undefined))).toBe('Unknown SQL Query'); + }); + + it('normalizes whitespace and removes trailing semicolon', () => { + const strings = ['SELECT *\n FROM users\n WHERE id = ', ';']; + expect(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1'); + }); + }); + }); + + describe('_sanitizeSqlQuery', () => { + const sanitize = (query: string | undefined) => + (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery(query); + + 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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize(input)).toBe(expected); + }); + + it('preserves numbers in identifiers', () => { + expect(sanitize('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); + expect(sanitize('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(sanitize(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(sanitize(input)).toBe(expected); + }); + + it('does not affect identifiers containing TRUE/FALSE', () => { + expect(sanitize('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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize(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(sanitize("INSERT INTO users (name, age, active) VALUES ('John', 30, TRUE)")).toBe( + 'INSERT INTO users (name, age, active) VALUES (?, ?, ?)', + ); + expect(sanitize("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(sanitize(input)).toBe(expected); + }); + }); + + describe('regression tests', () => { + it('does not replace $n with ? (OTEL compliance)', () => { + const result = sanitize('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 = sanitize('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 = sanitize('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( + sanitize( + '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(sanitize('SELECT * from generate_series(1,1000) as x')).toBe('SELECT * from generate_series(?,?) as x'); + }); + }); + }); +});