Skip to content
Draft
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,
},
}),
],
});
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"],
}
9 changes: 8 additions & 1 deletion 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"
}
},
"typesVersions": {
Expand All @@ -62,7 +66,10 @@
"@opentelemetry/api": "^1.9.1",
"@sentry/core": "10.65.0",
"@sentry/node": "10.65.0",
"@sentry/server-utils": "10.65.0"
"@sentry/server-utils": "10.65.0",
"jsonc-parser": "^3.3.1",
"magic-string": "~0.30.21",
"smol-toml": "^1.7.0"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.x || ^5.x"
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'],
}),
);
46 changes: 46 additions & 0 deletions packages/cloudflare/src/defineCloudflareOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { env as cloudflareEnv } from 'cloudflare:workers';
import type { CloudflareOptions } from './client';

/**
* Define the Sentry options for a Cloudflare Worker in a dedicated module.
*
* This is the recommended way to configure the SDK when using the Vite plugin's
* auto-instrumentation: place an `instrument.server.{ts,js,mjs}` file next to
* the worker entry whose **default export** is the result of this function. The
* plugin picks it up automatically and hands it to `withSentry`.
*
* Unlike Node's `Sentry.init(...)`, the options cannot be applied at module
* load time on Cloudflare: the DSN and other settings typically come from the
* per-request `env`, which only exists inside the handler. Pass a callback to
* read from `env`, or a static object when no `env` access is needed — either
* way you get full type-checking and autocomplete on {@link CloudflareOptions}.
*
* At runtime this is a thin pass-through; it only normalizes a static object
* into a callback so the plugin always imports a `(env) => options` function.
*
* @example
* ```ts
* // src/instrument.server.ts
* import { defineCloudflareOptions } from '@sentry/cloudflare';
*
* export default defineCloudflareOptions((env) => ({
* dsn: env.SENTRY_DSN,
* tracesSampleRate: 1.0,
* }));
* ```
*
* @example
* ```ts
* // Static options — no `env` access needed
* export default defineCloudflareOptions({ tracesSampleRate: 1.0 });
* ```
*/
export function defineCloudflareOptions<Env = typeof cloudflareEnv>(
optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined),
): (env: Env) => CloudflareOptions | undefined {
if (typeof optionsOrCallback === 'function') {
return optionsOrCallback as (env: Env) => CloudflareOptions | undefined;
}

return () => optionsOrCallback;
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
} from '@sentry/core';

export { withSentry } from './withSentry';
export { defineCloudflareOptions } from './defineCloudflareOptions';
export { instrumentDurableObjectWithSentry } from './durableobject';
export { sentryPagesPlugin } from './pages-plugin';

Expand Down
Loading
Loading