From 0bfe50128b1da7298394f0f903e3494a8da76b76 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 26 Nov 2025 14:20:47 +0000 Subject: [PATCH 01/11] feat(node): Add ESM support for postgres.js instrumentation --- .../postgresjs/instrument-requestHook.cjs | 25 + .../postgresjs/instrument-requestHook.mjs | 25 + .../suites/tracing/postgresjs/instrument.cjs | 9 + .../suites/tracing/postgresjs/instrument.mjs | 9 + .../postgresjs/scenario-requestHook.js | 49 ++ .../postgresjs/scenario-requestHook.mjs | 40 ++ .../tracing/postgresjs/scenario-unsafe.cjs | 46 ++ .../tracing/postgresjs/scenario-unsafe.mjs | 37 ++ .../tracing/postgresjs/scenario-url.cjs | 63 ++ .../tracing/postgresjs/scenario-url.mjs | 75 +++ .../suites/tracing/postgresjs/scenario.cjs | 62 ++ .../suites/tracing/postgresjs/scenario.js | 21 + .../suites/tracing/postgresjs/scenario.mjs | 74 +++ .../suites/tracing/postgresjs/test.ts | 438 +++++++++++++- .../src/integrations/tracing/postgresjs.ts | 564 ++++++++++++------ 15 files changed, 1346 insertions(+), 191 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.cjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument-requestHook.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.cjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.cjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.cjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.cjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs 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..4de527b0074d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js @@ -0,0 +1,49 @@ +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, +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const postgres = require('postgres'); + +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..9b5b21d747e9 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,33 @@ -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'; +// Helper function to create basic span matcher (reduces duplication in new tests) +function createDbSpanMatcher(operationName: string, descriptionMatcher: unknown = expect.any(String)) { + return expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': operationName, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: descriptionMatcher, + op: 'db', + origin: 'auto.db.otel.postgres', + }); +} + 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([ @@ -32,20 +54,192 @@ describe('postgresjs auto instrumentation', () => { 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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.op': 'db', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + 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" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + 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" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + 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.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * from generate_series(?,?) as x', + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + 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.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + 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.response.status_code': '42P01', + 'error.type': 'PostgresError', + 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'internal_error', + origin: 'auto.db.otel.postgres', + 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', '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', @@ -164,7 +358,7 @@ 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}'`, @@ -203,7 +397,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 +409,239 @@ 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.otel.postgres', + '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.otel.postgres', + }), + 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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + }), + 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}'`, + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + }), + ]), + 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.otel.postgres', + '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.otel.postgres', + }), + 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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + }), + 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}'`, + 'custom.requestHook': 'called', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + }), + ]), + 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([ + createDbSpanMatcher('CREATE TABLE'), + createDbSpanMatcher('INSERT'), + createDbSpanMatcher('UPDATE'), + createDbSpanMatcher('SELECT'), + ]), + }; + + 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([ + createDbSpanMatcher('CREATE TABLE'), + createDbSpanMatcher('INSERT'), + createDbSpanMatcher('SELECT'), + createDbSpanMatcher('DELETE'), + ]), + }; + + 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([ + createDbSpanMatcher('CREATE TABLE'), + createDbSpanMatcher('INSERT'), + createDbSpanMatcher('SELECT'), + createDbSpanMatcher('DROP TABLE'), + ]), + }; + + 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([ + createDbSpanMatcher('CREATE TABLE'), + createDbSpanMatcher('INSERT'), + createDbSpanMatcher('SELECT'), + createDbSpanMatcher('DROP TABLE'), + ]), + }; + + 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..3a664c0150e7 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -1,10 +1,10 @@ +/* eslint-disable max-lines */ // Instrumentation for https://github.com/porsager/postgres import { context, trace } from '@opentelemetry/api'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, - InstrumentationNodeModuleFile, safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import { @@ -21,15 +21,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 +39,9 @@ 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'); + type PostgresJsInstrumentationConfig = InstrumentationConfig & { /** * Whether to require a parent span for the instrumentation. @@ -63,7 +68,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,205 +78,395 @@ export class PostgresJsInstrumentation extends InstrumentationBase { - instrumentationModule.files.push( - new InstrumentationNodeModuleFile( - `postgres/${path}/connection.js`, - ['*'], - this._patchConnection.bind(this), - this._unwrap.bind(this), - ), - ); + public init(): InstrumentationNodeModuleDefinition { + const module = new InstrumentationNodeModuleDefinition( + 'postgres', + SUPPORTED_VERSIONS, + exports => { + try { + return this._patchPostgres(exports); + } catch (e) { + DEBUG_BUILD && debug.error('Failed to patch postgres module:', e); + return exports; + } + }, + exports => exports, + ); + return module; + } - instrumentationModule.files.push( - new InstrumentationNodeModuleFile( - `postgres/${path}/query.js`, - SUPPORTED_VERSIONS, - this._patchQuery.bind(this), - this._unwrap.bind(this), - ), - ); - }); + /** + * Patches the postgres module by wrapping the main export function. + * This intercepts the creation of sql instances and instruments them. + */ + 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; - return [instrumentationModule]; + 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; + } } /** - * 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. + * Wraps query-returning methods (unsafe, file) to ensure their queries are instrumented. */ - private _shouldCreateSpans(): boolean { - const config = this.getConfig(); - const hasParentSpan = trace.getSpan(context.active()) !== undefined; - return hasParentSpan || !config.requireParentSpan; + 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 reject method of the Query class to set the span status and end it + * 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 _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(); + private _wrapCallbackMethod( + original: (...args: unknown[]) => unknown, + target: unknown, + parentSqlInstance: unknown, + ): (...args: unknown[]) => unknown { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + return function (this: unknown, ...args: unknown[]): unknown { + // Extract parent context to propagate to child instances + const parentContext = (parentSqlInstance as Record)[CONNECTION_CONTEXT_SYMBOL] as + | PostgresConnectionContext + | undefined; + + // Check if this is a callback-based call by verifying the last argument is a function + const isCallbackBased = typeof args[args.length - 1] === 'function'; + + if (!isCallbackBased) { + // Not a callback-based call - could be tagged template or promise-based + const result = Reflect.apply(original, target, args); + // If result is a Promise (e.g., reserve() without callback), instrument the resolved Sql instance + if (result && typeof (result as Promise).then === 'function') { + return (result as Promise).then((sqlInstance: unknown) => { + return self._instrumentSqlInstance(sqlInstance, parentContext); + }); + } return result; - }, - }); + } + + // Callback-based call: wrap the callback to instrument the Sql instance + const callback = (args.length === 1 ? args[0] : args[1]) as (sql: unknown) => unknown; + const wrappedCallback = function (sqlInstance: unknown): unknown { + const instrumentedSql = self._instrumentSqlInstance(sqlInstance, parentContext); + return callback(instrumentedSql); + }; + + const newArgs = args.length === 1 ? [wrappedCallback] : [args[0], wrappedCallback]; + return Reflect.apply(original, target, newArgs); + }; } /** - * Patches the resolve method of the Query class to end the span when the query is resolved. + * Sets connection context attributes on a span. */ - 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); - } - span.end(); - return result; - }, - }); + 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); + } + } } /** - * Patches the Query class to instrument the handle method. + * Extracts DB operation name from SQL query and sets it on the span. */ - private _patchQuery(moduleExports: { - Query: { - prototype: { - handle: any; - }; + 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; + } + + 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 sanitizedSqlQuery = self._sanitizeSqlQuery(query.strings?.[0]); + + return startSpanManual( + { + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + }, + (span: Span) => { + addOriginToSpan(span, 'auto.db.otel.postgres'); + + 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; } /** @@ -283,27 +480,28 @@ export class PostgresJsInstrumentation extends InstrumentationBase { +const _postgresJsIntegration = ((options?: PostgresJsInstrumentationConfig) => { return { name: INTEGRATION_NAME, setupOnce() { - instrumentPostgresJs(); + instrumentPostgresJs(options); }, }; }) satisfies IntegrationFn; From 4369d4befbc1f5bbea08df036c81386c506cdadd Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 28 Nov 2025 13:22:21 +0000 Subject: [PATCH 02/11] Add Query.prototype fallback patching for postgres.js pre-existing instances --- .../src/integrations/tracing/postgresjs.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index 3a664c0150e7..1ea25b5df830 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -5,6 +5,7 @@ import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import { @@ -41,6 +42,9 @@ type PostgresConnectionContext = { 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 & { /** @@ -79,6 +83,9 @@ export class PostgresJsInstrumentation extends InstrumentationBase 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 => { + module.files.push( + new InstrumentationNodeModuleFile( + `postgres/${path}/query.js`, + SUPPORTED_VERSIONS, + this._patchQueryPrototype.bind(this), + this._unpatchQueryPrototype.bind(this), + ), + ); + }); + return module; } @@ -352,6 +373,10 @@ export class PostgresJsInstrumentation extends InstrumentationBase)[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; @@ -495,6 +520,145 @@ 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; + + // Store original for unpatch + moduleExports.Query.prototype.handle.__sentry_original__ = originalHandle; + + 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 sanitizedSqlQuery = self._sanitizeSqlQuery(this.strings?.[0]); + + return startSpanManual( + { + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + }, + (span: Span) => { + addOriginToSpan(span, 'auto.db.otel.postgres'); + + 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; + } + }, + ); + }; + + 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 = ((options?: PostgresJsInstrumentationConfig) => { From 12076c8cf65c2fe4d112baf874938cde47606809 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 28 Nov 2025 13:40:51 +0000 Subject: [PATCH 03/11] Fix formatting --- packages/node/src/integrations/tracing/postgresjs.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index 1ea25b5df830..e929fa7a8991 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -531,7 +531,9 @@ export class PostgresJsInstrumentation extends InstrumentationBase Promise) & { __sentry_original__?: (...args: unknown[]) => Promise }; + handle: ((...args: unknown[]) => Promise) & { + __sentry_original__?: (...args: unknown[]) => Promise; + }; }; }; }): typeof moduleExports { @@ -650,7 +652,9 @@ export class PostgresJsInstrumentation extends InstrumentationBase Promise) & { __sentry_original__?: (...args: unknown[]) => Promise }; + handle: ((...args: unknown[]) => Promise) & { + __sentry_original__?: (...args: unknown[]) => Promise; + }; }; }; }): typeof moduleExports { From 656602ff44a9a3898b83282527796536e0651ec1 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 28 Nov 2025 14:13:14 +0000 Subject: [PATCH 04/11] Make original header restorable by unpatch --- packages/node/src/integrations/tracing/postgresjs.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index e929fa7a8991..e543d4d43aaf 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -541,9 +541,6 @@ export class PostgresJsInstrumentation extends InstrumentationBase Date: Tue, 2 Dec 2025 01:18:49 +0000 Subject: [PATCH 05/11] Address review comments --- .../suites/tracing/postgresjs/test.ts | 84 +++++----- .../src/integrations/tracing/postgresjs.ts | 5 +- .../integrations/tracing/postgresjs.test.ts | 153 ++++++++++++++++++ 3 files changed, 197 insertions(+), 45 deletions(-) create mode 100644 packages/node/test/integrations/tracing/postgresjs.test.ts 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 9b5b21d747e9..fe3b98cbbcbc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -12,13 +12,13 @@ function createDbSpanMatcher(operationName: string, descriptionMatcher: unknown 'db.system.name': 'postgres', 'db.operation.name': operationName, 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), description: descriptionMatcher, op: 'db', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', }); } @@ -39,7 +39,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, }), @@ -47,7 +47,7 @@ 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), @@ -60,7 +60,7 @@ describe('postgresjs auto instrumentation', () => { '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', + 'sentry.origin': 'auto.db.postgresjs', 'sentry.op': 'db', 'server.address': 'localhost', 'server.port': 5444, @@ -68,7 +68,7 @@ describe('postgresjs auto instrumentation', () => { description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_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), @@ -82,14 +82,14 @@ describe('postgresjs auto instrumentation', () => { 'db.operation.name': 'UPDATE', 'db.query.text': `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_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}'`, 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), @@ -103,14 +103,14 @@ describe('postgresjs auto instrumentation', () => { 'db.operation.name': 'SELECT', 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_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" = '${EXISTING_TEST_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), @@ -124,14 +124,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), @@ -145,14 +145,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), @@ -168,14 +168,14 @@ describe('postgresjs auto instrumentation', () => { 'error.type': 'PostgresError', 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_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}'`, 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), @@ -234,7 +234,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, }), @@ -242,7 +242,7 @@ 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), @@ -255,7 +255,7 @@ describe('postgresjs auto instrumentation', () => { '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', + 'sentry.origin': 'auto.db.postgresjs', 'sentry.op': 'db', 'server.address': 'localhost', 'server.port': 5444, @@ -263,7 +263,7 @@ describe('postgresjs auto instrumentation', () => { description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_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), @@ -277,14 +277,14 @@ describe('postgresjs auto instrumentation', () => { 'db.operation.name': 'UPDATE', 'db.query.text': `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_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}'`, 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), @@ -298,14 +298,14 @@ describe('postgresjs auto instrumentation', () => { 'db.operation.name': 'SELECT', 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_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" = '${EXISTING_TEST_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), @@ -319,14 +319,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), @@ -340,14 +340,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), @@ -363,14 +363,14 @@ describe('postgresjs auto instrumentation', () => { 'error.type': 'PostgresError', 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_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}'`, 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), @@ -431,7 +431,7 @@ 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"))', 'custom.requestHook': 'called', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), @@ -439,7 +439,7 @@ 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', }), expect.objectContaining({ data: expect.objectContaining({ @@ -449,14 +449,14 @@ describe('postgresjs auto instrumentation', () => { 'db.query.text': `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, 'custom.requestHook': 'called', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', }), expect.objectContaining({ data: expect.objectContaining({ @@ -466,14 +466,14 @@ describe('postgresjs auto instrumentation', () => { 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, 'custom.requestHook': 'called', '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}'`, op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', }), ]), extra: expect.objectContaining({ @@ -507,7 +507,7 @@ 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"))', 'custom.requestHook': 'called', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), @@ -515,7 +515,7 @@ 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', }), expect.objectContaining({ data: expect.objectContaining({ @@ -525,14 +525,14 @@ describe('postgresjs auto instrumentation', () => { 'db.query.text': `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, 'custom.requestHook': 'called', 'sentry.op': 'db', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', }), expect.objectContaining({ data: expect.objectContaining({ @@ -542,14 +542,14 @@ describe('postgresjs auto instrumentation', () => { 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, 'custom.requestHook': 'called', '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}'`, op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: 'auto.db.postgresjs', }), ]), extra: expect.objectContaining({ diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index e543d4d43aaf..77b0d3ae704b 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -396,7 +396,7 @@ export class PostgresJsInstrumentation extends InstrumentationBase { - addOriginToSpan(span, 'auto.db.otel.postgres'); + addOriginToSpan(span, 'auto.db.postgresjs'); span.setAttributes({ [ATTR_DB_SYSTEM_NAME]: 'postgres', @@ -517,7 +517,6 @@ export class PostgresJsInstrumentation extends InstrumentationBase { - addOriginToSpan(span, 'auto.db.otel.postgres'); + addOriginToSpan(span, 'auto.db.postgresjs'); span.setAttributes({ [ATTR_DB_SYSTEM_NAME]: 'postgres', 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..500fb37715ab --- /dev/null +++ b/packages/node/test/integrations/tracing/postgresjs.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import { PostgresJsInstrumentation } from '../../../src/integrations/tracing/postgresjs'; + +describe('PostgresJs', () => { + describe('_sanitizeSqlQuery', () => { + const instrumentation = new PostgresJsInstrumentation({ requireParentSpan: true }); + const sanitize = (query: string | undefined) => + (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery(query); + + describe('basic query passthrough', () => { + it('returns simple SELECT query unchanged', () => { + expect(sanitize('SELECT * FROM users')).toBe('SELECT * FROM users'); + }); + + it('returns simple INSERT query unchanged', () => { + expect(sanitize('INSERT INTO users VALUES (a, b)')).toBe('INSERT INTO users VALUES (a, b)'); + }); + }); + + describe('comment removal', () => { + it('removes single-line comments', () => { + expect(sanitize('SELECT * FROM users -- this is a comment')).toBe('SELECT * FROM users'); + }); + + it('removes multi-line comments', () => { + expect(sanitize('SELECT /* comment */ * FROM users')).toBe('SELECT * FROM users'); + }); + + it('removes multi-line comments spanning multiple lines', () => { + expect(sanitize('SELECT /* this\nis\na\ncomment */ * FROM users')).toBe('SELECT * FROM users'); + }); + }); + + describe('whitespace normalization', () => { + it('collapses multiple spaces to single space', () => { + expect(sanitize('SELECT * FROM users')).toBe('SELECT * FROM users'); + }); + + it('normalizes newlines and tabs', () => { + expect(sanitize('SELECT *\n\tFROM\n\tusers')).toBe('SELECT * FROM users'); + }); + + it('trims leading and trailing whitespace', () => { + expect(sanitize(' SELECT * FROM users ')).toBe('SELECT * FROM users'); + }); + }); + + describe('trailing semicolon removal', () => { + it('removes trailing semicolon', () => { + expect(sanitize('SELECT * FROM users;')).toBe('SELECT * FROM users'); + }); + + it('removes trailing semicolon with whitespace', () => { + expect(sanitize('SELECT * FROM users; ')).toBe('SELECT * FROM users'); + }); + }); + + describe('PostgreSQL placeholder replacement', () => { + it('replaces $1 with ?', () => { + expect(sanitize('SELECT * FROM users WHERE id = $1')).toBe('SELECT * FROM users WHERE id = ?'); + }); + + it('replaces multiple placeholders', () => { + expect(sanitize('SELECT * FROM users WHERE id = $1 AND name = $2')).toBe( + 'SELECT * FROM users WHERE id = ? AND name = ?', + ); + }); + + it('replaces higher numbered placeholders', () => { + expect(sanitize('INSERT INTO t VALUES ($1, $10, $100)')).toBe('INSERT INTO t VALUES (?, ?, ?)'); + }); + }); + + describe('standalone number replacement', () => { + it('replaces standalone numbers', () => { + expect(sanitize('SELECT * FROM users WHERE id = 123')).toBe('SELECT * FROM users WHERE id = ?'); + }); + + it('preserves numbers in identifiers', () => { + expect(sanitize('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); + }); + + it('replaces decimal numbers as separate parts', () => { + expect(sanitize('SELECT * FROM products WHERE price = 19.99')).toBe('SELECT * FROM products WHERE price = ?.?'); + }); + + it('replaces negative numbers preserving the minus sign', () => { + expect(sanitize('SELECT * FROM accounts WHERE balance = -500')).toBe( + 'SELECT * FROM accounts WHERE balance = -?', + ); + }); + }); + + describe('IN clause collapsing', () => { + it('collapses IN clause with multiple placeholders', () => { + expect(sanitize('SELECT * FROM users WHERE id IN ($1, $2, $3)')).toBe('SELECT * FROM users WHERE id IN (?)'); + }); + + it('collapses IN clause case-insensitively', () => { + expect(sanitize('SELECT * FROM users WHERE id in ($1, $2)')).toBe('SELECT * FROM users WHERE id IN (?)'); + }); + + it('handles IN clause with varied spacing', () => { + expect(sanitize('SELECT * FROM users WHERE id IN ( $1 , $2 , $3 )')).toBe( + 'SELECT * FROM users WHERE id IN (?)', + ); + }); + + it('collapses multiple IN clauses in the same query', () => { + expect(sanitize('SELECT * FROM users WHERE id IN ($1, $2) AND status IN ($3, $4, $5)')).toBe( + 'SELECT * FROM users WHERE id IN (?) AND status IN (?)', + ); + }); + + it('collapses NOT IN clause', () => { + expect(sanitize('SELECT * FROM users WHERE id NOT IN ($1, $2, $3)')).toBe( + 'SELECT * FROM users WHERE id NOT IN (?)', + ); + }); + }); + + describe('empty/undefined input handling', () => { + it('returns Unknown SQL Query for undefined', () => { + expect(sanitize(undefined)).toBe('Unknown SQL Query'); + }); + + it('returns Unknown SQL Query for empty string', () => { + expect(sanitize('')).toBe('Unknown SQL Query'); + }); + }); + + describe('combined transformations', () => { + it('handles complex query with multiple transformations', () => { + 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 = ? AND status IN (?)'); + }); + + it('handles query with comments, whitespace, and placeholders', () => { + const input = ` + /* Multi-line + comment */ + INSERT INTO orders (user_id, amount) + VALUES ($1, $2); + `; + expect(sanitize(input)).toBe('INSERT INTO orders (user_id, amount) VALUES (?, ?)'); + }); + }); + }); +}); From 71b7942b094826514d65e9cb001682c6b3fe5dad Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 12 Dec 2025 14:04:23 +0000 Subject: [PATCH 06/11] Sanitize / parameterize queries better --- .../suites/tracing/postgresjs/test.ts | 46 ++++ .../src/integrations/tracing/postgresjs.ts | 24 +- .../integrations/tracing/postgresjs.test.ts | 211 +++++++++++++++++- 3 files changed, 278 insertions(+), 3 deletions(-) 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 fe3b98cbbcbc..c83ac12d6a2d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -117,6 +117,29 @@ describe('postgresjs auto instrumentation', () => { 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 then sanitized to ? + 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" = ? AND "name" = ?`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = ? AND "name" = ?`, + 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', @@ -312,6 +335,29 @@ describe('postgresjs auto instrumentation', () => { 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 then sanitized to ? + 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" = ? AND "name" = ?`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = ? AND "name" = ?`, + 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', diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index 77b0d3ae704b..8e6b551cf818 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -388,7 +388,8 @@ export class PostgresJsInstrumentation extends InstrumentationBase (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 @@ -558,7 +577,8 @@ export class PostgresJsInstrumentation extends InstrumentationBase { + 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('undefined/null/empty input handling', () => { + it('returns undefined for undefined input', () => { + expect(reconstruct(undefined)).toBeUndefined(); + }); + + it('returns undefined for null input', () => { + expect(reconstruct(null as unknown as undefined)).toBeUndefined(); + }); + + it('returns undefined for empty array', () => { + expect(reconstruct([])).toBeUndefined(); + }); + + it('returns undefined for array with single empty string', () => { + expect(reconstruct([''])).toBeUndefined(); + }); + + it('returns undefined for whitespace-only single element', () => { + // Whitespace-only strings are truthy, so they should be returned + expect(reconstruct([' '])).toBe(' '); + }); + }); + + describe('single-element array (non-parameterized queries)', () => { + it('returns the string as-is for a single-element array', () => { + expect(reconstruct(['SELECT * FROM users'])).toBe('SELECT * FROM users'); + }); + + it('handles sql.unsafe() style queries', () => { + expect(reconstruct(['SELECT * FROM users WHERE id = $1'])).toBe('SELECT * FROM users WHERE id = $1'); + }); + + it('handles complex single-element queries', () => { + expect(reconstruct(['INSERT INTO users (email, name) VALUES ($1, $2)'])).toBe( + 'INSERT INTO users (email, name) VALUES ($1, $2)', + ); + }); + }); + + describe('multi-element array (parameterized queries)', () => { + it('reconstructs query with single parameter', () => { + // sql`SELECT * FROM users WHERE id = ${123}` + // strings = ["SELECT * FROM users WHERE id = ", ""] + expect(reconstruct(['SELECT * FROM users WHERE id = ', ''])).toBe('SELECT * FROM users WHERE id = $1'); + }); + + it('reconstructs query with two parameters', () => { + // sql`SELECT * FROM users WHERE id = ${123} AND name = ${'foo'}` + // strings = ["SELECT * FROM users WHERE id = ", " AND name = ", ""] + expect(reconstruct(['SELECT * FROM users WHERE id = ', ' AND name = ', ''])).toBe( + 'SELECT * FROM users WHERE id = $1 AND name = $2', + ); + }); + + it('reconstructs query with three parameters', () => { + // sql`INSERT INTO users (id, name, email) VALUES (${1}, ${'John'}, ${'john@example.com'})` + expect(reconstruct(['INSERT INTO users (id, name, email) VALUES (', ', ', ', ', ')'])).toBe( + 'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)', + ); + }); + + it('reconstructs query with parameter at the beginning', () => { + // sql`${tableName} WHERE id = ${123}` + // strings = ["", " WHERE id = ", ""] + expect(reconstruct(['', ' WHERE id = ', ''])).toBe('$1 WHERE id = $2'); + }); + + it('reconstructs complex query with multiple parameters', () => { + // sql`SELECT * FROM ${table} WHERE id = ${id} AND status IN (${s1}, ${s2}) ORDER BY ${col}` + expect(reconstruct(['SELECT * FROM ', ' WHERE id = ', ' AND status IN (', ', ', ') ORDER BY ', ''])).toBe( + 'SELECT * FROM $1 WHERE id = $2 AND status IN ($3, $4) ORDER BY $5', + ); + }); + }); + + describe('edge cases', () => { + it('handles whitespace-only strings in array', () => { + expect(reconstruct(['SELECT * FROM users WHERE id = ', ' ', ''])).toBe( + 'SELECT * FROM users WHERE id = $1 $2', + ); + }); + + it('handles query ending without trailing empty string', () => { + // Some edge cases might not have trailing empty string + expect(reconstruct(['SELECT * FROM users WHERE id = ', ' LIMIT 10'])).toBe( + 'SELECT * FROM users WHERE id = $1 LIMIT 10', + ); + }); + + it('handles many parameters (10+)', () => { + // sql`INSERT INTO t VALUES (${a}, ${b}, ${c}, ${d}, ${e}, ${f}, ${g}, ${h}, ${i}, ${j})` + // 10 params need 11 string parts: prefix + 9 separators + suffix + 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('handles newlines in template strings', () => { + // sql`SELECT *\nFROM users\nWHERE id = ${123}` + expect(reconstruct(['SELECT *\nFROM users\nWHERE id = ', ''])).toBe('SELECT *\nFROM users\nWHERE id = $1'); + }); + + it('handles unicode characters', () => { + expect(reconstruct(['SELECT * FROM users WHERE name = ', ' AND emoji = ', ''])).toBe( + 'SELECT * FROM users WHERE name = $1 AND emoji = $2', + ); + }); + + it('handles quotes in template strings', () => { + // sql`SELECT * FROM "User" WHERE "email" = ${email}` + expect(reconstruct(['SELECT * FROM "User" WHERE "email" = ', ''])).toBe( + 'SELECT * FROM "User" WHERE "email" = $1', + ); + }); + + it('handles consecutive parameters', () => { + // sql`SELECT ${a}${b}${c}` + expect(reconstruct(['SELECT ', '', '', ''])).toBe('SELECT $1$2$3'); + }); + + it('handles parameter only query', () => { + // sql`${rawSql}` - just a single parameter + expect(reconstruct(['', ''])).toBe('$1'); + }); + }); + + describe('integration with _sanitizeSqlQuery', () => { + const sanitize = (query: string | undefined) => + (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery( + query, + ); + + it('reconstructed query gets properly sanitized', () => { + // Full flow: reconstruct then sanitize + const strings = ['SELECT * FROM users WHERE id = ', ' AND name = ', '']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('SELECT * FROM users WHERE id = ? AND name = ?'); + }); + + it('handles complex parameterized query end-to-end', () => { + const strings = ['SELECT * FROM users WHERE id = ', ' AND status IN (', ', ', ', ', ')']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('SELECT * FROM users WHERE id = ? AND status IN (?)'); + }); + + it('handles undefined strings array gracefully in full flow', () => { + const reconstructed = reconstruct(undefined); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('Unknown SQL Query'); + }); + + it('handles INSERT with parameterized values', () => { + // sql`INSERT INTO users (email, name) VALUES (${email}, ${name})` + const strings = ['INSERT INTO users (email, name) VALUES (', ', ', ')']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('INSERT INTO users (email, name) VALUES (?, ?)'); + }); + + it('handles UPDATE with parameterized values', () => { + // sql`UPDATE users SET name = ${name} WHERE id = ${id}` + const strings = ['UPDATE users SET name = ', ' WHERE id = ', '']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('UPDATE users SET name = ? WHERE id = ?'); + }); + + it('handles DELETE with parameterized values', () => { + // sql`DELETE FROM users WHERE id = ${id}` + const strings = ['DELETE FROM users WHERE id = ', '']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('DELETE FROM users WHERE id = ?'); + }); + + it('handles query with newlines that get normalized', () => { + // sql`SELECT *\n FROM users\n WHERE id = ${id}` + const strings = ['SELECT *\n FROM users\n WHERE id = ', '']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('SELECT * FROM users WHERE id = ?'); + }); + + it('handles query with trailing semicolon', () => { + // sql`SELECT * FROM users WHERE id = ${id};` + const strings = ['SELECT * FROM users WHERE id = ', ';']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('SELECT * FROM users WHERE id = ?'); + }); + + it('handles real-world postgres.js query pattern', () => { + // Actual pattern from postgres.js: sql`SELECT * FROM "User" WHERE "email" = ${email} AND "name" = ${name}` + const strings = ['SELECT * FROM "User" WHERE "email" = ', ' AND "name" = ', '']; + const reconstructed = reconstruct(strings); + const sanitized = sanitize(reconstructed); + expect(sanitized).toBe('SELECT * FROM "User" WHERE "email" = ? AND "name" = ?'); + }); + }); + }); + describe('_sanitizeSqlQuery', () => { - const instrumentation = new PostgresJsInstrumentation({ requireParentSpan: true }); const sanitize = (query: string | undefined) => (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery(query); From 1e344a6257d962e9fed98dc3a896ae9b0dcb0c38 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 12 Dec 2025 14:04:47 +0000 Subject: [PATCH 07/11] Explicitly test query properties --- .../suites/tracing/postgresjs/test.ts | 302 +++++++++++++++--- 1 file changed, 264 insertions(+), 38 deletions(-) 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 c83ac12d6a2d..847cac3e886e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -4,24 +4,6 @@ import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; const EXISTING_TEST_EMAIL = 'bar@baz.com'; const NON_EXISTING_TEST_EMAIL = 'foo@baz.com'; -// Helper function to create basic span matcher (reduces duplication in new tests) -function createDbSpanMatcher(operationName: string, descriptionMatcher: unknown = expect.any(String)) { - return expect.objectContaining({ - data: expect.objectContaining({ - 'db.namespace': 'test_db', - 'db.system.name': 'postgres', - 'db.operation.name': operationName, - 'sentry.op': 'db', - 'sentry.origin': 'auto.db.postgresjs', - 'server.address': 'localhost', - 'server.port': 5444, - }), - description: descriptionMatcher, - op: 'db', - origin: 'auto.db.postgresjs', - }); -} - describe('postgresjs auto instrumentation', () => { afterAll(() => { cleanupChildProcesses(); @@ -124,13 +106,13 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'SELECT', - 'db.query.text': `SELECT * FROM "User" WHERE "email" = ? AND "name" = ?`, + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ? AND "name" = ?', 'sentry.op': 'db', 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), - description: `SELECT * FROM "User" WHERE "email" = ? AND "name" = ?`, + description: 'SELECT * FROM "User" WHERE "email" = ? AND "name" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -342,13 +324,13 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'SELECT', - 'db.query.text': `SELECT * FROM "User" WHERE "email" = ? AND "name" = ?`, + 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ? AND "name" = ?', 'sentry.op': 'db', 'sentry.origin': 'auto.db.postgresjs', 'server.address': 'localhost', 'server.port': 5444, }), - description: `SELECT * FROM "User" WHERE "email" = ? AND "name" = ?`, + description: 'SELECT * FROM "User" WHERE "email" = ? AND "name" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -621,10 +603,72 @@ describe('postgresjs auto instrumentation', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ - createDbSpanMatcher('CREATE TABLE'), - createDbSpanMatcher('INSERT'), - createDbSpanMatcher('UPDATE'), - createDbSpanMatcher('SELECT'), + 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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_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': 'UPDATE', + 'db.query.text': `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_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" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), ]), }; @@ -639,10 +683,72 @@ describe('postgresjs auto instrumentation', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ - createDbSpanMatcher('CREATE TABLE'), - createDbSpanMatcher('INSERT'), - createDbSpanMatcher('SELECT'), - createDbSpanMatcher('DELETE'), + 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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_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" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_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" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `DELETE FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.postgresjs', + }), ]), }; @@ -658,10 +764,70 @@ describe('postgresjs auto instrumentation', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ - createDbSpanMatcher('CREATE TABLE'), - createDbSpanMatcher('INSERT'), - createDbSpanMatcher('SELECT'), - createDbSpanMatcher('DROP TABLE'), + 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', + }), + 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 (?)', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email") 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': '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', + }), ]), }; @@ -676,10 +842,70 @@ describe('postgresjs auto instrumentation', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ - createDbSpanMatcher('CREATE TABLE'), - createDbSpanMatcher('INSERT'), - createDbSpanMatcher('SELECT'), - createDbSpanMatcher('DROP TABLE'), + 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', + }), + 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 (?)', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.postgresjs', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'INSERT INTO "User" ("email") 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': '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', + }), ]), }; From 3b34194fcef7683c7fa85da1847d3d74708639b5 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 12 Dec 2025 17:57:36 +0000 Subject: [PATCH 08/11] Add complete sanitization --- .../postgresjs/scenario-requestHook.js | 11 +- .../suites/tracing/postgresjs/test.ts | 138 +++-- .../src/integrations/tracing/postgresjs.ts | 23 +- .../integrations/tracing/postgresjs.test.ts | 519 +++++++++--------- 4 files changed, 380 insertions(+), 311 deletions(-) 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 index 4de527b0074d..71da795216c0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js @@ -1,18 +1,9 @@ -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, -}); +const postgres = require('postgres'); // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); -const postgres = require('postgres'); - const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); async function 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 847cac3e886e..d4ef892fd86d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -41,13 +41,13 @@ 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}')`, + '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.postgresjs', @@ -62,13 +62,13 @@ 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" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + description: 'UPDATE "User" SET "name" = ? WHERE "email" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -83,13 +83,13 @@ describe('postgresjs auto instrumentation', () => { '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" = ?', 'sentry.op': 'db', '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" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -100,19 +100,20 @@ describe('postgresjs auto instrumentation', () => { trace_id: expect.any(String), }), // Parameterized query test - verifies that tagged template queries with interpolations - // are properly reconstructed with $1, $2 placeholders then sanitized to ? + // 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" = ? AND "name" = ?', + '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" = ? AND "name" = ?', + description: 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -171,13 +172,13 @@ describe('postgresjs auto instrumentation', () => { '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.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.postgresjs', @@ -259,13 +260,13 @@ 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}')`, + '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.postgresjs', @@ -280,13 +281,13 @@ 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" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + description: 'UPDATE "User" SET "name" = ? WHERE "email" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -301,13 +302,13 @@ describe('postgresjs auto instrumentation', () => { '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" = ?', 'sentry.op': 'db', '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" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -318,19 +319,20 @@ describe('postgresjs auto instrumentation', () => { trace_id: expect.any(String), }), // Parameterized query test - verifies that tagged template queries with interpolations - // are properly reconstructed with $1, $2 placeholders then sanitized to ? + // 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" = ? AND "name" = ?', + '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" = ? AND "name" = ?', + description: 'SELECT * FROM "User" WHERE "email" = $1 AND "name" = $2', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -389,13 +391,13 @@ describe('postgresjs auto instrumentation', () => { '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.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.postgresjs', @@ -474,14 +476,14 @@ 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}')`, + '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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -491,14 +493,31 @@ describe('postgresjs auto instrumentation', () => { '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" = ?', 'custom.requestHook': 'called', 'sentry.op': 'db', '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" = ?', + 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', @@ -550,14 +569,14 @@ 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}')`, + '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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -567,14 +586,31 @@ describe('postgresjs auto instrumentation', () => { '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" = ?', 'custom.requestHook': 'called', 'sentry.op': 'db', '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" = ?', + 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', @@ -626,13 +662,13 @@ 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}')`, + '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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -642,13 +678,13 @@ 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" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + description: 'UPDATE "User" SET "name" = ? WHERE "email" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -658,13 +694,13 @@ describe('postgresjs auto instrumentation', () => { '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" = ?', 'sentry.op': 'db', '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" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -706,13 +742,13 @@ 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}')`, + '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 ('Foo', '${EXISTING_TEST_EMAIL}')`, + description: 'INSERT INTO "User" ("email", "name") VALUES (?, ?)', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -722,13 +758,13 @@ describe('postgresjs auto instrumentation', () => { '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" = ?', 'sentry.op': 'db', '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" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -738,13 +774,13 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'DELETE', - 'db.query.text': `DELETE FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + '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" = '${EXISTING_TEST_EMAIL}'`, + description: 'DELETE FROM "User" WHERE "email" = ?', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -780,18 +816,19 @@ describe('postgresjs auto instrumentation', () => { 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 (?)', + '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 (?)', + description: 'INSERT INTO "User" ("email") VALUES ($1)', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -801,13 +838,13 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'SELECT', - 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + '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" = ?', + description: 'SELECT * FROM "User" WHERE "email" = $1', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -858,18 +895,19 @@ describe('postgresjs auto instrumentation', () => { 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 (?)', + '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 (?)', + description: 'INSERT INTO "User" ("email") VALUES ($1)', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', @@ -879,13 +917,13 @@ describe('postgresjs auto instrumentation', () => { 'db.namespace': 'test_db', 'db.system.name': 'postgres', 'db.operation.name': 'SELECT', - 'db.query.text': 'SELECT * FROM "User" WHERE "email" = ?', + '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" = ?', + description: 'SELECT * FROM "User" WHERE "email" = $1', op: 'db', status: 'ok', origin: 'auto.db.postgresjs', diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts index 8e6b551cf818..55ee90444b47 100644 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -516,6 +516,9 @@ export class PostgresJsInstrumentation extends InstrumentationBase { instrumentation as unknown as { _reconstructQuery: (s: string[] | undefined) => string | undefined } )._reconstructQuery(strings); - describe('undefined/null/empty input handling', () => { - it('returns undefined for undefined input', () => { - expect(reconstruct(undefined)).toBeUndefined(); + 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 undefined for null input', () => { - expect(reconstruct(null as unknown as undefined)).toBeUndefined(); - }); - - it('returns undefined for empty array', () => { - expect(reconstruct([])).toBeUndefined(); - }); - - it('returns undefined for array with single empty string', () => { - expect(reconstruct([''])).toBeUndefined(); - }); - - it('returns undefined for whitespace-only single element', () => { - // Whitespace-only strings are truthy, so they should be returned + it('returns whitespace-only string as-is', () => { expect(reconstruct([' '])).toBe(' '); }); }); - describe('single-element array (non-parameterized queries)', () => { - it('returns the string as-is for a single-element array', () => { - expect(reconstruct(['SELECT * FROM users'])).toBe('SELECT * FROM users'); - }); - - it('handles sql.unsafe() style queries', () => { - expect(reconstruct(['SELECT * FROM users WHERE id = $1'])).toBe('SELECT * FROM users WHERE id = $1'); - }); - - it('handles complex single-element queries', () => { - expect(reconstruct(['INSERT INTO users (email, name) VALUES ($1, $2)'])).toBe( - 'INSERT INTO users (email, name) VALUES ($1, $2)', - ); + 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 queries)', () => { - it('reconstructs query with single parameter', () => { - // sql`SELECT * FROM users WHERE id = ${123}` - // strings = ["SELECT * FROM users WHERE id = ", ""] - expect(reconstruct(['SELECT * FROM users WHERE id = ', ''])).toBe('SELECT * FROM users WHERE id = $1'); - }); - - it('reconstructs query with two parameters', () => { - // sql`SELECT * FROM users WHERE id = ${123} AND name = ${'foo'}` - // strings = ["SELECT * FROM users WHERE id = ", " AND name = ", ""] - expect(reconstruct(['SELECT * FROM users WHERE id = ', ' AND name = ', ''])).toBe( - 'SELECT * FROM users WHERE id = $1 AND name = $2', - ); - }); - - it('reconstructs query with three parameters', () => { - // sql`INSERT INTO users (id, name, email) VALUES (${1}, ${'John'}, ${'john@example.com'})` - expect(reconstruct(['INSERT INTO users (id, name, email) VALUES (', ', ', ', ', ')'])).toBe( - 'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)', - ); - }); - - it('reconstructs query with parameter at the beginning', () => { - // sql`${tableName} WHERE id = ${123}` - // strings = ["", " WHERE id = ", ""] - expect(reconstruct(['', ' WHERE id = ', ''])).toBe('$1 WHERE id = $2'); - }); - - it('reconstructs complex query with multiple parameters', () => { - // sql`SELECT * FROM ${table} WHERE id = ${id} AND status IN (${s1}, ${s2}) ORDER BY ${col}` - expect(reconstruct(['SELECT * FROM ', ' WHERE id = ', ' AND status IN (', ', ', ') ORDER BY ', ''])).toBe( + 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 whitespace-only strings in array', () => { - expect(reconstruct(['SELECT * FROM users WHERE id = ', ' ', ''])).toBe( - 'SELECT * FROM users WHERE id = $1 $2', - ); - }); - - it('handles query ending without trailing empty string', () => { - // Some edge cases might not have trailing empty string - expect(reconstruct(['SELECT * FROM users WHERE id = ', ' LIMIT 10'])).toBe( - 'SELECT * FROM users WHERE id = $1 LIMIT 10', - ); - }); - - it('handles many parameters (10+)', () => { - // sql`INSERT INTO t VALUES (${a}, ${b}, ${c}, ${d}, ${e}, ${f}, ${g}, ${h}, ${i}, ${j})` - // 10 params need 11 string parts: prefix + 9 separators + suffix + 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('handles newlines in template strings', () => { - // sql`SELECT *\nFROM users\nWHERE id = ${123}` - expect(reconstruct(['SELECT *\nFROM users\nWHERE id = ', ''])).toBe('SELECT *\nFROM users\nWHERE id = $1'); - }); - - it('handles unicode characters', () => { - expect(reconstruct(['SELECT * FROM users WHERE name = ', ' AND emoji = ', ''])).toBe( - 'SELECT * FROM users WHERE name = $1 AND emoji = $2', - ); - }); - - it('handles quotes in template strings', () => { - // sql`SELECT * FROM "User" WHERE "email" = ${email}` - expect(reconstruct(['SELECT * FROM "User" WHERE "email" = ', ''])).toBe( - 'SELECT * FROM "User" WHERE "email" = $1', - ); - }); - - it('handles consecutive parameters', () => { - // sql`SELECT ${a}${b}${c}` - expect(reconstruct(['SELECT ', '', '', ''])).toBe('SELECT $1$2$3'); - }); - - it('handles parameter only query', () => { - // sql`${rawSql}` - just a single parameter - expect(reconstruct(['', ''])).toBe('$1'); + 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); }); }); @@ -141,73 +74,23 @@ describe('PostgresJs', () => { query, ); - it('reconstructed query gets properly sanitized', () => { - // Full flow: reconstruct then sanitize + it('preserves $n placeholders per OTEL spec', () => { const strings = ['SELECT * FROM users WHERE id = ', ' AND name = ', '']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('SELECT * FROM users WHERE id = ? AND name = ?'); + expect(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1 AND name = $2'); }); - it('handles complex parameterized query end-to-end', () => { + it('collapses IN clause with $n to IN ($?)', () => { const strings = ['SELECT * FROM users WHERE id = ', ' AND status IN (', ', ', ', ', ')']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('SELECT * FROM users WHERE id = ? AND status IN (?)'); - }); - - it('handles undefined strings array gracefully in full flow', () => { - const reconstructed = reconstruct(undefined); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('Unknown SQL Query'); - }); - - it('handles INSERT with parameterized values', () => { - // sql`INSERT INTO users (email, name) VALUES (${email}, ${name})` - const strings = ['INSERT INTO users (email, name) VALUES (', ', ', ')']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('INSERT INTO users (email, name) VALUES (?, ?)'); - }); - - it('handles UPDATE with parameterized values', () => { - // sql`UPDATE users SET name = ${name} WHERE id = ${id}` - const strings = ['UPDATE users SET name = ', ' WHERE id = ', '']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('UPDATE users SET name = ? WHERE id = ?'); - }); - - it('handles DELETE with parameterized values', () => { - // sql`DELETE FROM users WHERE id = ${id}` - const strings = ['DELETE FROM users WHERE id = ', '']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('DELETE FROM users WHERE id = ?'); - }); - - it('handles query with newlines that get normalized', () => { - // sql`SELECT *\n FROM users\n WHERE id = ${id}` - const strings = ['SELECT *\n FROM users\n WHERE id = ', '']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('SELECT * FROM users WHERE id = ?'); + expect(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1 AND status IN ($?)'); }); - it('handles query with trailing semicolon', () => { - // sql`SELECT * FROM users WHERE id = ${id};` - const strings = ['SELECT * FROM users WHERE id = ', ';']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('SELECT * FROM users WHERE id = ?'); + it('returns Unknown SQL Query for undefined input', () => { + expect(sanitize(reconstruct(undefined))).toBe('Unknown SQL Query'); }); - it('handles real-world postgres.js query pattern', () => { - // Actual pattern from postgres.js: sql`SELECT * FROM "User" WHERE "email" = ${email} AND "name" = ${name}` - const strings = ['SELECT * FROM "User" WHERE "email" = ', ' AND "name" = ', '']; - const reconstructed = reconstruct(strings); - const sanitized = sanitize(reconstructed); - expect(sanitized).toBe('SELECT * FROM "User" WHERE "email" = ? AND "name" = ?'); + 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'); }); }); }); @@ -216,146 +99,288 @@ describe('PostgresJs', () => { const sanitize = (query: string | undefined) => (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery(query); - describe('basic query passthrough', () => { - it('returns simple SELECT query unchanged', () => { - expect(sanitize('SELECT * FROM users')).toBe('SELECT * FROM users'); - }); - - it('returns simple INSERT query unchanged', () => { - expect(sanitize('INSERT INTO users VALUES (a, b)')).toBe('INSERT INTO users VALUES (a, b)'); + 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('removes single-line comments', () => { - expect(sanitize('SELECT * FROM users -- this is a comment')).toBe('SELECT * FROM users'); - }); - - it('removes multi-line comments', () => { - expect(sanitize('SELECT /* comment */ * FROM users')).toBe('SELECT * FROM users'); + 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); }); + }); - it('removes multi-line comments spanning multiple lines', () => { - expect(sanitize('SELECT /* this\nis\na\ncomment */ * FROM users')).toBe('SELECT * FROM users'); + 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('whitespace normalization', () => { - it('collapses multiple spaces to single space', () => { - expect(sanitize('SELECT * FROM users')).toBe('SELECT * FROM users'); + 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); }); + }); - it('normalizes newlines and tabs', () => { - expect(sanitize('SELECT *\n\tFROM\n\tusers')).toBe('SELECT * FROM users'); + 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); }); + }); - it('trims leading and trailing whitespace', () => { - expect(sanitize(' SELECT * FROM users ')).toBe('SELECT * FROM users'); + 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('trailing semicolon removal', () => { - it('removes trailing semicolon', () => { - expect(sanitize('SELECT * FROM users;')).toBe('SELECT * FROM users'); + 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('removes trailing semicolon with whitespace', () => { - expect(sanitize('SELECT * FROM users; ')).toBe('SELECT * FROM users'); + 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('PostgreSQL placeholder replacement', () => { - it('replaces $1 with ?', () => { - expect(sanitize('SELECT * FROM users WHERE id = $1')).toBe('SELECT * FROM users WHERE id = ?'); + 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); }); + }); - it('replaces multiple placeholders', () => { - expect(sanitize('SELECT * FROM users WHERE id = $1 AND name = $2')).toBe( - 'SELECT * FROM users WHERE id = ? AND name = ?', - ); + 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('replaces higher numbered placeholders', () => { - expect(sanitize('INSERT INTO t VALUES ($1, $10, $100)')).toBe('INSERT INTO t VALUES (?, ?, ?)'); + 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('standalone number replacement', () => { - it('replaces standalone numbers', () => { - expect(sanitize('SELECT * FROM users WHERE id = 123')).toBe('SELECT * FROM users WHERE id = ?'); + 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); }); + }); - it('preserves numbers in identifiers', () => { - expect(sanitize('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); + 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); }); + }); - it('replaces decimal numbers as separate parts', () => { - expect(sanitize('SELECT * FROM products WHERE price = 19.99')).toBe('SELECT * FROM products WHERE price = ?.?'); + 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); }); + }); - it('replaces negative numbers preserving the minus sign', () => { - expect(sanitize('SELECT * FROM accounts WHERE balance = -500')).toBe( - 'SELECT * FROM accounts WHERE balance = -?', - ); + 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('IN clause collapsing', () => { - it('collapses IN clause with multiple placeholders', () => { - expect(sanitize('SELECT * FROM users WHERE id IN ($1, $2, $3)')).toBe('SELECT * FROM users WHERE id IN (?)'); + 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('collapses IN clause case-insensitively', () => { - expect(sanitize('SELECT * FROM users WHERE id in ($1, $2)')).toBe('SELECT * FROM users WHERE id 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 IN clause with varied spacing', () => { - expect(sanitize('SELECT * FROM users WHERE id IN ( $1 , $2 , $3 )')).toBe( - 'SELECT * FROM users WHERE id IN (?)', + 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('collapses multiple IN clauses in the same query', () => { - expect(sanitize('SELECT * FROM users WHERE id IN ($1, $2) AND status IN ($3, $4, $5)')).toBe( - 'SELECT * FROM users WHERE id IN (?) AND status IN (?)', + 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 = ?', ); }); + }); - it('collapses NOT IN clause', () => { - expect(sanitize('SELECT * FROM users WHERE id NOT IN ($1, $2, $3)')).toBe( - 'SELECT * FROM users WHERE id NOT IN (?)', - ); + 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('empty/undefined input handling', () => { - it('returns Unknown SQL Query for undefined', () => { - expect(sanitize(undefined)).toBe('Unknown SQL Query'); + 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('returns Unknown SQL Query for empty string', () => { - expect(sanitize('')).toBe('Unknown SQL Query'); + 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 = ?'); }); - }); - describe('combined transformations', () => { - it('handles complex query with multiple transformations', () => { - 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 = ? AND status IN (?)'); + 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 query with comments, whitespace, and placeholders', () => { - const input = ` - /* Multi-line - comment */ - INSERT INTO orders (user_id, amount) - VALUES ($1, $2); - `; - expect(sanitize(input)).toBe('INSERT INTO orders (user_id, amount) VALUES (?, ?)'); + 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'); }); }); }); From 660f716c0035b31389db49947903f7bc6db1c4b5 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 12 Dec 2025 18:14:18 +0000 Subject: [PATCH 09/11] Lint --- .../integrations/tracing/postgresjs.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/node/test/integrations/tracing/postgresjs.test.ts b/packages/node/test/integrations/tracing/postgresjs.test.ts index caa3e8cf786f..a20b1941bb28 100644 --- a/packages/node/test/integrations/tracing/postgresjs.test.ts +++ b/packages/node/test/integrations/tracing/postgresjs.test.ts @@ -233,7 +233,10 @@ describe('PostgresJs', () => { ['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 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 ($?)'], @@ -265,7 +268,10 @@ describe('PostgresJs', () => { ["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 ?'], + [ + "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) => { @@ -342,12 +348,30 @@ describe('PostgresJs', () => { ["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'], + [ + "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); }); From 9cf452d63875bceddf3f0cf1a300c3d7428c4228 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 15 Dec 2025 17:38:41 +0000 Subject: [PATCH 10/11] Fix linter --- .../suites/tracing/postgresjs/scenario-requestHook.js | 1 - .../suites/tracing/postgresjs/scenario-requestHook.mjs | 1 - .../suites/tracing/postgresjs/scenario-unsafe.mjs | 1 - .../suites/tracing/postgresjs/scenario-url.mjs | 1 - .../suites/tracing/postgresjs/scenario.mjs | 1 - .../node-integration-tests/suites/tracing/postgresjs/test.ts | 3 --- 6 files changed, 8 deletions(-) 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 index 71da795216c0..a2b405d71f60 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.js @@ -36,5 +36,4 @@ async function run() { ); } -// 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 index 154f9374ef0f..f6e69354ccbc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-requestHook.mjs @@ -36,5 +36,4 @@ async function run() { ); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises 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 index a6db6d7b0fec..9d2e7de99e51 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-unsafe.mjs @@ -33,5 +33,4 @@ async function run() { ); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises 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 index 181e264b8de6..2694bca96569 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario-url.mjs @@ -71,5 +71,4 @@ async function run() { ); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs index 28a0c384b21f..7d62c8d52dde 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.mjs @@ -70,5 +70,4 @@ async function run() { ); } -// 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 d4ef892fd86d..2dfbc020966b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -1,9 +1,6 @@ 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', () => { afterAll(() => { cleanupChildProcesses(); From decde6f38bf4c859a7403c241767fb8ee90dd2b1 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 15 Dec 2025 17:47:38 +0000 Subject: [PATCH 11/11] Increase size limit by 1 KB --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 00b4bdbfd4d8..880f91cbeb54 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '161 KB', + limit: '162 KB', }, { name: '@sentry/node - without tracing',