From 8ef295ba698da6c2b7f0a09d393ab75631dc86d7 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 26 Jun 2026 21:05:52 -0700 Subject: [PATCH 1/4] feat: pg orchestrion instrumentation Add orchestrion instrumentation for Node, Deno, and Bun, covering the `pg` module. This basically copies exactly what the `mysql` integration does, but for postgres. fix: #20764 fix: JS-2415 --- .github/workflows/build.yml | 3 +- .../bun-integration-tests/package.json | 3 +- .../suites/orchestrion-postgres/build.ts | 43 +++ .../suites/orchestrion-postgres/scenario.ts | 61 ++++ .../suites/orchestrion-postgres/test.ts | 65 +++++ .../test-applications/deno-pg/deno.json | 7 + .../deno-pg/docker-compose.yml | 17 ++ .../deno-pg/global-setup.mjs | 14 + .../deno-pg/global-teardown.mjs | 12 + .../test-applications/deno-pg/package.json | 23 ++ .../deno-pg/playwright.config.mjs | 12 + .../test-applications/deno-pg/src/app.ts | 69 +++++ .../deno-pg/start-event-proxy.mjs | 6 + .../deno-pg/tests/pg.test.ts | 55 ++++ .../postgres/instrument-orchestrion.mjs | 16 ++ .../suites/tracing/postgres/test.ts | 190 +++++++++++++ packages/deno/package.json | 3 +- packages/deno/src/index.ts | 1 + packages/deno/src/integrations/postgres.ts | 36 +++ packages/deno/src/sdk.ts | 3 +- .../deno/test/__snapshots__/mod.test.ts.snap | 4 + .../deno/test/orchestrion-postgres.test.ts | 150 ++++++++++ .../test/orchestrion-postgres/scenario.mjs | 40 +++ ...erimentalUseDiagnosticsChannelInjection.ts | 19 +- .../integrations/tracing-channel/postgres.ts | 268 ++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 3 + .../server-utils/src/orchestrion/config.ts | 38 +++ .../server-utils/src/orchestrion/index.ts | 1 + .../postgres-ignore-connect.test.ts | 83 ++++++ .../test/orchestrion/postgres.test.ts | 231 +++++++++++++++ 30 files changed, 1470 insertions(+), 6 deletions(-) create mode 100644 dev-packages/bun-integration-tests/suites/orchestrion-postgres/build.ts create mode 100644 dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts create mode 100644 dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/package.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion.mjs create mode 100644 packages/deno/src/integrations/postgres.ts create mode 100644 packages/deno/test/orchestrion-postgres.test.ts create mode 100644 packages/deno/test/orchestrion-postgres/scenario.mjs create mode 100644 packages/server-utils/src/integrations/tracing-channel/postgres.ts create mode 100644 packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts create mode 100644 packages/server-utils/test/orchestrion/postgres.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ebb2f0f97b05..695fa5ee81f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1047,7 +1047,8 @@ jobs: - name: Set up Deno if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application == - 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' + 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || + matrix.test-application == 'deno-pg' uses: denoland/setup-deno@v2.0.4 with: deno-version: ${{ matrix.deno-version || 'v2.8.0' }} diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index e653fa37aa09..cc3a2129b071 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -16,7 +16,8 @@ "@sentry/bun": "10.63.0", "@sentry/hono": "10.63.0", "hono": "^4.12.25", - "mysql": "^2.18.1" + "mysql": "^2.18.1", + "pg": "8.16.0" }, "devDependencies": { "@sentry-internal/test-utils": "10.63.0", diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/build.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/build.ts new file mode 100644 index 000000000000..4091b03e6711 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/build.ts @@ -0,0 +1,43 @@ +// Builds the smoke scenario with the orchestrion `bun build` plugin and writes +// the bundle to a temp dir, printing the output path for test.ts to execute. +// +// A successful build proves `bun build` runs with the plugin; running the +// bundle (see test.ts) then proves the bundled `pg` is actually instrumented. + +// @ts-ignore -- subpath export resolved by Bun at runtime; the package +// tsconfig's node module resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +void (async () => { + const outdir = join(tmpdir(), `sentry-bun-orchestrion-pg-${process.pid}-${Date.now()}`); + const result = await Bun.build({ + entrypoints: [join(__dirname, 'scenario.ts')], + target: 'bun', + outdir, + // Deliberately mark `pg` external. An externalized dependency is resolved + // from `node_modules` at runtime and never passes through the transform's + // `onLoad`, so its channel injection would be silently skipped. The plugin + // must strip instrumented packages back out of `external` so they get + // bundled (and thus transformed). + external: ['pg'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } + + const output = result.outputs[0]; + if (!output) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED no outputs'); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log(`BUILD_OK outfile=${output.path}`); +})(); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts new file mode 100644 index 000000000000..72a779d9cf90 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/scenario.ts @@ -0,0 +1,61 @@ +// Bundled entry for the `bun build` smoke test. +// +// Once `Bun.build` (with the orchestrion plugin) has transformed `pg`, +// calling `client.query()` publishes to the `orchestrion:pg:query` tracing +// channel. +// +// `start` fires synchronously on the call, so no live database is needed. +// +// We subscribe, run a query, and report which channel events fired +// (plus the detection marker the plugin's banner sets at boot). + +import { tracingChannel } from 'node:diagnostics_channel'; + +// @ts-ignore -- only the runtime value is needed; pg's types are irrelevant +import pg from 'pg'; + +interface QueryContext { + arguments?: unknown[]; +} +interface Client { + query(sql: string, cb: () => void): void; +} +interface PgModule { + Client: new (opts: { host: string; user: string; database: string }) => Client; +} + +const events: string[] = []; +let statement = ''; + +tracingChannel('orchestrion:pg:query').subscribe({ + start(message: unknown) { + events.push('start'); + const first = (message as QueryContext).arguments?.[0]; + statement = typeof first === 'string' ? first : ''; + }, + end() { + events.push('end'); + }, + asyncStart() {}, + asyncEnd() { + events.push('asyncEnd'); + }, + error() {}, +}); + +const client = new (pg as PgModule).Client({ host: '127.0.0.1', user: 'root', database: 'mydb' }); +try { + client.query('SELECT 1 AS solution', () => {}); +} catch { + // No live server + // `start` has already published synchronously by this point. +} + +const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } }) + .__SENTRY_ORCHESTRION__; + +setTimeout(() => { + // eslint-disable-next-line no-console + console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(marker ?? null)}`); + process.exit(0); +}, 200); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts new file mode 100644 index 000000000000..58e97626f8bc --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/orchestrion-postgres/test.ts @@ -0,0 +1,65 @@ +import { spawnSync } from 'child_process'; +import { rmSync } from 'fs'; +import { dirname, join } from 'path'; +import { describe, expect, it } from 'vitest'; + +const dir = __dirname; + +// Cap each `bun` subprocess. The test runs two of them sequentially, so its +// own timeout must exceed `2 * SUBPROCESS_TIMEOUT_MS` otherwise the suite's +// default `testTimeout` (20s) fails the test before these caps do, +// for example on a slow CI runner where the build+run can take >20s. +const SUBPROCESS_TIMEOUT_MS = 60_000; + +function runBun(args: string[]): { stdout: string; stderr: string; status: number | null } { + const res = spawnSync('bun', args, { cwd: dir, encoding: 'utf8', timeout: SUBPROCESS_TIMEOUT_MS }); + return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', status: res.status }; +} + +// Bun orchestrion instrumentation is BUILD-ONLY (`@sentry/bun/plugin` is a +// `Bun.build` plugin; there is no `bun run` preload). +// +// A `bun run` runtime plugin cannot instrument CommonJS dependencies like +// `pg`: any module returned by a runtime `onLoad` plugin in Bun loses its +// CommonJS named exports +// +// When https://github.com/oven-sh/bun/pull/31770 lands, we can revisit an +// auto-load plugin for `bun run`. +describe('orchestrion pg instrumentation (Bun)', () => { + it( + 'bundles `pg` with the plugin, and the built output fires the pg channel when run', + () => { + // Build the scenario with the orchestrion `bun build` plugin. + const build = runBun(['run', join(dir, 'build.ts')]); + expect(build.status, `build failed:\nstderr:\n${build.stderr}\nstdout:\n${build.stdout}`).toBe(0); + + const outfile = build.stdout.match(/BUILD_OK outfile=(.+)/)?.[1]?.trim(); + expect(outfile, `no outfile in build output:\n${build.stdout}`).toBeTruthy(); + + try { + // Run the built bundle. The bundled (transformed) `pg` should publish + // to the `orchestrion:pg:query` channel when `client.query()` is + // called, and the plugin's banner should set the `bundler` marker at + // boot. + const run = runBun(['run', outfile as string]); + expect(run.status, `run failed:\nstderr:\n${run.stderr}\nstdout:\n${run.stdout}`).toBe(0); + + const line = run.stdout.split('\n').find(l => l.startsWith('SCENARIO')) ?? ''; + // channel `start` fired on `client.query()` + expect(line).toContain('events=start'); + // with the expected SQL + expect(line).toContain('statement=SELECT 1 AS solution'); + // injected banner ran at bundle boot + expect(line).toContain('"bundler":true'); + } finally { + if (outfile) { + rmSync(dirname(outfile), { recursive: true, force: true }); + } + } + // Allow for both sequential `runBun` calls hitting their subprocess + // cap, so the `spawnSync` timeouts (not the vitest 20s def) are the + // binding limit. + }, + 2 * SUBPROCESS_TIMEOUT_MS, + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/deno.json b/dev-packages/e2e-tests/test-applications/deno-pg/deno.json new file mode 100644 index 000000000000..2bc35855c689 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "pg": "npm:pg@8.16.0" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml new file mode 100644 index 000000000000..aeee1935341e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml @@ -0,0 +1,17 @@ +services: + db: + image: postgres:13 + restart: always + container_name: e2e-tests-deno-pg + ports: + - '5432:5432' + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: postgres + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs new file mode 100644 index 000000000000..2e9841a6fdbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start PostgreSQL via Docker Compose. `--wait` blocks until the healthcheck + // in docker-compose.yml passes, so the Deno app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/package.json b/dev-packages/e2e-tests/test-applications/deno-pg/package.json new file mode 100644 index 000000000000..fafb688ac688 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/package.json @@ -0,0 +1,23 @@ +{ + "name": "deno-pg", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", + "pg": "8.16.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs new file mode 100644 index 000000000000..d525dd371bc9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs @@ -0,0 +1,12 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts new file mode 100644 index 000000000000..2b9e7a432376 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts @@ -0,0 +1,69 @@ +// `@sentry/deno/import` MUST be the very first import: it registers the +// orchestrion runtime hook, which transforms `pg` (imported dynamically below) +// to publish the `orchestrion:pg:query` diagnostics channel. +// In Deno 2.8.0–2.8.2 the hook only works as the first import in the entry +// graph. +import '@sentry/deno/import'; +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + environment: 'qa', + dsn: Deno.env.get('E2E_TEST_DSN'), + debug: !!Deno.env.get('DEBUG'), + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1, +}); + +// Dynamic import AFTER init so the orchestrion hook (registered above) is in +// place to transform `pg/lib/client.js`'s `query`, and so +// `denoPostgresIntegration` (wired by `init()`) is already subscribed. +const { default: pg } = await import('pg'); + +const client = new pg.Client({ + host: Deno.env.get('PGHOST') ?? '127.0.0.1', + port: Number(Deno.env.get('PGPORT') ?? 5432), + user: 'postgres', + password: 'password', + database: 'postgres', +}); + +// Swallow connection errors (e.g. the DB container going away at teardown) so +// they don't become an uncaught exception that crashes the process on +// shutdown. +client.on('error', (err: unknown) => { + // eslint-disable-next-line no-console + console.error('pg client error', err); +}); + +client.connect((err: unknown) => { + if (err) { + // eslint-disable-next-line no-console + console.error('pg connect error', err); + } +}); + +const port = 3030; + +Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { + const url = new URL(req.url); + + // Runs two queries, the second NESTED inside the first's callback. pg + // dispatches that callback from its socket data handler (a fresh async + // context), so the nested query's span only lands on this request's + // http.server transaction if `denoPostgresIntegration`'s AsyncLocalStorage + // context strategy restored the parent across the async boundary. + if (url.pathname === '/test-pg') { + await new Promise((resolve, reject) => { + client.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + client.query('SELECT NOW()', (err2: unknown) => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return Response.json({ status: 'ok' }); + } + + return new Response('Not found', { status: 404 }); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs new file mode 100644 index 000000000000..7f5c950f439e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno-pg', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts b/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts new file mode 100644 index 000000000000..34bbb9240862 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('pg queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { + // Each incoming request gets a Sentry http.server transaction (via the + // default denoServeIntegration); the pg queries run inside it, so their + // db spans attach to that transaction. + const transactionPromise = waitForTransaction('deno-pg', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-pg') && + (event.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/test-pg`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const dbSpans = transaction.spans!.filter(span => span.op === 'db'); + + const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + expect(firstQuery).toBeDefined(); + expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.orchestrion.postgres'); + expect(firstQuery!.data?.['db.system']).toBe('postgresql'); + expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution'); + expect(firstQuery!.data?.['net.peer.port']).toBe(5432); + expect(firstQuery!.data?.['db.user']).toBe('postgres'); +}); + +test('a nested query lands on the same transaction (AsyncLocalStorage context restored)', async ({ baseURL }) => { + // The second query runs inside the first query's callback + // i.e. across pg's async socket-callback dispatch. Both spans appearing + // on the SAME http.server transaction proves denoPostgresIntegration's + // context strategy restored the parent span across that async boundary + // (otherwise the nested query would start its own trace and never join + // this transaction). + const transactionPromise = waitForTransaction('deno-pg', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-pg') && + (event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/test-pg`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description); + expect(descriptions).toContain('SELECT 1 + 1 AS solution'); + expect(descriptions).toContain('SELECT NOW()'); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion.mjs new file mode 100644 index 000000000000..d0ac1aec0b2c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion.mjs @@ -0,0 +1,16 @@ +// Opting in via `experimentalUseDiagnosticsChannelInjection()` before `init()` +// is all that's needed. Because this file is loaded +// (via `--import`/`--require`) before the scenario imports `pg`, +// `Sentry.init()` synchronously installs the channel-injection hooks, so the +// OTel `Postgres` instrumentation is swapped for the diagnostics-channel one. +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +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/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts index 1d0a1e86555d..8358a481186c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -358,4 +358,194 @@ describe('postgres auto instrumentation', () => { { additionalDependencies: { 'pg-native': '3.7.0', pg: '8.20.0' } }, ); }); + + // Orchestrion (diagnostics-channel) variant: the same scenarios opted into + // `experimentalUseDiagnosticsChannelInjection()`. Produces the same spans as + // the OTel path, except the query origin reports the mechanism + // (`auto.db.orchestrion.postgres`); connect/pool-connect spans stay 'manual' + // (mirroring OTel — those spans never set an origin). + describe('orchestrion (diagnostics-channel)', () => { + const ORIGIN = 'auto.db.orchestrion.postgres'; + + describe('default', () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'sentry.origin': 'manual', + 'sentry.op': 'db', + }), + description: 'pg.connect', + op: 'db', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.statement': 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + op: 'db', + status: 'ok', + origin: ORIGIN, + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.statement': 'SELECT * FROM "User" WHERE "email" = $1', + 'db.postgresql.plan': 'select-user-by-email', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'SELECT * FROM "User" WHERE "email" = $1', + op: 'db', + status: 'ok', + origin: ORIGIN, + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.statement': 'SELECT * FROM "does_not_exist_table"', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'SELECT * FROM "does_not_exist_table"', + op: 'db', + status: 'internal_error', + origin: ORIGIN, + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-orchestrion.mjs', (createTestRunner, test) => { + test('auto-instruments `pg` via diagnostics channels', { timeout: 90_000 }, async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }); + }); + + describe('pool', () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.connection_string': 'postgresql://localhost:5494/tests', + 'sentry.op': 'db', + }), + description: 'pg-pool.connect', + op: 'db', + status: 'ok', + origin: 'manual', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.statement': 'SELECT 1 AS foo', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'SELECT 1 AS foo', + op: 'db', + status: 'ok', + origin: ORIGIN, + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-pool.mjs', 'instrument-orchestrion.mjs', (createTestRunner, test) => { + test('auto-instruments `pg.Pool` and handles callback-style queries', { timeout: 90_000 }, async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }); + }); + + describe('connect error', () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ 'db.system': 'postgresql', 'db.name': 'tests', 'sentry.op': 'db' }), + description: 'pg.connect', + op: 'db', + status: 'internal_error', + origin: 'manual', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-connect-error.mjs', + 'instrument-orchestrion.mjs', + (createTestRunner, test) => { + test('records an errored connect span when the connection fails', { timeout: 90_000 }, async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + ); + }); + + describe('requireParentSpan', () => { + createEsmAndCjsTests( + __dirname, + 'scenario-no-parent.mjs', + 'instrument-orchestrion.mjs', + (createTestRunner, test) => { + test( + 'does not instrument queries or connects without an active parent span', + { timeout: 90_000 }, + async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ + transaction: txn => { + const descriptions = txn.spans?.map(span => span.description) ?? []; + expect(descriptions).not.toContain('SELECT 1 AS unparented'); + expect(descriptions.find(name => name?.includes('connect'))).toBeUndefined(); + expect(txn).toMatchObject({ + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.statement': 'SELECT 2 AS parented', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'SELECT 2 AS parented', + op: 'db', + status: 'ok', + origin: ORIGIN, + }), + ]), + }); + }, + }) + .start() + .completed(); + }, + ); + }, + ); + }); + }); }); diff --git a/packages/deno/package.json b/packages/deno/package.json index 1bab4fc4dc7c..cd534645819f 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -32,7 +32,8 @@ "@sentry/server-utils": "10.63.0" }, "devDependencies": { - "mysql": "^2.18.1" + "mysql": "^2.18.1", + "pg": "8.16.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 6ed78e48a884..7361e4d28e86 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -112,6 +112,7 @@ export type { DenoHttpIntegrationOptions } from './integrations/http'; export { denoRedisIntegration } from './integrations/redis'; export type { DenoRedisIntegrationOptions } from './integrations/redis'; export { denoMysqlIntegration } from './integrations/mysql'; +export { denoPostgresIntegration } from './integrations/postgres'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/postgres.ts b/packages/deno/src/integrations/postgres.ts new file mode 100644 index 000000000000..b0ccc9b30c95 --- /dev/null +++ b/packages/deno/src/integrations/postgres.ts @@ -0,0 +1,36 @@ +import { postgresChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoPostgres' as const; + +interface DenoPostgresIntegrationOptions { + /** Whether to skip creating spans for `pg`/`pg-pool` connections. Defaults to `false`. */ + ignoreConnectSpans?: boolean; +} + +/** + * Create spans for `pg` (node-postgres) queries under Deno. + * + * `pg` channels are injected by the orchestrion runtime hook at load time. + * The `@sentry/deno/import` loader must be active for this integration to + * record anything. + * + * The channel-subscription logic is shared with the other server runtimes in + * `@sentry/server-utils`. This just installs Deno's + * `AsyncLocalStorage` context strategy (so spans nest under the active + * span and survive pg's internal callback dispatch) before delegating. + */ +const _denoPostgresIntegration = ((options?: DenoPostgresIntegrationOptions) => { + const inner = postgresChannelIntegration(options); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoPostgresIntegration = defineIntegration(_denoPostgresIntegration); diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 5846d5f02747..db846d6b8007 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -24,6 +24,7 @@ import { import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoMysqlIntegration } from './integrations/mysql'; +import { denoPostgresIntegration } from './integrations/postgres'; import { denoRedisIntegration } from './integrations/redis'; import { globalHandlersIntegration } from './integrations/globalhandlers'; import { normalizePathsIntegration } from './integrations/normalizepaths'; @@ -59,7 +60,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { // It's possible that the orchestrion channels will be injected AFTER // (or in parallel to) loading the SDK, so we only gate on whether the // feature is possible. If they're never loaded, it'll just be a no-op. - ...(MODULE_REGISTER_HOOKS_SUPPORTED ? [denoMysqlIntegration()] : []), + ...(MODULE_REGISTER_HOOKS_SUPPORTED ? [denoMysqlIntegration(), denoPostgresIntegration()] : []), contextLinesIntegration(), normalizePathsIntegration(), globalHandlersIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 2b4107c40251..92173dfec650 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -116,6 +116,7 @@ snapshot[`captureException 1`] = ` "DenoHttp", "DenoRedis", "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -192,6 +193,7 @@ snapshot[`captureMessage 1`] = ` "DenoHttp", "DenoRedis", "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -275,6 +277,7 @@ snapshot[`captureMessage twice 1`] = ` "DenoHttp", "DenoRedis", "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -365,6 +368,7 @@ snapshot[`captureMessage twice 2`] = ` "DenoHttp", "DenoRedis", "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", diff --git a/packages/deno/test/orchestrion-postgres.test.ts b/packages/deno/test/orchestrion-postgres.test.ts new file mode 100644 index 000000000000..f1ca62c16988 --- /dev/null +++ b/packages/deno/test/orchestrion-postgres.test.ts @@ -0,0 +1,150 @@ +// + +import { tracingChannel } from 'node:diagnostics_channel'; +import type { TransactionEvent } from '@sentry/core'; +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; +import type { DenoClient } from '../build/esm/index.js'; +import { getCurrentScope, getGlobalScope, getIsolationScope, init, startSpan } from '../build/esm/index.js'; + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +/** See `deno-redis.test.ts` same sink shape, deduped for clarity. */ +function transactionSink(): { + beforeSendTransaction: (event: TransactionEvent) => null; + waitFor: (predicate: (event: TransactionEvent) => boolean) => Promise; +} { + const transactions: TransactionEvent[] = []; + const waiters: { predicate: (e: TransactionEvent) => boolean; resolve: (e: TransactionEvent) => void }[] = []; + return { + beforeSendTransaction(event) { + transactions.push(event); + for (let i = waiters.length - 1; i >= 0; i--) { + const w = waiters[i]!; + if (w.predicate(event)) { + waiters.splice(i, 1); + w.resolve(event); + } + } + return null; + }, + waitFor(predicate) { + const already = transactions.find(predicate); + if (already) return Promise.resolve(already); + return new Promise(resolve => { + waiters.push({ predicate, resolve }); + }); + }, + }; +} + +function withTimeout(p: Promise, ms: number, what: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${what} after ${ms}ms`)), ms); + }); + return Promise.race([p, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +Deno.test('denoPostgresIntegration: included in default integrations (Deno 2.8.0+)', () => { + resetGlobals(); + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; + const names = client.getOptions().integrations.map(i => i.name); + assert(names.includes('DenoPostgres'), `DenoPostgres should be in defaults, got ${names.join(', ')}`); +}); + +// The orchestrion runtime hook (`@sentry/deno/import`) only works as a FIRST +// import inside the entry graph in Deno 2.8.0 through 2.8.2. +// TODO: revisit a `--import` or `--preload` approach once Deno 2.8.3 ships. +Deno.test('@sentry/deno/import: transforms pg so it publishes the orchestrion channel', async () => { + const scenario = new URL('./orchestrion-postgres/scenario.mjs', import.meta.url); + + // packages/deno, where node_modules resolves + const cwd = new URL('../', import.meta.url); + + const command = new Deno.Command('deno', { + args: ['run', '--allow-all', scenario.pathname], + cwd: cwd.pathname, + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stdout, stderr } = await command.output(); + const out = new TextDecoder().decode(stdout); + const err = new TextDecoder().decode(stderr); + + assertEquals(code, 0, `scenario exited ${code}\nstdout:\n${out}\nstderr:\n${err}`); + + const line = out.split('\n').find(l => l.startsWith('SCENARIO')) ?? ''; + assert(line, `no SCENARIO line in output:\n${out}\nstderr:\n${err}`); + // The injected channel fired on `client.query()` + // proves pg was transformed... + assert(line.includes('events=start'), `expected channel 'start' event, got: ${line}`); + // ...with the real SQL forwarded through the channel context. + assert(line.includes('statement=SELECT 1 AS solution'), `expected forwarded SQL, got: ${line}`); + // The runtime hook set its detection marker at boot. + assert(line.includes('"runtime":true'), `expected runtime marker, got: ${line}`); +}); + +// Exercises the SDK path e2e: `init()` wires `denoPostgresIntegration` +// (which installs the AsyncLocalStorage context strategy and subscribes to +// the channel), and we drive the `orchestrion:pg:query` channel manually, +// the same events the orchestrion transform publishes around +// `client.query()`, so no live database is needed. Asserting a nested `db` +// span proves the subscriber, the emitted attributes, AND the +// context-strategy wiring all work. +Deno.test('denoPostgresIntegration: orchestrion:pg:query channel produces a nested db span', async () => { + resetGlobals(); + const sink = transactionSink(); + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: sink.beforeSendTransaction, + }); + + const channel = tracingChannel('orchestrion:pg:query'); + + // The shared context object orchestrion reuses across the lifecycle events + // + // `arguments[0]` is the SQL; `self.connectionParameters` is pg's resolved + // connection config. + const ctx = { + arguments: ['SELECT 1 AS solution'], + self: { connectionParameters: { host: '127.0.0.1', port: 5432, database: 'mydb', user: 'root' } }, + }; + + // Callback-success order published by orchestrion's transform: + // start -> end -> asyncStart -> asyncEnd (the span closes on asyncEnd). + startSpan({ name: 'parent', op: 'test' }, () => { + channel.start.runStores(ctx, () => { + channel.end.publish(ctx); + }); + channel.asyncStart.runStores(ctx, () => { + channel.asyncEnd.publish(ctx); + }); + }); + + const parent = await withTimeout( + sink.waitFor(t => t.transaction === 'parent'), + 5000, + "'parent' transaction", + ); + + const pgSpan = parent.spans?.find(s => s.op === 'db'); + assertExists(pgSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); + assertEquals(pgSpan!.description, 'SELECT 1 AS solution'); + assertEquals(pgSpan!.data?.['db.system'], 'postgresql'); + assertEquals(pgSpan!.data?.['db.statement'], 'SELECT 1 AS solution'); + assertEquals(pgSpan!.data?.['net.peer.name'], '127.0.0.1'); + assertEquals(pgSpan!.data?.['net.peer.port'], 5432); + assertEquals(pgSpan!.data?.['db.user'], 'root'); + assertEquals(pgSpan!.data?.['sentry.origin'], 'auto.db.orchestrion.postgres'); +}); diff --git a/packages/deno/test/orchestrion-postgres/scenario.mjs b/packages/deno/test/orchestrion-postgres/scenario.mjs new file mode 100644 index 000000000000..fa6aadbdbe31 --- /dev/null +++ b/packages/deno/test/orchestrion-postgres/scenario.mjs @@ -0,0 +1,40 @@ +// Spawned by orchestrion-postgres.test.ts via `deno run`. +// +// Importing `@sentry/deno/import` FIRST registers the orchestrion module hook, +// so the subsequent `pg` import is transformed to publish to the +// `orchestrion:pg:query` tracing channel. `client.query()` publishes `start` +// synchronously, so no live database is needed. +import '@sentry/deno/import'; + +import { tracingChannel } from 'node:diagnostics_channel'; +const { default: pg } = await import('pg'); + +const events = []; +let statement = ''; + +tracingChannel('orchestrion:pg:query').subscribe({ + start(message) { + events.push('start'); + const first = message?.arguments?.[0]; + statement = typeof first === 'string' ? first : ''; + }, + end() { + events.push('end'); + }, + asyncStart() {}, + asyncEnd() { + events.push('asyncEnd'); + }, + error() {}, +}); + +const client = new pg.Client({ host: '127.0.0.1', user: 'root', database: 'mydb' }); +try { + client.query('SELECT 1 AS solution', () => {}); +} catch { + // No live server, `start` has already published synchronously by now. +} + +const marker = globalThis.__SENTRY_ORCHESTRION__ ?? null; +// eslint-disable-next-line no-console +console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(marker)}`); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 6bba862cdfc0..d5dda607b345 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,7 +1,8 @@ import { - mysqlChannelIntegration, lruMemoizerChannelIntegration, detectOrchestrionSetup, + mysqlChannelIntegration, + postgresChannelIntegration, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; @@ -37,11 +38,25 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject * subscriber/channel modules; the heavy code-transform dependencies stay lazy * inside `register()` and load only when injection actually runs. * + * Per-integration options are passed here rather than via the OTel + * `xxxIntegration({...})` instances, because those are swapped out wholesale for + * their channel equivalents (and a user-provided OTel instance would otherwise + * win integration de-duplication, silently keeping the OTel path). For example, + * to suppress pg connect spans on the orchestrion path: + * + * ```ts + * Sentry.experimentalUseDiagnosticsChannelInjection({ postgres: { ignoreConnectSpans: true } }); + * ``` + * * @experimental May change or be removed in any release. */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const; + const integrations = [ + mysqlChannelIntegration(), + postgresChannelIntegration(), + lruMemoizerChannelIntegration(), + ] as const; const replacedOtelIntegrationNames = integrations.map(i => i.name); return { diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts new file mode 100644 index 000000000000..42277769bc88 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -0,0 +1,268 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Scope, SpanAttributes } from '@sentry/core'; +import { + bindScopeToEmitter, + debug, + defineIntegration, + getActiveSpan, + getCurrentScope, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Postgres' integration is omitted from the default set. +const INTEGRATION_NAME = 'Postgres' as const; + +// Only the query span carries an origin (the connect/pool-connect spans don't, +// so they default to 'manual'). +const ORIGIN = 'auto.db.orchestrion.postgres'; + +// OpenTelemetry "OLD" db/net semantic-conventions, inlined to keep this +// integration free of `@opentelemetry/*` deps. +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const ATTR_DB_USER = 'db.user'; +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const ATTR_PG_PLAN = 'db.postgresql.plan'; +const ATTR_PG_IDLE_TIMEOUT = 'db.postgresql.idle.timeout.millis'; +const ATTR_PG_MAX_CLIENT = 'db.postgresql.max.client'; +const DB_SYSTEM_POSTGRESQL = 'postgresql'; + +// We set `op: 'db'` and the SQL description directly here (same as mysql +// orchestrion) rather than relying on the OTel pipeline's `inferDbSpanData` +// processor, which only runs in the node SDK, so setting them here is what +// makes the spans correct on the other runtimes +// +// The user-visible span is identical to OTel: query spans are named after +// `db.statement`; connect/pool-connect spans keep these names. +const SPAN_QUERY_FALLBACK = 'pg.query'; +const SPAN_CONNECT = 'pg.connect'; +const SPAN_POOL_CONNECT = 'pg-pool.connect'; + +/** + * The shape orchestrion's transform attaches to the tracing-channel `context`. Documented here rather + * than imported because orchestrion's runtime doesn't export it. + */ +interface PgChannelContext { + // The live args array passed to the wrapped `query`/`connect` call. + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; + // The caller's scope, captured at `start` and replayed onto a streamed `Submittable` emitter (see below). + _sentryCallerScope?: Scope; +} + +interface PgConnectionParams { + database?: string; + host?: string; + port?: number; + user?: string; + connectionString?: string; +} + +interface PgPoolOptions extends PgConnectionParams { + idleTimeoutMillis?: number; + // pg-pool stores the max pool size as `max` (defaulting it to 10 in its + // constructor). The OTel pg instrumentation reads a `maxClient` field that + // pg-pool never sets, so its `db.postgresql.max.client` attribute is always + // dropped; we read the real `max` so the attribute is actually populated. + max?: number; +} + +const _postgresChannelIntegration = ((options: { ignoreConnectSpans?: boolean } = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + // Query spans: `pg`/native `Client.prototype.query`. + subscribeQueryLikeChannel(CHANNELS.PG_QUERY, querySpanOptions); + + // Connect spans, gated by `ignoreConnectSpans` (same as OTel pg). + // `Client.prototype.connect` (pg + native) + // and `Pool.prototype.connect` (pg-pool). + if (!options.ignoreConnectSpans) { + subscribeQueryLikeChannel(CHANNELS.PG_CONNECT, connectSpanOptions); + subscribeQueryLikeChannel(CHANNELS.PGPOOL_CONNECT, poolConnectSpanOptions); + } + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Subscribe to a pg tracing-channel and manage a span across its lifecycle. + * Shared by the query/connect/pool-connect channels. They differ only in how + * the span's name + attributes are built (`getSpanOptions`). + */ +function subscribeQueryLikeChannel( + channelName: string, + getSpanOptions: (ctx: PgChannelContext) => { name: string; op: string; attributes: SpanAttributes }, +): void { + DEBUG_BUILD && debug.log(`[orchestrion:pg] subscribing to channel "${channelName}"`); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + // Only instrument when there's an active span; returning `undefined` opts this call out entirely, + // leaving the active context untouched (e.g. connects issued during app startup). + if (!getActiveSpan()) { + return undefined; + } + + // Capture the caller's scope while still synchronously inside the call, for the streamed path: + // pg dispatches a `Submittable` emitter's events outside the original async scope, so `deferSpanEnd` + // replays this scope onto that emitter. + data._sentryCallerScope = getCurrentScope(); + + return startInactiveSpan(getSpanOptions(data)); + }, + { + // Streamable `Submittable` (e.g. `client.query(new Query())`) returns an emitter that orchestrion + // stores on `ctx.result` while firing no async events; the query isn't done until the emitter emits + // `'end'`/`'error'`. Defer ending to those events for that path; the callback, promise, and sync-throw + // paths carry no emitter, so the helper ends the span as usual. + deferSpanEnd({ data, end }) { + const result = data.result; + if (!result || typeof result !== 'object' || !hasOnMethod(result)) { + return false; + } + + // Replay the caller's scope onto the emitter so listeners the user attaches after the call returns + // (and any spans they start) nest under the caller, not a fresh root trace. + const callerScope = data._sentryCallerScope; + if (callerScope) { + bindScopeToEmitter(result, callerScope); + } + + result.on('error', err => end(err)); + result.on('end', () => end()); + + return true; + }, + }, + ); +} + +function querySpanOptions(ctx: PgChannelContext): { name: string; op: string; attributes: SpanAttributes } { + const params = (ctx.self as { connectionParameters?: PgConnectionParams } | undefined)?.connectionParameters ?? {}; + const queryConfig = extractQueryConfig(ctx.arguments); + return { + // The description is the SQL statement + name: queryConfig?.text ?? SPAN_QUERY_FALLBACK, + op: 'db', + attributes: { + ...getConnectionAttributes(params), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + ...(queryConfig?.text ? { [ATTR_DB_STATEMENT]: queryConfig.text } : {}), + ...(typeof queryConfig?.name === 'string' ? { [ATTR_PG_PLAN]: queryConfig.name } : {}), + }, + }; +} + +function connectSpanOptions(ctx: PgChannelContext): { name: string; op: string; attributes: SpanAttributes } { + const params = (ctx.self as { connectionParameters?: PgConnectionParams } | undefined)?.connectionParameters ?? {}; + // No origin set -> defaults to 'manual' + return { name: SPAN_CONNECT, op: 'db', attributes: getConnectionAttributes(params) }; +} + +function poolConnectSpanOptions(ctx: PgChannelContext): { name: string; op: string; attributes: SpanAttributes } { + const opts = (ctx.self as { options?: PgPoolOptions } | undefined)?.options ?? {}; + return { name: SPAN_POOL_CONNECT, op: 'db', attributes: getPoolConnectionAttributes(opts) }; +} + +function hasOnMethod(obj: object): obj is { on: (event: string, listener: (arg?: unknown) => void) => unknown } { + return 'on' in obj && typeof (obj as { on?: unknown }).on === 'function'; +} + +// `client.query(text, cb?)`, `client.query(text, values, cb?)`, and +// `client.query(configObj, cb?)` are all valid; normalize to `{ text, name }` +// (the only fields the span needs). Returns undefined for invalid args. +function extractQueryConfig(args: unknown[]): { text: string; name?: unknown } | undefined { + const arg0 = args[0]; + if (typeof arg0 === 'string') { + return { text: arg0 }; + } + if (arg0 && typeof arg0 === 'object' && typeof (arg0 as { text?: unknown }).text === 'string') { + const obj = arg0 as { text: string; name?: unknown }; + return { text: obj.text, name: obj.name }; + } + return undefined; +} + +function getConnectionAttributes(params: PgConnectionParams): SpanAttributes { + return { + [ATTR_DB_SYSTEM]: DB_SYSTEM_POSTGRESQL, + [ATTR_DB_CONNECTION_STRING]: getConnectionString(params), + ...(params.database ? { [ATTR_DB_NAME]: params.database } : {}), + ...(params.user ? { [ATTR_DB_USER]: params.user } : {}), + ...(params.host ? { [ATTR_NET_PEER_NAME]: params.host } : {}), + ...(Number.isInteger(params.port) ? { [ATTR_NET_PEER_PORT]: params.port } : {}), + }; +} + +function getPoolConnectionAttributes(opts: PgPoolOptions): SpanAttributes { + let url: URL | undefined; + try { + url = opts.connectionString ? new URL(opts.connectionString) : undefined; + } catch { + url = undefined; + } + const database = url?.pathname.slice(1) || opts.database; + const host = url?.hostname || opts.host; + const port = url ? Number(url.port) || undefined : Number.isInteger(opts.port) ? opts.port : undefined; + const user = url?.username || opts.user; + return { + [ATTR_DB_SYSTEM]: DB_SYSTEM_POSTGRESQL, + [ATTR_DB_CONNECTION_STRING]: getConnectionString(opts), + ...(opts.idleTimeoutMillis !== undefined ? { [ATTR_PG_IDLE_TIMEOUT]: opts.idleTimeoutMillis } : {}), + ...(opts.max !== undefined ? { [ATTR_PG_MAX_CLIENT]: opts.max } : {}), + ...(database ? { [ATTR_DB_NAME]: database } : {}), + ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), + ...(port !== undefined ? { [ATTR_NET_PEER_PORT]: port } : {}), + ...(user ? { [ATTR_DB_USER]: user } : {}), + }; +} + +// Builds `postgresql://host:port/database`, masking credentials when a raw +// connection string was provided. +function getConnectionString(params: PgConnectionParams): string { + if (params.connectionString) { + try { + const url = new URL(params.connectionString); + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + return 'postgresql://localhost:5432/'; + } + } + const host = params.host || 'localhost'; + const port = params.port || 5432; + const database = params.database || ''; + return `postgresql://${host}:${port}/${database}`; +} + +/** + * EXPERIMENTAL: orchestrion-driven `pg` (node-postgres) integration. + * + * Subscribes to the `orchestrion:pg:query`/`:connect` and + * `orchestrion:pg-pool:connect` diagnostics_channels that the orchestrion code + * transform injects into `pg`'s `Client.prototype.query`/`connect` + * and `pg-pool`'s `Pool.prototype.connect`. Requires the orchestrion runtime + * hook or bundler plugin to be active. + */ +export const postgresChannelIntegration = defineIntegration(_postgresChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index ad2d8ccdd4dd..71cba4d6e8da 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -14,6 +14,9 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', + PG_QUERY: 'orchestrion:pg:query', + PG_CONNECT: 'orchestrion:pg:connect', + PGPOOL_CONNECT: 'orchestrion:pg-pool:connect', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 104df2185386..a41e255772c7 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -38,6 +38,44 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, + // `pg` (node-postgres). + // instruments `Client.prototype.query`/`connect` (both the JS and native + // clients) plus `pg-pool`'s `Pool.prototype.connect`. + // `Auto` covers the callback, promise, and streamable-`Submittable` + // call shapes (like mysql). + // `pg/lib/client.js` is `class Client { query() {...} connect() {...} }`, + // so `className`+`methodName` matches directly. + { + channelName: 'query', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/client.js' }, + functionQuery: { className: 'Client', methodName: 'query', kind: 'Auto' }, + }, + { + channelName: 'connect', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/client.js' }, + functionQuery: { className: 'Client', methodName: 'connect', kind: 'Auto' }, + }, + // The native client (`pg/lib/native/client.js`) is a constructor function, + // not a class. + // `Client.prototype.query = function (config, values, callback) {...}` + // so it needs `expressionName` (the mysql shape), publishing to the SAME + // `orchestrion:pg:query`/`:connect` channels as the JS client. + { + channelName: 'query', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/native/client.js' }, + functionQuery: { expressionName: 'query', kind: 'Auto' }, + }, + { + channelName: 'connect', + module: { name: 'pg', versionRange: '>=8.0.3 <9', filePath: 'lib/native/client.js' }, + functionQuery: { expressionName: 'connect', kind: 'Auto' }, + }, + // `pg-pool` is `class Pool extends EventEmitter { connect(cb) {...} }`. + { + channelName: 'connect', + module: { name: 'pg-pool', versionRange: '>=2.0.0 <4', filePath: 'index.js' }, + functionQuery: { className: 'Pool', methodName: 'connect', kind: 'Auto' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 4b182e51ec13..e1b55a602876 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,4 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +export { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; diff --git a/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts new file mode 100644 index 000000000000..12f2bd6688df --- /dev/null +++ b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts @@ -0,0 +1,83 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { postgresChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// `setupOnce` only subscribes once `waitForTracingChannelBinding` sees an +// async-context strategy exposing `getTracingChannelBinding`. Install a +// minimal one so the subscriptions actually register here. +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +// `setupOnce` subscribes to process-global `tracingChannel`s, so asserting the +// ABSENCE of connect subscribers only holds when no other (default-options) +// integration in the same module context has subscribed. vitest isolates +// module state per file, so this file keeps that assertion clean (the default +// options integration is exercised in `postgres.test.ts`). +describe('postgresChannelIntegration({ ignoreConnectSpans: true })', () => { + beforeAll(() => { + installTestAsyncContextStrategy(); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + it('subscribes to the query channel but NOT the connect / pool-connect channels', () => { + postgresChannelIntegration({ ignoreConnectSpans: true }).setupOnce?.(); + + expect(tracingChannel(CHANNELS.PG_QUERY).start.hasSubscribers).toBe(true); + expect(tracingChannel(CHANNELS.PG_CONNECT).start.hasSubscribers).toBe(false); + expect(tracingChannel(CHANNELS.PGPOOL_CONNECT).start.hasSubscribers).toBe(false); + }); +}); diff --git a/packages/server-utils/test/orchestrion/postgres.test.ts b/packages/server-utils/test/orchestrion/postgres.test.ts new file mode 100644 index 000000000000..fb9cb310c734 --- /dev/null +++ b/packages/server-utils/test/orchestrion/postgres.test.ts @@ -0,0 +1,231 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { postgresChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// `bindTracingChannelToSpan` only binds (and `setupOnce` only subscribes via +// `waitForTracingChannelBinding`) when an async-context strategy exposes a +// `getTracingChannelBinding`. Install a minimal one so the channel +// subscriptions actually register in this unit-test context (no SDK `init`). +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +// The subscriber builds spans via `startInactiveSpan` and gates on +// `getActiveSpan`. We spy both: `getActiveSpan` to satisfy the +// requireParentSpan gate, and `startInactiveSpan` to capture the span +// options the subscriber builds (name + raw attributes) and to track the +// span's lifecycle. The final `op: 'db'` / SQL description come from the +// SDK's `inferDbSpanData` processor, which isn't wired up here. That's +// covered by the integration test. +function makeSpan(): Span { + return { end: vi.fn(), setStatus: vi.fn(), setAttributes: vi.fn() } as unknown as Span; +} + +interface ChannelContext { + arguments: unknown[]; + self?: unknown; +} + +describe('postgresChannelIntegration', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + // Subscribe once for the whole file so a single subscriber handles each + // publish (avoids accumulating duplicate subscriptions across tests). The + // strategy must be installed first so `setupOnce`'s `waitForTracingChannelBinding` fires synchronously. + beforeAll(() => { + installTestAsyncContextStrategy(); + postgresChannelIntegration().setupOnce?.(); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + // A truthy active span by default, so the requireParentSpan gate passes. + getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const CONNECTION = { database: 'tests', host: 'localhost', port: 5432, user: 'tim' }; + + it('query: builds a `pg.query` span with db attributes and the orchestrion origin', async () => { + const ctx: ChannelContext = { arguments: ['SELECT * FROM "User"'], self: { connectionParameters: CONNECTION } }; + + await tracingChannel(CHANNELS.PG_QUERY).tracePromise(async () => ({ rows: [] }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SELECT * FROM "User"', + op: 'db', + attributes: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.user': 'tim', + 'net.peer.name': 'localhost', + 'net.peer.port': 5432, + 'db.connection_string': 'postgresql://localhost:5432/tests', + 'db.statement': 'SELECT * FROM "User"', + 'sentry.origin': 'auto.db.orchestrion.postgres', + }), + }), + ); + // Ended on `asyncEnd` (the full promise round-trip). + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('query: records the prepared-statement name as `db.postgresql.plan`', async () => { + const ctx: ChannelContext = { + arguments: [{ name: 'select-user-by-email', text: 'SELECT * FROM "User" WHERE "email" = $1', values: ['x'] }], + self: { connectionParameters: CONNECTION }, + }; + + await tracingChannel(CHANNELS.PG_QUERY).tracePromise(async () => ({ rows: [] }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SELECT * FROM "User" WHERE "email" = $1', + op: 'db', + attributes: expect.objectContaining({ + 'db.statement': 'SELECT * FROM "User" WHERE "email" = $1', + 'db.postgresql.plan': 'select-user-by-email', + 'sentry.origin': 'auto.db.orchestrion.postgres', + }), + }), + ); + }); + + it('query: sets error status and ends the span when the query rejects', async () => { + const ctx: ChannelContext = { arguments: ['SELECT 1'], self: { connectionParameters: CONNECTION } }; + + await expect( + tracingChannel(CHANNELS.PG_QUERY).tracePromise(async () => { + throw new Error('boom'); + }, ctx), + ).rejects.toThrow('boom'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: expect.anything(), message: 'boom' }); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('connect: builds a `pg.connect` span with no origin (defaults to manual)', async () => { + const ctx: ChannelContext = { arguments: [], self: { connectionParameters: CONNECTION } }; + + await tracingChannel(CHANNELS.PG_CONNECT).tracePromise(async () => undefined, ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'pg.connect', + op: 'db', + attributes: expect.objectContaining({ 'db.system': 'postgresql', 'db.name': 'tests' }), + }), + ); + // Connect spans must NOT set an origin (so they default to 'manual'). + const options = startInactiveSpanSpy.mock.calls[0]![0] as { attributes: Record }; + expect(options.attributes['sentry.origin']).toBeUndefined(); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('pool connect: builds a `pg-pool.connect` span with masked connection string + pool attributes', async () => { + const ctx: ChannelContext = { + arguments: [], + self: { + options: { + connectionString: 'postgresql://user:secret@localhost:5494/tests', + idleTimeoutMillis: 10_000, + // pg-pool exposes the max pool size as `max` (not `maxClient`). + max: 10, + }, + }, + }; + + await tracingChannel(CHANNELS.PGPOOL_CONNECT).tracePromise(async () => undefined, ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'pg-pool.connect', + op: 'db', + attributes: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.user': 'user', + 'net.peer.name': 'localhost', + 'net.peer.port': 5494, + // Credentials masked out of the connection string. + 'db.connection_string': 'postgresql://localhost:5494/tests', + 'db.postgresql.idle.timeout.millis': 10_000, + 'db.postgresql.max.client': 10, + }), + }), + ); + const options = startInactiveSpanSpy.mock.calls[0]![0] as { attributes: Record }; + expect(options.attributes['sentry.origin']).toBeUndefined(); + }); + + it('requireParentSpan: does not create a span when there is no active span', async () => { + getActiveSpanSpy.mockReturnValue(undefined); + const ctx: ChannelContext = { arguments: ['SELECT 1'], self: { connectionParameters: CONNECTION } }; + + await tracingChannel(CHANNELS.PG_QUERY).tracePromise(async () => ({ rows: [] }), ctx); + + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); +}); From 7245b992b36924238a815f3dfc710ff8aed20dce Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 1 Jul 2026 12:14:21 -0700 Subject: [PATCH 2/4] fix(node): re-export orchestrion integrations Make it possible to provide options to orchestrion integrations without needing to access internal API `@sentry/server-utils/orchestrion`. An integration test is added verifying that `ignoreConnectSpans` can be set on the orchestrion `pg` integration. --- .../scripts/consistentExports.ts | 4 ++ .../node-integration-tests/package.json | 1 + .../instrument-orchestrion-ignoreConnect.mjs | 18 ++++++ .../suites/tracing/postgres/test.ts | 59 +++++++++++++++++++ packages/node/src/index.ts | 5 +- ...erimentalUseDiagnosticsChannelInjection.ts | 22 ++++--- yarn.lock | 27 ++++++--- 7 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion-ignoreConnect.mjs diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 1436e35fcc6b..dd6f7f4613cb 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -25,6 +25,10 @@ const NODE_EXPORTS_IGNORE = [ // registers Node module hooks and is not surfaced through the framework / // serverless SDKs. 'experimentalUseDiagnosticsChannelInjection', + // Companion to the above: returns the diagnostics-channel integration + // factories for that same Node-runtime-only opt-in, so it isn't surfaced + // through the framework / serverless SDKs either. + 'diagnosticsChannelInjectionIntegrations', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index aebda7d552a1..3a08d5594ddb 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -48,6 +48,7 @@ "@prisma/client": "6.15.0", "@sentry/aws-serverless": "10.63.0", "@sentry/core": "10.63.0", + "@sentry/server-utils": "10.63.0", "@sentry/hono": "10.63.0", "@sentry/node": "10.63.0", "@types/mongodb": "^3.6.20", diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion-ignoreConnect.mjs b/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion-ignoreConnect.mjs new file mode 100644 index 000000000000..65dfab533f86 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion-ignoreConnect.mjs @@ -0,0 +1,18 @@ +// Same orchestrion opt-in as `instrument-orchestrion.mjs`, but configuring the +// integration the normal way: `postgresIntegration({ ignoreConnectSpans: true })`. +// Because injection was opted into, `postgresIntegration()` builds the +// diagnostics-channel implementation and forwards the option to it — so connect +// spans are suppressed on the orchestrion path exactly as on the OTel one. +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +const { postgresIntegration } = Sentry.diagnosticsChannelInjectionIntegrations(); + +Sentry.experimentalUseDiagnosticsChannelInjection(); +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [postgresIntegration({ ignoreConnectSpans: true })], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts index 8358a481186c..1338dc530f10 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -547,5 +547,64 @@ describe('postgres auto instrumentation', () => { }, ); }); + + describe('ignoreConnectSpans', () => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-orchestrion-ignoreConnect.mjs', + (createTestRunner, test) => { + test( + "doesn't emit connect spans if ignoreConnectSpans is true (orchestrion)", + { timeout: 90_000 }, + async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ + transaction: txn => { + const spanNames = txn.spans?.map(span => span.description); + // No `pg.connect` / `pg-pool.connect` spans were produced. + expect(spanNames?.find(name => name?.includes('connect'))).toBeUndefined(); + // ...but the query spans are still instrumented via orchestrion. + expect(txn).toMatchObject({ + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.statement': 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + op: 'db', + status: 'ok', + origin: ORIGIN, + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.statement': 'SELECT * FROM "User"', + 'sentry.origin': ORIGIN, + 'sentry.op': 'db', + }), + description: 'SELECT * FROM "User"', + op: 'db', + status: 'ok', + origin: ORIGIN, + }), + ]), + }); + }, + }) + .start() + .completed(); + }, + ); + }, + ); + }); }); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5dc8b988ed7f..a03f9e239525 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -47,7 +47,10 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, } from './sdk'; -export { experimentalUseDiagnosticsChannelInjection } from './sdk/experimentalUseDiagnosticsChannelInjection'; +export { + experimentalUseDiagnosticsChannelInjection, + diagnosticsChannelInjectionIntegrations, +} from './sdk/experimentalUseDiagnosticsChannelInjection'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index d5dda607b345..fd813ac807cd 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,13 +1,21 @@ import { + mysqlChannelIntegration, lruMemoizerChannelIntegration, detectOrchestrionSetup, - mysqlChannelIntegration, postgresChannelIntegration, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; +export function diagnosticsChannelInjectionIntegrations() { + return { + postgresIntegration: postgresChannelIntegration, + mysqlIntegration: mysqlChannelIntegration, + lruMemoizerIntegration: lruMemoizerChannelIntegration, + }; +} + /** * EXPERIMENTAL: opt into diagnostics-channel-based auto-instrumentation. * @@ -38,24 +46,14 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject * subscriber/channel modules; the heavy code-transform dependencies stay lazy * inside `register()` and load only when injection actually runs. * - * Per-integration options are passed here rather than via the OTel - * `xxxIntegration({...})` instances, because those are swapped out wholesale for - * their channel equivalents (and a user-provided OTel instance would otherwise - * win integration de-duplication, silently keeping the OTel path). For example, - * to suppress pg connect spans on the orchestrion path: - * - * ```ts - * Sentry.experimentalUseDiagnosticsChannelInjection({ postgres: { ignoreConnectSpans: true } }); - * ``` - * * @experimental May change or be removed in any release. */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { const integrations = [ mysqlChannelIntegration(), - postgresChannelIntegration(), lruMemoizerChannelIntegration(), + postgresChannelIntegration(), ] as const; const replacedOtelIntegrationNames = integrations.map(i => i.name); diff --git a/yarn.lock b/yarn.lock index c8ca952adda8..9e0be249b2e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24031,17 +24031,17 @@ periscopic@^3.1.0: estree-walker "^3.0.0" is-reference "^3.0.0" -pg-cloudflare@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz#386035d4bfcf1a7045b026f8b21acf5353f14d65" - integrity sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ== +pg-cloudflare@^1.2.5, pg-cloudflare@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497" + integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A== pg-connection-string@2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.1.tgz#78c23c21a35dd116f48e12e23c0965e8d9e2cbfb" integrity sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg== -pg-connection-string@^2.12.0: +pg-connection-string@^2.12.0, pg-connection-string@^2.9.0: version "2.14.0" resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== @@ -24051,12 +24051,12 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.13.0: +pg-pool@^3.10.0, pg-pool@^3.13.0: version "3.14.0" resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== -pg-protocol@*, pg-protocol@^1.13.0: +pg-protocol@*, pg-protocol@^1.10.0, pg-protocol@^1.13.0: version "1.15.0" resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.15.0.tgz#758f6c0679cc0bbf4938603b7597703f333180c0" integrity sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ== @@ -24072,6 +24072,19 @@ pg-types@2.2.0, pg-types@^2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" +pg@8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.16.0.tgz#40b08eedb5eb1834252cf3e3629503e32e6c6c04" + integrity sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg== + dependencies: + pg-connection-string "^2.9.0" + pg-pool "^3.10.0" + pg-protocol "^1.10.0" + pg-types "2.2.0" + pgpass "1.0.5" + optionalDependencies: + pg-cloudflare "^1.2.5" + pg@8.20.0, pg@^8.16.3: version "8.20.0" resolved "https://registry.yarnpkg.com/pg/-/pg-8.20.0.tgz#1a274de944cb329fd6dd77a6d371a005ba6b136d" From e418247bc5b58f8a1fe6ff3ca649ffecc01a0f34 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 1 Jul 2026 12:52:21 -0700 Subject: [PATCH 3/4] fix(server-utils): better span attributes falsey detection --- .../integrations/tracing-channel/postgres.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts index 42277769bc88..187f589383f1 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -167,8 +167,8 @@ function querySpanOptions(ctx: PgChannelContext): { name: string; op: string; at attributes: { ...getConnectionAttributes(params), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - ...(queryConfig?.text ? { [ATTR_DB_STATEMENT]: queryConfig.text } : {}), - ...(typeof queryConfig?.name === 'string' ? { [ATTR_PG_PLAN]: queryConfig.name } : {}), + [ATTR_DB_STATEMENT]: queryConfig?.text || undefined, + [ATTR_PG_PLAN]: typeof queryConfig?.name === 'string' ? queryConfig.name : undefined, }, }; } @@ -207,10 +207,10 @@ function getConnectionAttributes(params: PgConnectionParams): SpanAttributes { return { [ATTR_DB_SYSTEM]: DB_SYSTEM_POSTGRESQL, [ATTR_DB_CONNECTION_STRING]: getConnectionString(params), - ...(params.database ? { [ATTR_DB_NAME]: params.database } : {}), - ...(params.user ? { [ATTR_DB_USER]: params.user } : {}), - ...(params.host ? { [ATTR_NET_PEER_NAME]: params.host } : {}), - ...(Number.isInteger(params.port) ? { [ATTR_NET_PEER_PORT]: params.port } : {}), + [ATTR_DB_NAME]: params.database, + [ATTR_DB_USER]: params.user, + [ATTR_NET_PEER_NAME]: params.host, + [ATTR_NET_PEER_PORT]: Number.isInteger(params.port) ? params.port : undefined, }; } @@ -228,12 +228,13 @@ function getPoolConnectionAttributes(opts: PgPoolOptions): SpanAttributes { return { [ATTR_DB_SYSTEM]: DB_SYSTEM_POSTGRESQL, [ATTR_DB_CONNECTION_STRING]: getConnectionString(opts), - ...(opts.idleTimeoutMillis !== undefined ? { [ATTR_PG_IDLE_TIMEOUT]: opts.idleTimeoutMillis } : {}), - ...(opts.max !== undefined ? { [ATTR_PG_MAX_CLIENT]: opts.max } : {}), - ...(database ? { [ATTR_DB_NAME]: database } : {}), - ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), - ...(port !== undefined ? { [ATTR_NET_PEER_PORT]: port } : {}), - ...(user ? { [ATTR_DB_USER]: user } : {}), + [ATTR_PG_IDLE_TIMEOUT]: opts.idleTimeoutMillis, + [ATTR_PG_MAX_CLIENT]: opts.max, + [ATTR_DB_NAME]: database, + [ATTR_NET_PEER_PORT]: port, + // these two come from a url parse and slice, can be '' + [ATTR_NET_PEER_NAME]: host || undefined, + [ATTR_DB_USER]: user || undefined, }; } From b2621bd1d7b3b050624240e421a20b3cd3a07cc8 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 1 Jul 2026 13:04:52 -0700 Subject: [PATCH 4/4] fix(ci): better deno- detection --- .github/workflows/build.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 695fa5ee81f3..c84f55e8f9f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1045,10 +1045,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: - matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application == - 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || - matrix.test-application == 'deno-pg' + if: contains(matrix.test-application, 'deno') || matrix.test-application == 'hono-4' uses: denoland/setup-deno@v2.0.4 with: deno-version: ${{ matrix.deno-version || 'v2.8.0' }} @@ -1171,7 +1168,7 @@ jobs: with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Deno - if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' + if: contains(matrix.test-application, 'deno') uses: denoland/setup-deno@v2.0.4 with: deno-version: ${{ matrix.deno-version || 'v2.8.0' }}