diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd3b67538538..08c00304f15f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -778,7 +778,7 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -1046,8 +1046,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "hono-4", "bun-bytecode"]'), - matrix.test-application) + contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "hono-4", "bun-bytecode", + "bun-mysql"]'), matrix.test-application) uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' @@ -1058,7 +1058,10 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: contains(matrix.test-application, 'deno') || matrix.test-application == 'hono-4' + 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' uses: denoland/setup-deno@v2.0.4 with: deno-version: ${{ matrix.deno-version || 'v2.8.0' }} @@ -1181,7 +1184,7 @@ jobs: with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Deno - if: contains(matrix.test-application, 'deno') + if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' uses: denoland/setup-deno@v2.0.4 with: deno-version: ${{ matrix.deno-version || 'v2.8.0' }} diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/.gitignore b/dev-packages/e2e-tests/test-applications/bun-mysql/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/build.ts b/dev-packages/e2e-tests/test-applications/bun-mysql/build.ts new file mode 100644 index 000000000000..6fd9f4544712 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/build.ts @@ -0,0 +1,32 @@ +// Builds `src/app.ts` with the orchestrion `bun build` plugin, emitting +// `dist/app.js` for the server to run. The plugin injects the +// `orchestrion:mysql:query` diagnostics channel into the bundled `mysql`. + +// @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 { join } from 'path'; + +void (async () => { + const result = await Bun.build({ + entrypoints: [join(__dirname, 'src/app.ts')], + target: 'bun', + outdir: join(__dirname, 'dist'), + // `@sentry/bun` (and its deps) stay external, so we don't bundle the + // whole SDK/OTel stack. `mysql` is also listed, but the plugin strips + // instrumented packages back out of `external` so they get bundled and + // transformed (channel injection only happens on code that passes through + // the bundler). + external: ['@sentry/bun', 'mysql'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log('BUILD_OK'); +})(); diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/docker-compose.yml b/dev-packages/e2e-tests/test-applications/bun-mysql/docker-compose.yml new file mode 100644 index 000000000000..64a282b72b51 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-bun-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: password + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/global-setup.mjs b/dev-packages/e2e-tests/test-applications/bun-mysql/global-setup.mjs new file mode 100644 index 000000000000..634e34824a2f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/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 MySQL via Docker Compose. `--wait` blocks until the healthcheck + // in docker-compose.yml passes, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/bun-mysql/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/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/bun-mysql/package.json b/dev-packages/e2e-tests/test-applications/bun-mysql/package.json new file mode 100644 index 000000000000..2c9b59032628 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/package.json @@ -0,0 +1,25 @@ +{ + "name": "bun-mysql", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "docker compose up -d --wait && bun run dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && bun run build.ts", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", + "mysql": "2.18.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "bun-types": "^1.2.9" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/bun-mysql/playwright.config.mjs new file mode 100644 index 000000000000..d525dd371bc9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/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/bun-mysql/src/app.ts b/dev-packages/e2e-tests/test-applications/bun-mysql/src/app.ts new file mode 100644 index 000000000000..1deaf29d8fc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/src/app.ts @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/bun'; + +// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime. +import mysql from 'mysql'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1, +}); + +interface Connection { + query(sql: string, cb: (err: unknown) => void): void; + connect(cb: (err: unknown) => void): void; + on(event: string, cb: (err: unknown) => void): void; +} +interface MysqlModule { + createConnection(opts: { host: string; port: number; user: string; password: string }): Connection; +} + +// `mysql` was transformed at build time (by `@sentry/bun/plugin`) to publish +// the `orchestrion:mysql:query` channel. The Bun SDK subscribes to it, so the +// queries below produce db spans without any OTel require-hook. +const connection = (mysql as MysqlModule).createConnection({ + host: process.env.MYSQL_HOST ?? '127.0.0.1', + port: Number(process.env.MYSQL_PORT ?? 3306), + user: 'root', + password: 'password', +}); + +// 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. +connection.on('error', (err: unknown) => { + // eslint-disable-next-line no-console + console.error('mysql connection error', err); +}); + +connection.connect((err: unknown) => { + if (err) { + // eslint-disable-next-line no-console + console.error('mysql connect error', err); + } +}); + +Bun.serve({ + port: 3030, + hostname: '0.0.0.0', + async fetch(req: Request) { + const url = new URL(req.url); + + // Runs two queries, the second NESTED inside the first's callback. mysql + // 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 the channel subscriber restored the parent + // across the async boundary. + if (url.pathname === '/test-mysql') { + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + connection.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/bun-mysql/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/bun-mysql/start-event-proxy.mjs new file mode 100644 index 000000000000..d2411966d5e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'bun-mysql', +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts new file mode 100644 index 000000000000..28f4206f2bef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/tests/mysql.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { + // Each incoming request gets a Sentry http.server transaction; the mysql + // queries run inside it, so their db spans attach to that transaction. The + // channels were injected at build time by `@sentry/bun/plugin`, and the Bun + // SDK subscribes to them by default. + const transactionPromise = waitForTransaction('bun-mysql', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-mysql') && + (event.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/test-mysql`); + 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.mysql'); + expect(firstQuery!.data?.['db.system']).toBe('mysql'); + expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution'); + expect(firstQuery!.data?.['net.peer.port']).toBe(3306); + expect(firstQuery!.data?.['db.user']).toBe('root'); +}); + +test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => { + // The second query runs inside the first query's callback — i.e. across + // mysql's async socket-callback dispatch. Both spans appearing on the SAME + // http.server transaction proves the channel subscriber 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('bun-mysql', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-mysql') && + (event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/test-mysql`); + 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/e2e-tests/test-applications/bun-mysql/tsconfig.json b/dev-packages/e2e-tests/test-applications/bun-mysql/tsconfig.json new file mode 100644 index 000000000000..ef784cd1e0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-mysql/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "build.ts"] +} diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index f817f71ab2c9..6ada072d11db 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -1,9 +1,15 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// Query-span origin depends on which instrumentation is active. Blocks driving the SDK's default +// integrations get the diagnostics-channel origin when the generic orchestrion run is enabled (via +// INJECT_ORCHESTRION), since the OTel `Postgres` integration is then swapped for the channel one. Blocks +// that pass an explicit `postgresIntegration()` (e.g. `ignoreConnectSpans`) keep the OTel origin. +const QUERY_ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.postgres' : 'auto.db.otel.postgres'; + const COMMON_DB_ATTRIBUTES = { 'db.connection_string': { type: 'string', @@ -74,22 +80,28 @@ const COMMON_DB_ATTRIBUTES = { /** * Builds the expected strict shape of a streamed postgres db span. * - * Query spans carry a `db.statement` and the `auto.db.otel.postgres` origin. The `pg.connect` span - * has no `db.statement`, and since the pg instrumentation sets no origin on it, it carries the - * default `manual` origin (written as an attribute on the streamed-span path; the non-streamed/SDK - * path omits the `manual` default). + * Query spans carry a `db.statement` and the query origin (`auto.db.otel.postgres`, or + * `auto.db.orchestrion.postgres` under the generic orchestrion run — see `QUERY_ORIGIN`). The + * `pg.connect` span has no `db.statement`, and since the pg instrumentation sets no origin on it, it + * carries the default `manual` origin (written as an attribute on the streamed-span path; the + * non-streamed/SDK path omits the `manual` default). * * `host` defaults to `localhost`, but the `pg-native` scenarios connect to the IPv4 loopback * (`127.0.0.1`) explicitly, so the reported peer name and connection string reflect that. + * + * `origin` defaults to `QUERY_ORIGIN`; blocks that force the OTel path (explicit `postgresIntegration()`) + * pass `auto.db.otel.postgres` explicitly. */ function expectedDbSpan({ name, statement, host = 'localhost', + origin = QUERY_ORIGIN, }: { name: string; statement?: string; host?: string; + origin?: string; }): unknown { const attributes: Record = { ...COMMON_DB_ATTRIBUTES, @@ -110,7 +122,7 @@ function expectedDbSpan({ }; attributes['sentry.origin'] = { type: 'string', - value: 'auto.db.otel.postgres', + value: origin, }; } else { attributes['sentry.origin'] = { @@ -192,13 +204,17 @@ describe('postgres auto instrumentation (streamed)', () => { expect(dbSpans.find(span => span.name.includes('connect'))).toBeUndefined(); expect(dbSpans.length).toBe(3); + // This block passes an explicit `postgresIntegration({ ignoreConnectSpans: true })`, which + // survives the orchestrion swap, so query spans keep the OTel origin even under INJECT_ORCHESTRION. + const origin = 'auto.db.otel.postgres'; expect(dbSpans).toEqual([ - expectedDbSpan({ name: CREATE_USER_TABLE_STATEMENT, statement: CREATE_USER_TABLE_STATEMENT }), + expectedDbSpan({ name: CREATE_USER_TABLE_STATEMENT, statement: CREATE_USER_TABLE_STATEMENT, origin }), expectedDbSpan({ name: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + origin, }), - expectedDbSpan({ name: 'SELECT * FROM "User"', statement: 'SELECT * FROM "User"' }), + expectedDbSpan({ name: 'SELECT * FROM "User"', statement: 'SELECT * FROM "User"', origin }), ]); }, }) 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 ad890371ca07..a042c88c27ed 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('postgres auto instrumentation', () => { @@ -7,6 +7,12 @@ describe('postgres auto instrumentation', () => { cleanupChildProcesses(); }); + // The query-span origin depends on which instrumentation is active. The blocks below drive the SDK's + // default integrations, so when the generic orchestrion run is enabled (via INJECT_ORCHESTRION) the OTel + // `Postgres` integration is swapped for the diagnostics-channel one, changing the origin. Blocks that pass + // an explicit `postgresIntegration()` (e.g. `ignoreConnectSpans`) keep the OTel origin and don't use this. + const QUERY_ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.postgres' : 'auto.db.otel.postgres'; + describe('default', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', @@ -27,26 +33,26 @@ describe('postgres auto instrumentation', () => { 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), expect.objectContaining({ data: expect.objectContaining({ 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'SELECT * FROM "User"', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT * FROM "User"', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), expect.objectContaining({ data: expect.objectContaining({ @@ -54,26 +60,26 @@ describe('postgres auto instrumentation', () => { 'db.name': 'tests', 'db.statement': 'SELECT * FROM "User" WHERE "email" = $1', 'db.postgresql.plan': 'select-user-by-email', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT * FROM "User" WHERE "email" = $1', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), expect.objectContaining({ data: expect.objectContaining({ 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'SELECT * FROM "does_not_exist_table"', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT * FROM "does_not_exist_table"', op: 'db', status: 'internal_error', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), ]), }; @@ -165,13 +171,13 @@ describe('postgres auto instrumentation', () => { 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'SELECT 1 AS foo', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT 1 AS foo', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), ]), }; @@ -232,13 +238,13 @@ describe('postgres auto instrumentation', () => { 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'SELECT 1 AS connect_then', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT 1 AS connect_then', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), ]), }; @@ -278,13 +284,13 @@ describe('postgres auto instrumentation', () => { 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'SELECT 2 AS parented', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT 2 AS parented', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), ]), }); @@ -316,26 +322,26 @@ describe('postgres auto instrumentation', () => { 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'INSERT INTO "NativeUser" ("email", "name") VALUES ($1, $2)', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'INSERT INTO "NativeUser" ("email", "name") VALUES ($1, $2)', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), expect.objectContaining({ data: expect.objectContaining({ 'db.system': 'postgresql', 'db.name': 'tests', 'db.statement': 'SELECT * FROM "NativeUser"', - 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.origin': QUERY_ORIGIN, 'sentry.op': 'db', }), description: 'SELECT * FROM "NativeUser"', op: 'db', status: 'ok', - origin: 'auto.db.otel.postgres', + origin: QUERY_ORIGIN, }), ]), }; @@ -424,15 +430,23 @@ describe('postgres auto instrumentation', () => { ]), }; - 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(); - }); - }); + 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(); + }); + }, + // This block enables orchestrion itself via its instrument file, so opt out of the generic + // INJECT_ORCHESTRION auto-injection to avoid enabling it twice. + { injectOrchestrion: false }, + ); }); describe('pool', () => { @@ -467,15 +481,22 @@ describe('postgres auto instrumentation', () => { ]), }; - 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(); - }); - }); + 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(); + }); + }, + // Enables orchestrion itself; opt out of the generic INJECT_ORCHESTRION auto-injection. + { injectOrchestrion: false }, + ); }); describe('connect error', () => { @@ -505,6 +526,8 @@ describe('postgres auto instrumentation', () => { await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }, + // Enables orchestrion itself; opt out of the generic INJECT_ORCHESTRION auto-injection. + { injectOrchestrion: false }, ); }); @@ -549,6 +572,8 @@ describe('postgres auto instrumentation', () => { }, ); }, + // Enables orchestrion itself; opt out of the generic INJECT_ORCHESTRION auto-injection. + { injectOrchestrion: false }, ); }); @@ -608,6 +633,8 @@ describe('postgres auto instrumentation', () => { }, ); }, + // Enables orchestrion itself; opt out of the generic INJECT_ORCHESTRION auto-injection. + { injectOrchestrion: false }, ); }); }); diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 213965c80304..ae27a986c352 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -22,12 +22,49 @@ import { onUnhandledRejectionIntegration, processSessionIntegration, } from '@sentry/node'; +import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; +/** + * The orchestrion channel-subscriber integrations, listening on the diagnostics + * channels that `@sentry/bun/plugin` injects at build time. + */ +function getChannelIntegrations(): Integration[] { + return Object.values(channelIntegrations).map(integrationFactory => integrationFactory()); +} + +/** + * The performance integrations for bun: the OTel auto-performance set, but with + * the orchestrion diagnostics-channel subscribers swapped in for their OTel + * equivalents *only* when the orchestrion channels were actually injected (i.e. + * the app was built with `@sentry/bun/plugin`). Without that, the channels + * never fire — and the OTel versions rely on a runtime require-hook bun doesn't + * support — so leave the auto-performance set alone. + */ +function getPerformanceIntegrations(options: Options): Integration[] { + if (!hasSpansEnabled(options)) { + return []; + } + + const autoPerformanceIntegrations = getAutoPerformanceIntegrations(); + if (!isOrchestrionInjected()) { + return autoPerformanceIntegrations; + } + + const channelIntegrationInstances = getChannelIntegrations(); + // The OTel integrations these channel subscribers replace, keyed by the name they share with them. + const replacedOtelIntegrationNames = new Set(channelIntegrationInstances.map(integration => integration.name)); + + return [ + ...autoPerformanceIntegrations.filter(integration => !replacedOtelIntegrationNames.has(integration.name)), + ...channelIntegrationInstances, + ]; +} + /** Get the default integrations for the Bun SDK. */ -export function getDefaultIntegrations(_options: Options): Integration[] { +export function getDefaultIntegrations(options: Options): Integration[] { // We return a copy of the defaultIntegrations here to avoid mutating this return [ // Common @@ -51,7 +88,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { processSessionIntegration(), // Bun Specific bunServerIntegration(), - ...(hasSpansEnabled(_options) ? getAutoPerformanceIntegrations() : []), + ...getPerformanceIntegrations(options), ]; } diff --git a/packages/server-utils/src/integrations/tracing-channel/postgres.ts b/packages/server-utils/src/integrations/tracing-channel/postgres.ts index 2af3cf7b58d1..c9834bcea34e 100644 --- a/packages/server-utils/src/integrations/tracing-channel/postgres.ts +++ b/packages/server-utils/src/integrations/tracing-channel/postgres.ts @@ -7,6 +7,7 @@ import { getActiveSpan, getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, startInactiveSpan, waitForTracingChannelBinding, } from '@sentry/core'; @@ -131,7 +132,9 @@ function subscribeQueryLikeChannel( // replays this scope onto that emitter. data._sentryCallerScope = getCurrentScope(); - return startInactiveSpan(getSpanOptions(data)); + // `kind: CLIENT` mirrors the OTel pg instrumentation, so the emitted + // `otel.kind` matches across the OTel and diagnostics-channel paths. + return startInactiveSpan({ ...getSpanOptions(data), kind: SPAN_KIND.CLIENT }); }, // `connect`/`pool-connect` resolve with a persistent `Client` (itself an // `EventEmitter`), which is NOT a streamed result. Deferring their span diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 60b6070740ba..9dfa88bf427d 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -6,6 +6,20 @@ declare global { var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; } +/** + * Whether orchestrion has injected the diagnostics channels into this process, + * either by the runtime `--import` hook / init-time registration (`runtime`) + * or a bundler plugin (`bundler`). Both injectors set a flag on the + * `globalThis.__SENTRY_ORCHESTRION__` marker. + * + * Use this to avoid wiring up channel-subscriber integrations when nothing + * will ever publish to those channels. + */ +export function isOrchestrionInjected(): boolean { + const marker = globalThis.__SENTRY_ORCHESTRION__; + return !!(marker?.runtime || marker?.bundler); +} + /** * Verifies that the diagnostics channels have been injected either by the * runtime `--import` hook (or init-time registration), a bundler plugin, or @@ -30,7 +44,7 @@ export function detectOrchestrionSetup(): void { DEBUG_BUILD && debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); - if (!runtime && !bundler) { + if (!isOrchestrionInjected()) { DEBUG_BUILD && debug.warn( '[Sentry] No diagnostics-channel injection detected. Channel-based integrations ' + diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index ecde9e3386ac..51369f3a319c 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -2,7 +2,7 @@ import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/l import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; -export { detectOrchestrionSetup } from './detect'; +export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; export { lruMemoizerChannelIntegration, mysqlChannelIntegration, postgresChannelIntegration }; /** diff --git a/yarn.lock b/yarn.lock index d511cf92fa6f..19432f5fdbea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16865,9 +16865,9 @@ fast-text-encoding@^1.0.0: integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== fast-uri@^3.0.0, fast-uri@^3.0.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" - integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.3.tgz#f695a40f006aba505631573a0021ddb21194ad11" + integrity sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg== fast-xml-builder@^1.1.7: version "1.2.0" @@ -16895,9 +16895,9 @@ fast-xml-parser@^4.4.1: strnum "^1.0.5" fastify@^5.7.0: - version "5.8.5" - resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.8.5.tgz#c452224295e0ca550bcd0efc3f7d3e90e9c11955" - integrity sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q== + version "5.9.0" + resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.9.0.tgz#067a1d175ff3a09228e3e76ba6f639408c3fa0b1" + integrity sha512-VMS5lE0zj+MZlJpQa3Qv5iGjfun0H2N7VRgoBwpcTNQ2bdIQpv7fDpb+HGteGbicBsGkzGS+X+hdx9mmrfWuHQ== dependencies: "@fastify/ajv-compiler" "^4.0.5" "@fastify/error" "^4.0.0" @@ -16906,7 +16906,7 @@ fastify@^5.7.0: abstract-logging "^2.0.1" avvio "^9.0.0" fast-json-stringify "^6.0.0" - find-my-way "^9.0.0" + find-my-way "^9.6.0" light-my-request "^6.0.0" pino "^9.14.0 || ^10.1.0" process-warning "^5.0.0" @@ -17111,7 +17111,7 @@ find-my-way-ts@^0.1.6: resolved "https://registry.yarnpkg.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz#37f7b8433d0f61e7fe7290772240b0c133b0ebf2" integrity sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== -find-my-way@^9.0.0: +find-my-way@^9.6.0: version "9.6.0" resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-9.6.0.tgz#d8e78d98d02ba749c86526edaca780c094073313" integrity sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==