diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/docker-compose.yml b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/docker-compose.yml new file mode 100644 index 000000000000..631fe79549e8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-astro-6-cf-workers-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: docker + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/global-setup.mjs b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/global-setup.mjs new file mode 100644 index 000000000000..9ba25cd71638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/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 worker can connect on the first request. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/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/astro-6-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json index dc0f6bda829b..3e691b0e9565 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json @@ -19,6 +19,7 @@ "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "astro": "^6.0.0", + "mysql": "2.18.1", "wrangler": "^4.72.0" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/playwright.config.mjs index ae58e4ff3ddc..03175396e229 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/playwright.config.mjs @@ -6,8 +6,14 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig({ - startCommand: 'pnpm start', -}); +const config = getPlaywrightConfig( + { + startCommand: 'pnpm start', + }, + { + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + }, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/src/pages/db-mysql.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/src/pages/db-mysql.ts new file mode 100644 index 000000000000..2a2922aca3b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/src/pages/db-mysql.ts @@ -0,0 +1,43 @@ +import mysql from 'mysql'; + +// The `@sentry/astro` orchestrion transform injects the `orchestrion:mysql:query` diagnostics +// channel into the bundled `mysql` package at build time. On Cloudflare Workers the transform also +// registers the matching subscriber factory on the global marker, which `@sentry/cloudflare` reads +// in the `withSentry` wrap — so these queries produce `db` spans with no OTel require-hook, which +// wouldn't work in workerd anyway. +export async function GET() { + // The connection is created inside the handler: workerd forbids I/O in global scope, and mysql + // opens its socket lazily on the first query. Explicit host/port because workerd's default + // resolution differs from Node's. + const connection = mysql.createConnection({ + host: '127.0.0.1', + port: 3306, + user: 'root', + password: 'docker', + }); + + // Swallow connection-level errors so a socket hiccup doesn't become an uncaught exception that + // fails the request unrelated to the spans. + connection.on('error', () => { + // no-op + }); + + try { + // The second query is 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 span across + // that async boundary. + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', err1 => { + if (err1) return reject(err1); + connection.query('SELECT NOW()', err2 => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return new Response(JSON.stringify({ status: 'ok' }), { headers: { 'content-type': 'application/json' } }); + } finally { + connection.end(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/db.test.ts new file mode 100644 index 000000000000..5c4041ea0728 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/db.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('astro-6-cf-workers', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await request.get('/db-mysql'); + expect(res.status()).toBe(200); + + const transactionEvent = await transactionPromise; + const dbSpans = transactionEvent.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.mysql'); + expect(firstQuery!.data?.['db.system']).toBe('mysql'); + expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution'); + expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1'); + 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 ({ request }) => { + const transactionPromise = waitForTransaction('astro-6-cf-workers', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await request.get('/db-mysql'); + expect(res.status()).toBe(200); + + const transactionEvent = await transactionPromise; + const descriptions = transactionEvent.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/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 32593a0d2c32..752c4bf4f4b1 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -174,13 +174,23 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // `mysql`, `ioredis`) get `diagnostics_channel` publishers injected into the SSR bundle at // build time, with no manual plugin setup. The plugin opts out internally when // `buildTimeInstrumentation` is `false`. - // TODO: Cloudflare/workerd needs different wiring — skipped for now. if (sdkEnabled.server && !isCloudflare) { updateConfig({ vite: { plugins: [sentryOrchestrionPlugin({ buildTimeInstrumentation }) as VitePlugin], }, }); + } else if (sdkEnabled.server && isCloudflareWorkers) { + // On Cloudflare Workers, subscribers are wired via a build-time marker the SDK reads at + // runtime (through the `withSentry` wrap added below). Cloudflare Pages is skipped: it gets + // no `withSentry` wrap, so there'd be nothing to read the marker. + updateConfig({ + vite: { + plugins: [ + sentryOrchestrionPlugin({ buildTimeInstrumentation, injectChannelSubscribers: true }) as VitePlugin, + ], + }, + }); } if (isCloudflare) { diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 68ef1dd5e33a..a6d76ee2f97d 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -1,3 +1,4 @@ +import type * as FsModule from 'fs'; import type { AstroConfig, AstroIntegrationLogger } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _getUpdatedSourceMapSettings, sentryAstro } from '../../src/integration'; @@ -12,11 +13,14 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({ // Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in). // Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant. -const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({ - name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', -})); +const orchestrionVite = vi.fn( + (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({ + name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite', + }), +); vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ - sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), + sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => + orchestrionVite(options), })); // The cloudflare adapter path resolves `@sentry/cloudflare` via `createRequire` and calls @@ -30,6 +34,22 @@ vi.mock('module', async requireActual => { }; }); +// `isCloudflarePages()` probes for a wrangler config with `pages_build_output_dir`. By default no +// such file exists (Workers); the Pages test flips `wranglerPagesConfig` to a Pages config. +let wranglerPagesConfig: string | undefined; +vi.mock('fs', async requireActual => { + const actual = await requireActual(); + return { + ...actual, + existsSync: (p: unknown) => + wranglerPagesConfig !== undefined && String(p).endsWith('wrangler.jsonc') ? true : actual.existsSync(p as string), + readFileSync: (p: unknown, ...rest: unknown[]) => + wranglerPagesConfig !== undefined && String(p).endsWith('wrangler.jsonc') + ? wranglerPagesConfig + : (actual.readFileSync as (...args: unknown[]) => string)(p, ...rest), + }; +}); + process.env = { ...process.env, SENTRY_AUTH_TOKEN: 'my-token', @@ -431,7 +451,7 @@ describe('sentryAstro integration', () => { }); }); - it("doesn't add the orchestrion plugin for the cloudflare adapter", async () => { + it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare workers adapter', async () => { const integration = sentryAstro({}); const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig; @@ -445,14 +465,44 @@ describe('sentryAstro integration', () => { config: cloudflareConfig, }); - expect(orchestrionVite).not.toHaveBeenCalled(); - expect(updateConfig).not.toHaveBeenCalledWith({ + // No wrangler config with `pages_build_output_dir` is present, so this resolves as Workers. + expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true })); + expect(updateConfig).toHaveBeenCalledWith({ vite: { plugins: [{ name: 'sentry-orchestrion-vite' }], }, }); }); + it("doesn't add the orchestrion plugin for the cloudflare pages adapter", async () => { + // Simulate a Pages project: a wrangler config containing `pages_build_output_dir`. + wranglerPagesConfig = '{ "pages_build_output_dir": "./dist" }'; + + try { + const integration = sentryAstro({}); + const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig; + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + updateConfig, + injectScript, + config: cloudflareConfig, + }); + + // Pages has no `withSentry` wrap to read the marker, so orchestrion stays off there. + expect(orchestrionVite).not.toHaveBeenCalled(); + expect(updateConfig).not.toHaveBeenCalledWith({ + vite: { + plugins: [{ name: 'sentry-orchestrion-vite' }], + }, + }); + } finally { + wranglerPagesConfig = undefined; + } + }); + it("doesn't warn about deprecated options when `buildTimeInstrumentation` is set", async () => { const integration = sentryAstro({ buildTimeInstrumentation: false });