Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
.wrangler
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
services:
db:
image: mysql:8.0
restart: always
container_name: e2e-tests-cloudflare-orchestrion-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
Original file line number Diff line number Diff line change
@@ -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',
});
}
Original file line number Diff line number Diff line change
@@ -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',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "cloudflare-orchestrion-mysql",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"mysql": "2.18.1"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.35.0",
"@playwright/test": "~1.56.0",
"@cloudflare/workers-types": "^4.20260629.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^24.12.4",
"typescript": "^5.5.2",
"vite": "7.3.2",
"wrangler": "^4.61.0",
"ws": "^8.18.3"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` (where the Sentry plugin's orchestrion transform runs) produces
// the worker; `pnpm preview` (`wrangler dev`, following the vite plugin's
// `.wrangler/deploy` redirect to the built output) serves it. `globalSetup`
// spins up the MySQL container the worker connects to.
const config = getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);

export default {
...config,
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface Env {
E2E_TEST_DSN: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as Sentry from '@sentry/cloudflare';
// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime.
import mysql from 'mysql';

// The `@sentry/cloudflare/vite` plugin's orchestrion transform injects the
// `orchestrion:mysql:query` diagnostics channel into the bundled `mysql`
// package at build time. The SDK detects the injection and subscribes to the
// channel, so the queries below produce `db` spans with no OTel require-hook —
// which wouldn't work in workerd anyway.

interface Connection {
query(sql: string, cb: (err: unknown, results?: unknown) => void): void;
end(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;
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
transportOptions: {
bufferSize: 1000,
},
}),
{
async fetch(request: Request): Promise<Response> {
const url = new URL(request.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
// span across that async boundary.
if (url.pathname === '/test-mysql') {
// The connection is created inside the handler: workerd forbids I/O in
// global scope, and mysql opens its socket lazily on the first query.
const connection = (mysql as MysqlModule).createConnection({
host: '127.0.0.1',
port: 3306,
user: 'root',
password: 'password',
});

// 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
});

await new Promise<void>((resolve, reject) => {
connection.query('SELECT 1 + 1 AS solution', (err: unknown) => {
if (err) return reject(err);
connection.query('SELECT NOW()', (err2: unknown) => {
connection.end();
if (err2) return reject(err2);
resolve();
});
});
});

return Response.json({ status: 'ok' });
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-orchestrion-mysql',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 }) => {
// Each incoming request gets a Sentry http.server transaction; the mysql
// queries run inside it, so their db spans attach to it. The
// `orchestrion:mysql:query` channel was injected into the bundled `mysql`
// package at build time by `@sentry/cloudflare/vite`, and the Cloudflare SDK
// subscribes to it once it detects the injection.
const transactionPromise = waitForTransaction('cloudflare-orchestrion-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.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('cloudflare-orchestrion-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()');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
useDiagnosticsChannelInjection: true,
},
}),
Comment on lines +8 to +12

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the only option that would be needed to enable this?

Asking because usually, there's still the Sentry.experimentalUseDiagnosticsChannelInjection(); before init (while we're on v10).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. experimentalUseDiagnosticsChannelInjection wouldn't have an effect right now, because the Cloudflare SDK is using the core client and not the one from the Node SDK.

That would only change if we move this entire snippet over to Cloudflare directly:

...diagnosticsChannelInjection.integrations,

],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-orchestrion-mysql",
"main": "src/index.ts",
"compatibility_date": "2026-06-29",
"compatibility_flags": ["nodejs_compat"],
}
4 changes: 4 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
"types": "./build/types/nodejs_compat/index.d.ts",
"default": "./build/cjs/nodejs_compat/index.js"
}
},
"./vite": {
"types": "./build/types/vite/index.d.ts",
"import": "./build/esm/vite/index.js"
Comment on lines +51 to +52

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you need the cjs export here as well? Other exports have it 👀

Edit: I just noticed the comment above that it's only ESM. But this PR could fix that: #22286

}
},
"typesVersions": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu

export default makeNPMConfigVariants(
makeBaseNPMConfig({
entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts'],
entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts', 'src/vite/index.ts'],
}),
);
25 changes: 25 additions & 0 deletions packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
dedupeIntegration,
functionToStringIntegration,
getIntegrationsToSetup,
GLOBAL_OBJ,
inboundFiltersIntegration,
initAndBind,
linkedErrorsIntegration,
Expand All @@ -21,6 +22,23 @@ import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
import { makeCloudflareTransport } from './transport';
import { defaultStackParser } from './vendor/stacktrace';

/**
* Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite`
* plugin registered on the global marker. The plugin splices a small snippet
* into each instrumented module that `.set`s its factory here (keyed by export
* name), so the marker holds one factory per package actually bundled.
*
* The marker is read directly instead of importing the factories, so a worker
* built without the plugin — where the channels never fire — ships none of this
* code.
* TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default.
*/
function getRegisteredChannelIntegrations(): Integration[] {
const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations;

return registered ? [...registered.values()].map(factory => factory()) : [];
}

/** Get the default integrations for the Cloudflare SDK. */
export function getDefaultIntegrations(options: CloudflareOptions): Integration[] {
// TODO(v11): Drop this transitional gating and let `requestDataIntegration` rely on the resolved
Expand All @@ -44,6 +62,13 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[
httpServerIntegration(),
requestDataIntegration(cookiesEnabled ? undefined : { include: { cookies: false } }),
consoleIntegration(),
// The orchestrion diagnostics-channel subscribers (mysql, pg, …). The
// `@sentry/cloudflare/vite` plugin injects the channels at build time and,
// next to each, a snippet that registers the matching subscriber factory on
// the global marker. Read from there instead of importing them so bundles
// built without the plugin — where the channels would never fire — don't
// ship the code.
...getRegisteredChannelIntegrations(),
];
}

Expand Down
Loading
Loading