From a32873db8bdc49ee4308b441d7002b31c620524e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 17:32:23 +0200 Subject: [PATCH 1/2] feat(sveltekit)!: Enable orchestrion instrumentation on Cloudflare Wire up orchestrion build-time instrumentation for the SvelteKit Cloudflare adapter, which was previously skipped. On workerd the channel subscribers can't be registered via require-patching, so the plugin injects a build-time marker that @sentry/cloudflare reads at runtime. Ref: getsentry/sentry-javascript#22764 Co-Authored-By: Claude Opus 4.8 --- .../docker-compose.yml | 18 +++++++ .../global-setup.mjs | 14 +++++ .../global-teardown.mjs | 12 +++++ .../sveltekit-cloudflare-pages/package.json | 4 +- .../playwright.config.js | 19 ++++--- .../src/hooks.server.ts | 1 + .../src/routes/db-mysql/+server.ts | 44 ++++++++++++++++ .../start-event-proxy.mjs | 6 +++ .../tests/db.test.ts | 52 +++++++++++++++++++ .../sveltekit/src/vite/sentryVitePlugins.ts | 11 ++-- 10 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/db-mysql/+server.ts create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/docker-compose.yml b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/docker-compose.yml new file mode 100644 index 000000000000..cdc5e8293736 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-sveltekit-cloudflare-pages-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/sveltekit-cloudflare-pages/global-setup.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/global-setup.mjs new file mode 100644 index 000000000000..9ba25cd71638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/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/sveltekit-cloudflare-pages/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/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/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json index f1b6618d6e85..435d8470fb62 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -15,10 +15,12 @@ "test:assert": "pnpm run test:e2e" }, "dependencies": { - "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" + "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz", + "mysql": "2.18.1" }, "devDependencies": { "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", "@sveltejs/adapter-cloudflare": "^5.0.3", "@sveltejs/kit": "2.69.1", "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.js index 18bda456025e..84fc7fb3ec42 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.js @@ -1,10 +1,17 @@ -import { defineConfig } from '@playwright/test'; +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -export default defineConfig({ - webServer: { - command: 'pnpm run build && pnpm run preview', +// `vite build` (where the Sentry SvelteKit plugin's orchestrion transform runs) produces the +// worker; `pnpm preview` (`wrangler pages dev`) serves the built output. `globalSetup` spins up +// the MySQL container the worker connects to. +const config = getPlaywrightConfig( + { + startCommand: 'pnpm preview', port: 4173, }, + { + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + }, +); - testDir: 'tests', -}); +export default config; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts index adb193c0ada8..631e702db53f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts @@ -8,6 +8,7 @@ export const handle = sequence( initCloudflareSentryHandle({ traceLifecycle: 'static', dsn: E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }), sentryHandle(), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/db-mysql/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/db-mysql/+server.ts new file mode 100644 index 000000000000..4cf5d845937b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/db-mysql/+server.ts @@ -0,0 +1,44 @@ +import { json } from '@sveltejs/kit'; +import mysql from 'mysql'; + +// The `@sentry/sveltekit` orchestrion transform injects the `orchestrion:mysql:query` +// diagnostics channel into the bundled `mysql` package at build time. On Cloudflare the +// transform also registers the matching subscriber factory on the global marker, which +// `@sentry/cloudflare` reads in `wrapRequestHandler` — so these queries produce `db` spans +// with no OTel require-hook, which wouldn't work in workerd anyway. +export const GET = async () => { + // 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 json({ status: 'ok' }); + } finally { + connection.end(); + } +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/start-event-proxy.mjs new file mode 100644 index 000000000000..ad68e74306ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'sveltekit-cloudflare-pages', +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts new file mode 100644 index 000000000000..df6c1593bde0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/db.test.ts @@ -0,0 +1,52 @@ +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 ({ baseURL }) => { + // The `orchestrion:mysql:query` channel is injected into the bundled `mysql` package at + // build time by `@sentry/sveltekit`, which — because this app uses the Cloudflare adapter — + // also registers the subscriber factory on the global marker that `@sentry/cloudflare` reads + // in `wrapRequestHandler`. The query below therefore produces a `db` span on the request's + // http.server transaction, with no OTel require-hook (which wouldn't work in workerd). + const transactionPromise = waitForTransaction('sveltekit-cloudflare-pages', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/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 ({ 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('sveltekit-cloudflare-pages', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/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/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 17a79dd06955..171ca5e128c1 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -55,10 +55,13 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} ); } - // TODO: Cloudflare needs different wiring - if (mergedOptions.adapter !== 'cloudflare') { - sentryPlugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation })); - } + sentryPlugins.push( + sentryOrchestrionPlugin({ + buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation, + // On Cloudflare, subscribers are wired via a build-time marker the SDK reads at runtime; + ...(mergedOptions.adapter === 'cloudflare' ? { injectChannelSubscribers: true } : {}), + }), + ); const sentryVitePluginsOptions = generateVitePluginOptions(mergedOptions); From 96effb71467c8bedef5fd6dd063c79fd48415203 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 17:55:16 +0200 Subject: [PATCH 2/2] test(sveltekit): Update orchestrion plugin tests for Cloudflare wiring Co-Authored-By: Claude Opus 4.8 --- .../test/vite/sentrySvelteKitPlugins.test.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index b32fa56cda04..931ab6d8d419 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -19,11 +19,14 @@ vi.mock('fs', async () => { // 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), })); vi.spyOn(console, 'log').mockImplementation(() => { @@ -109,13 +112,19 @@ describe('sentrySvelteKit()', () => { expect(pluginNames).not.toContain('sentry-orchestrion-vite'); }); - it("doesn't add the orchestrion plugin for the cloudflare adapter", async () => { + it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare adapter', async () => { orchestrionVite.mockClear(); const plugins = await getSentrySvelteKitPlugins({ adapter: 'cloudflare' }); - const pluginNames = plugins.map(plugin => plugin.name); - expect(orchestrionVite).not.toHaveBeenCalled(); - expect(pluginNames).not.toContain('sentry-orchestrion-vite'); - expect(pluginNames).not.toContain('sentry-orchestrion-disabled'); + expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true })); + expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-vite'); + }); + + it("doesn't inject channel subscribers for non-cloudflare adapters", async () => { + orchestrionVite.mockClear(); + await getSentrySvelteKitPlugins({ adapter: 'node' }); + expect(orchestrionVite).toHaveBeenCalledWith( + expect.not.objectContaining({ injectChannelSubscribers: expect.anything() }), + ); }); it('passes user-specified vite plugin options to the custom sentry source maps plugin', async () => {