From ab619f79077c0f8cba572c80d922e1ddfd33a124 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 26 Jun 2026 21:05:52 -0700 Subject: [PATCH 1/5] 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 | 34 ++ packages/deno/src/sdk.ts | 3 +- .../deno/test/__snapshots__/mod.test.ts.snap | 4 + .../deno/test/orchestrion-postgres.test.ts | 148 +++++++++ .../test/orchestrion-postgres/scenario.mjs | 40 +++ ...erimentalUseDiagnosticsChannelInjection.ts | 10 +- .../integrations/tracing-channel/postgres.ts | 313 ++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 3 + .../server-utils/src/orchestrion/config.ts | 38 +++ .../server-utils/src/orchestrion/index.ts | 1 + .../test/orchestrion/postgres.test.ts | 168 ++++++++++ 29 files changed, 1355 insertions(+), 7 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.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95d746229277..f5b424400357 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 d6ecb6b86ce9..2dd0074acf7c 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -16,7 +16,8 @@ "@sentry/bun": "10.62.0", "@sentry/hono": "10.62.0", "hono": "^4.12.25", - "mysql": "^2.18.1" + "mysql": "^2.18.1", + "pg": "8.16.0" }, "devDependencies": { "@sentry-internal/test-utils": "10.62.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 a24b11efee42..f72dac61a1b1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -353,4 +353,194 @@ describe('postgres auto instrumentation', () => { }); }); }); + + // 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 184b628adf21..655334f0bfd6 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -32,7 +32,8 @@ "@sentry/server-utils": "10.62.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..afb72e827a13 --- /dev/null +++ b/packages/deno/src/integrations/postgres.ts @@ -0,0 +1,34 @@ +import { postgresChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoPostgres' as const; + +/** + * 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 = (() => { + const inner = postgresChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoPostgresIntegration = defineIntegration(_denoPostgresIntegration) as () => Integration & { + name: 'DenoPostgres'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index b3e80d117691..3de21f6cb940 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -25,6 +25,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'; @@ -60,7 +61,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..e4c49eae0eae --- /dev/null +++ b/packages/deno/test/orchestrion-postgres.test.ts @@ -0,0 +1,148 @@ +// + +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.publish(ctx); + channel.end.publish(ctx); + channel.asyncStart.publish(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 d51f2d86a610..f49cfda0696e 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,4 +1,8 @@ -import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { + 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'; @@ -38,8 +42,8 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader( (): DiagnosticsChannelInjection => ({ - integrations: [mysqlChannelIntegration()], - replacedOtelIntegrationNames: ['Mysql'], + integrations: [mysqlChannelIntegration(), postgresChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql', 'Postgres'], register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, }), 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..fa3f94537ab5 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -0,0 +1,313 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Scope, Span, SpanAttributes } from '@sentry/core'; +import { + bindScopeToEmitter, + debug, + defineIntegration, + getActiveSpan, + getCurrentScope, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startInactiveSpan, + withScope, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// 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 wrapAuto transform attaches to the tracing-channel + * `context`. `arguments` is the live call args; orchestrion splices the user's + * callback out and inserts its own wrapper at the same index before `start`, + * and the `start` hook re-wraps that entry to restore the caller's scope + * across pg's async callback dispatch. + */ +interface PgChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +interface PgConnectionParams { + database?: string; + host?: string; + port?: number; + user?: string; + connectionString?: string; +} + +interface PgPoolOptions extends PgConnectionParams { + idleTimeoutMillis?: number; + maxClient?: number; +} + +const _postgresChannelIntegration = ((options: { ignoreConnectSpans?: boolean } = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // 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}"`); + const ch = diagnosticsChannel.tracingChannel(channelName); + const spans = new WeakMap(); + const parentScopes = new WeakMap(); + + ch.subscribe({ + start(rawCtx) { + // only instrument when there's an active span + if (!getActiveSpan()) { + return; + } + const ctx = rawCtx as PgChannelContext; + const span = startInactiveSpan(getSpanOptions(ctx)); + spans.set(rawCtx, span); + + // Capture the caller's scope while we're still synchronously inside the + // call, to restore it for deferred callbacks/streamed events + // (pg dispatches them outside the original async scope). + const scope = getCurrentScope(); + parentScopes.set(rawCtx, scope); + + // Re-wrap orchestrion's callback wrapper so the user's callback and + // anything chained off it runs with the captured scope active. + if (ctx.arguments.length > 0) { + const cbIdx = ctx.arguments.length - 1; + const orchestrionWrappedCb = ctx.arguments[cbIdx]; + if (typeof orchestrionWrappedCb === 'function') { + const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown; + ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown { + return withScope(scope, () => wrapped.apply(this, args)); + }; + } + } + }, + + end(rawCtx) { + const ctx = rawCtx as PgChannelContext; + + // Sync throw: `end` fires after `error`, so `ctx.error` is set; close + // now since no `asyncEnd` will fire. + if (ctx.error !== undefined) { + finishSpan(rawCtx); + return; + } + + // Streamable `Submittable` (e.g. `client.query(new Query())`): + // orchestrion stored the returned emitter on `ctx.result` and fired no + // async events. Bind the captured scope to it and finish on + // end or error. + const result = ctx.result; + if (result && typeof result === 'object' && hasOnMethod(result)) { + const span = spans.get(rawCtx); + if (!span) { + return; + } + const parentScope = parentScopes.get(rawCtx); + if (parentScope) { + bindScopeToEmitter(result, parentScope); + } + result.on('error', err => { + span.setStatus({ code: SPAN_STATUS_ERROR, message: err instanceof Error ? err.message : 'unknown_error' }); + finishSpan(rawCtx); + }); + result.on('end', () => finishSpan(rawCtx)); + return; + } + + // Callback/promise path: `asyncEnd` closes the span. + }, + + error(rawCtx) { + const ctx = rawCtx as PgChannelContext; + const span = spans.get(rawCtx); + if (!span) { + return; + } + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: ctx.error instanceof Error ? ctx.error.message : 'unknown_error', + }); + }, + + asyncStart() { + // No-op: we end on `asyncEnd` so the span covers the full duration. + }, + + asyncEnd(rawCtx) { + finishSpan(rawCtx); + }, + }); + + function finishSpan(rawCtx: object): void { + const span = spans.get(rawCtx); + if (span) { + span.end(); + spans.delete(rawCtx); + parentScopes.delete(rawCtx); + } + } +} + +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.maxClient !== undefined ? { [ATTR_PG_MAX_CLIENT]: opts.maxClient } : {}), + ...(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 28dcf0c33468..039177747a30 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -13,6 +13,9 @@ */ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', + 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 35b326fb8eb1..32cc440f0341 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -32,6 +32,44 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ // attach `'end'`/`'error'` listeners that finish the span. functionQuery: { expressionName: 'query', kind: 'Auto' }, }, + // `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 dd3ecd0f8f19..9f2f274455c4 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,2 +1,3 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +export { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; 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..ad42ad3d4891 --- /dev/null +++ b/packages/server-utils/test/orchestrion/postgres.test.ts @@ -0,0 +1,168 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { postgresChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +// 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() } 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). + beforeAll(() => { + postgresChannelIntegration().setupOnce?.(); + }); + + 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, + maxClient: 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 a6f72ff18ddf78150f76f29a33890f4b52417718 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 26 Jun 2026 21:43:25 -0700 Subject: [PATCH 2/5] fixup! feat: pg orchestrion instrumentation --- .../src/integrations/tracing-channel/postgres.ts | 8 ++++++-- packages/server-utils/test/orchestrion/postgres.test.ts | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts index fa3f94537ab5..a98b8068058e 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -71,7 +71,11 @@ interface PgConnectionParams { interface PgPoolOptions extends PgConnectionParams { idleTimeoutMillis?: number; - maxClient?: 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 } = {}) => { @@ -274,7 +278,7 @@ function getPoolConnectionAttributes(opts: PgPoolOptions): SpanAttributes { [ATTR_DB_SYSTEM]: DB_SYSTEM_POSTGRESQL, [ATTR_DB_CONNECTION_STRING]: getConnectionString(opts), ...(opts.idleTimeoutMillis !== undefined ? { [ATTR_PG_IDLE_TIMEOUT]: opts.idleTimeoutMillis } : {}), - ...(opts.maxClient !== undefined ? { [ATTR_PG_MAX_CLIENT]: opts.maxClient } : {}), + ...(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 } : {}), diff --git a/packages/server-utils/test/orchestrion/postgres.test.ts b/packages/server-utils/test/orchestrion/postgres.test.ts index ad42ad3d4891..b206562c33de 100644 --- a/packages/server-utils/test/orchestrion/postgres.test.ts +++ b/packages/server-utils/test/orchestrion/postgres.test.ts @@ -129,7 +129,8 @@ describe('postgresChannelIntegration', () => { options: { connectionString: 'postgresql://user:secret@localhost:5494/tests', idleTimeoutMillis: 10_000, - maxClient: 10, + // pg-pool exposes the max pool size as `max` (not `maxClient`). + max: 10, }, }, }; From 9d241da5a70d601749f14e48507eb4c8b389871e Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 26 Jun 2026 21:51:25 -0700 Subject: [PATCH 3/5] fixup! fixup! feat: pg orchestrion instrumentation --- ...erimentalUseDiagnosticsChannelInjection.ts | 16 ++++++++++++++-- .../postgres-ignore-connect.test.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index f49cfda0696e..21cecbaf8b58 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -37,12 +37,24 @@ 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 { +export function experimentalUseDiagnosticsChannelInjection( + options: { postgres?: { ignoreConnectSpans?: boolean } } = {}, +): void { setDiagnosticsChannelInjectionLoader( (): DiagnosticsChannelInjection => ({ - integrations: [mysqlChannelIntegration(), postgresChannelIntegration()], + integrations: [mysqlChannelIntegration(), postgresChannelIntegration(options.postgres)], replacedOtelIntegrationNames: ['Mysql', 'Postgres'], register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, 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..0949296084f8 --- /dev/null +++ b/packages/server-utils/test/orchestrion/postgres-ignore-connect.test.ts @@ -0,0 +1,19 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import { describe, expect, it } from 'vitest'; +import { postgresChannelIntegration } from '../../src/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; + +// `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 })', () => { + 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); + }); +}); From 334614a2f3d45bc78ff911e9834f87557721ad38 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sat, 27 Jun 2026 16:18:04 -0700 Subject: [PATCH 4/5] fix(node): do not pass options to orchestrion opt-in This resolves the API surface wart where options for orchestrion integrations had to be passed into the `experimentalUseDiagnosticsChannelInjection` method, and returns that to being an argument-free void-returning opt-in that can be easily no-op'ed in the future, and all options are passed in via `Sentry.init()` as they were in the prior OTel implementation. --- .../instrument-orchestrion-ignoreConnect.mjs | 17 +++++ .../suites/tracing/postgres/test.ts | 55 ++++++++++++++ .../src/integrations/tracing/mysql/index.ts | 8 +++ .../integrations/tracing/postgres/index.ts | 8 +++ .../src/sdk/diagnosticsChannelInjection.ts | 37 ++++++++-- ...erimentalUseDiagnosticsChannelInjection.ts | 42 ++++++----- packages/node/src/sdk/index.ts | 26 ++----- .../sdk/orchestrionIntegrationSwap.test.ts | 71 +++++++++++++++++++ 8 files changed, 222 insertions(+), 42 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion-ignoreConnect.mjs create mode 100644 packages/node/test/sdk/orchestrionIntegrationSwap.test.ts 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..0bf0316cf234 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/instrument-orchestrion-ignoreConnect.mjs @@ -0,0 +1,17 @@ +// 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 * 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, + integrations: [Sentry.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 f72dac61a1b1..053d2b7f80f9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -542,5 +542,60 @@ 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", { 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/integrations/tracing/mysql/index.ts b/packages/node/src/integrations/tracing/mysql/index.ts index e29d1b6034f1..e77353c68db3 100644 --- a/packages/node/src/integrations/tracing/mysql/index.ts +++ b/packages/node/src/integrations/tracing/mysql/index.ts @@ -2,12 +2,20 @@ import { MySQLInstrumentation } from './vendored/instrumentation'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; +import { getChannelIntegrationFactory } from '../../../sdk/diagnosticsChannelInjection'; const INTEGRATION_NAME = 'Mysql' as const; export const instrumentMysql = generateInstrumentOnce(INTEGRATION_NAME, () => new MySQLInstrumentation({})); const _mysqlIntegration = (() => { + // When opted into diagnostics-channel injection, build + // orchestrion implementation instead, with same options. + const channelIntegration = getChannelIntegrationFactory(INTEGRATION_NAME); + if (channelIntegration) { + return channelIntegration(); + } + return { name: INTEGRATION_NAME, setupOnce() { diff --git a/packages/node/src/integrations/tracing/postgres/index.ts b/packages/node/src/integrations/tracing/postgres/index.ts index 8db6589cc041..155cccecb520 100644 --- a/packages/node/src/integrations/tracing/postgres/index.ts +++ b/packages/node/src/integrations/tracing/postgres/index.ts @@ -2,6 +2,7 @@ import { PgInstrumentation } from './vendored/instrumentation'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; +import { getChannelIntegrationFactory } from '../../../sdk/diagnosticsChannelInjection'; interface PostgresIntegrationOptions { ignoreConnectSpans?: boolean; @@ -18,6 +19,13 @@ export const instrumentPostgres = generateInstrumentOnce( ); const _postgresIntegration = ((options?: PostgresIntegrationOptions) => { + // When opted into diagnostics-channel injection, build + // orchestrion implementation instead, with same options. + const channelIntegration = getChannelIntegrationFactory(INTEGRATION_NAME); + if (channelIntegration) { + return channelIntegration(options); + } + return { name: INTEGRATION_NAME, setupOnce() { diff --git a/packages/node/src/sdk/diagnosticsChannelInjection.ts b/packages/node/src/sdk/diagnosticsChannelInjection.ts index 9f51c053d30a..5061ccf0180e 100644 --- a/packages/node/src/sdk/diagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/diagnosticsChannelInjection.ts @@ -1,4 +1,4 @@ -import type { Integration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; /** * The orchestrion-driven pieces, resolved lazily by the opt-in loader. @@ -13,10 +13,15 @@ import type { Integration } from '@sentry/core'; * normally. */ export interface DiagnosticsChannelInjection { - /** Channel-based integrations to register, replacing their OTel equivalents. */ - integrations: Integration[]; - /** OTel integration names these replace; filtered out of the default set. */ - replacedOtelIntegrationNames: string[]; + /** + * Channel-based integration factories, keyed by the OTel integration name + * they replace (e.g. `Postgres`). The matching OTel integration factory + * (e.g. `postgresIntegration()`) looks itself up here and, when present, + * builds the channel implementation instead, forwarding the user's options. + * This is how opting in swaps implementations without the OTel factory + * ever importing the orchestrion code (which would defeat tree-shaking). + */ + integrationFactories: Record; /** Installs the module hooks that inject the diagnostics channels. */ register: () => void; /** Warns (DEBUG only) about missing or doubled channel injection. */ @@ -35,6 +40,8 @@ let cached: DiagnosticsChannelInjection | undefined; */ export function setDiagnosticsChannelInjectionLoader(load: () => DiagnosticsChannelInjection): void { loader = load; + // A new loader invalidates anything memoized from a previous one. + cached = undefined; } /** Whether `experimentalUseDiagnosticsChannelInjection()` was called. */ @@ -54,3 +61,23 @@ export function resolveDiagnosticsChannelInjection(): DiagnosticsChannelInjectio } return (cached ??= loader()); } + +/** + * The channel-based integration factory registered to replace the OTel + * integration named `name` (e.g. `Postgres`), or `undefined` when the app + * didn't opt into diagnostics-channel injection. Node integration factories + * call this to decide whether to build the orchestrion implementation (with + * the user's options) instead of the OTel one, without ever importing + * orchestrion directly. + * + * @internal + */ +export function getChannelIntegrationFactory(name: string): IntegrationFn | undefined { + return resolveDiagnosticsChannelInjection()?.integrationFactories[name]; +} + +/** Test-only: clear the registered loader and memoized result. */ +export function _resetDiagnosticsChannelInjectionForTesting(): void { + loader = undefined; + cached = undefined; +} diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 21cecbaf8b58..d2655990606a 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -27,9 +27,25 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject * OpenTelemetry ones, and installs the module hooks that inject the channels * (so libraries imported after `init()` publish the channel events). * + * The swap is per-integration and transparent: the OTel integration factories + * build their channel equivalent when this has been called, so you configure + * them exactly as before. + * + * For example, to suppress pg connect spans on the orchestrion path: + * + * ```ts + * Sentry.experimentalUseDiagnosticsChannelInjection(); + * Sentry.init({ + * dsn: '__DSN__', + * integrations: [Sentry.postgresIntegration({ ignoreConnectSpans: true })], + * }); + * ``` + * * This is a standalone function rather than an `init()` option so that a * bundler drops all of it (and its transitive deps) when this function isn't - * called. `init()` reads the loader registered below. + * called. The integration factories reach the channel implementations only via + * the registry populated below, so they never import the orchestrion code + * themselves, keeping the tree-shaking boundary at this function. * * An app that DOES call it gets the orchestrion code bundled as intended. * @@ -37,25 +53,19 @@ 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( - options: { postgres?: { ignoreConnectSpans?: boolean } } = {}, -): void { +// Note: This function is to remain argument-free and void-returning so that +// it can be easily no-op'ed (or provided with a `false` flag or something to +// opt-out) when orchestrion becomes the default. +export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader( (): DiagnosticsChannelInjection => ({ - integrations: [mysqlChannelIntegration(), postgresChannelIntegration(options.postgres)], - replacedOtelIntegrationNames: ['Mysql', 'Postgres'], + // Keyed by the OTel integration name each one replaces. + integrationFactories: { + Mysql: mysqlChannelIntegration, + Postgres: postgresChannelIntegration, + }, register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, }), diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 8c8d2e887541..794165ba38a9 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -30,32 +30,16 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { - const integrations: Integration[] = [ + return [ ...getDefaultIntegrationsWithoutPerformance(), // We only add performance integrations if tracing is enabled - // Note that this means that without tracing enabled, e.g. `expressIntegration()` will not be added - // This means that generally request isolation will work (because that is done by httpIntegration) + // Note that this means that without tracing enabled, e.g. + // `expressIntegration()` will not be added + // This means that generally request isolation will work (because that is + // done by httpIntegration) // But `transactionName` will not be set automatically ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; - - // When the app opted into diagnostics-channel injection (via - // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is - // enabled, swap the channel-based integrations in place of OTel equivalents - // so the two don't both instrument the same library. - // - // Every channel-based integration we ship today is a 1:1 replacement for an - // OTel performance/tracing integration and produces nothing but spans (those - // only come from `getAutoPerformanceIntegrations()` above), so it's gated on - // span recording. - if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { - const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); - if (diagnosticsChannelInjection) { - const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); - return [...integrations.filter(i => !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; - } - } - return integrations; } /** diff --git a/packages/node/test/sdk/orchestrionIntegrationSwap.test.ts b/packages/node/test/sdk/orchestrionIntegrationSwap.test.ts new file mode 100644 index 000000000000..b5c06732aacb --- /dev/null +++ b/packages/node/test/sdk/orchestrionIntegrationSwap.test.ts @@ -0,0 +1,71 @@ +import type { Integration } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mysqlIntegration } from '../../src/integrations/tracing/mysql'; +import { postgresIntegration } from '../../src/integrations/tracing/postgres'; +import { + _resetDiagnosticsChannelInjectionForTesting, + setDiagnosticsChannelInjectionLoader, +} from '../../src/sdk/diagnosticsChannelInjection'; + +// `experimentalUseDiagnosticsChannelInjection()` registers +// channel-integration factories into a runtime registry; the OTel +// `xxxIntegration()` factories look themselves up there and build the +// channel implementation instead. We exercise that swap by registering fakes +// directly, so this file never imports the orchestrion code (which is exactly +// the property that keeps it tree-shakeable). + +afterEach(() => { + _resetDiagnosticsChannelInjectionForTesting(); + vi.restoreAllMocks(); +}); + +describe('OTel integration factory <-> channel swap', () => { + it('returns the OTel implementation when injection is not opted into', () => { + // No loader registered (the default), so no channel factory is found and + // the factories build their own OTel implementation. + const pg = postgresIntegration(); + const mysql = mysqlIntegration(); + + expect(pg.name).toBe('Postgres'); + expect(typeof pg.setupOnce).toBe('function'); + expect(mysql.name).toBe('Mysql'); + expect(typeof mysql.setupOnce).toBe('function'); + }); + + it('builds the registered channel implementation, forwarding options, when opted in', () => { + const pgChannel: Integration = { name: 'Postgres', setupOnce: vi.fn() }; + const mysqlChannel: Integration = { name: 'Mysql', setupOnce: vi.fn() }; + const pgFactory = vi.fn(() => pgChannel); + const mysqlFactory = vi.fn(() => mysqlChannel); + + setDiagnosticsChannelInjectionLoader(() => ({ + integrationFactories: { Postgres: pgFactory, Mysql: mysqlFactory }, + register: vi.fn(), + detect: vi.fn(), + })); + + const pg = postgresIntegration({ ignoreConnectSpans: true }); + const mysql = mysqlIntegration(); + + // The factory's instance is returned verbatim + expect(pg).toBe(pgChannel); + expect(mysql).toBe(mysqlChannel); + // The user's options reach the channel factory unchanged. + expect(pgFactory).toHaveBeenCalledWith({ ignoreConnectSpans: true }); + }); + + it('only swaps the integrations that have a registered factory', () => { + const pgChannel: Integration = { name: 'Postgres', setupOnce: vi.fn() }; + + setDiagnosticsChannelInjectionLoader(() => ({ + integrationFactories: { Postgres: () => pgChannel }, + register: vi.fn(), + detect: vi.fn(), + })); + + // Postgres has a registered factory; mysql does not, so it stays OTel. + expect(postgresIntegration()).toBe(pgChannel); + expect(mysqlIntegration().name).toBe('Mysql'); + expect(mysqlIntegration()).not.toBe(pgChannel); + }); +}); From 9545bf8afe6c6116979ce8ff08dc908506a2d8dd Mon Sep 17 00:00:00 2001 From: isaacs Date: Sat, 27 Jun 2026 16:32:10 -0700 Subject: [PATCH 5/5] fix(deno): provide options to pg integration --- packages/deno/src/integrations/postgres.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/deno/src/integrations/postgres.ts b/packages/deno/src/integrations/postgres.ts index afb72e827a13..c76ac86f3893 100644 --- a/packages/deno/src/integrations/postgres.ts +++ b/packages/deno/src/integrations/postgres.ts @@ -5,6 +5,11 @@ 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. * @@ -17,8 +22,8 @@ const INTEGRATION_NAME = 'DenoPostgres' as const; * `AsyncLocalStorage` context strategy (so spans nest under the active * span and survive pg's internal callback dispatch) before delegating. */ -const _denoPostgresIntegration = (() => { - const inner = postgresChannelIntegration(); +const _denoPostgresIntegration = ((options?: DenoPostgresIntegrationOptions) => { + const inner = postgresChannelIntegration(options); return extendIntegration(inner, { name: INTEGRATION_NAME, @@ -28,7 +33,9 @@ const _denoPostgresIntegration = (() => { }); }) satisfies IntegrationFn; -export const denoPostgresIntegration = defineIntegration(_denoPostgresIntegration) as () => Integration & { +export const denoPostgresIntegration = defineIntegration(_denoPostgresIntegration) as ( + options?: DenoPostgresIntegrationOptions, +) => Integration & { name: 'DenoPostgres'; setupOnce: () => void; };