Skip to content

Commit d6c0992

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Instrument bundled packages via orchestrion diagnostics channels
Integrate the server-utils orchestrion channel-integration mechanism into the Cloudflare SDK, so bundled npm packages (e.g. `mysql`) are traced without monkey-patching, which workerd doesn't support anyway. - Add the `@sentry/cloudflare/vite` plugin, which runs the orchestrion transform (injecting `diagnostics_channel.tracingChannel` calls) and injects a registration module that puts the channel-subscriber integrations on the global marker. Workers built without the plugin don't ship that code. - `getDefaultIntegrations()` reads the registered integrations from the marker at `init()`, activating only those whose module was actually transformed, and warns (debug only, once per isolate) about modules whose transform failed. - Add a Cloudflare + MySQL e2e test app exercising real `db` spans in workerd. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 95c6874 commit d6c0992

18 files changed

Lines changed: 416 additions & 3 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist
2+
.wrangler
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
db:
3+
image: mysql:8.0
4+
restart: always
5+
container_name: e2e-tests-cloudflare-orchestrion-mysql
6+
# The `mysql` 2.x driver doesn't speak MySQL 8's default
7+
# `caching_sha2_password` auth, so force the legacy plugin.
8+
command: ['--default-authentication-plugin=mysql_native_password']
9+
ports:
10+
- '3306:3306'
11+
environment:
12+
MYSQL_ROOT_PASSWORD: password
13+
healthcheck:
14+
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword']
15+
interval: 2s
16+
timeout: 3s
17+
retries: 30
18+
start_period: 10s
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalSetup() {
8+
// Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in
9+
// docker-compose.yml passes, so the worker can connect on the first request.
10+
execSync('docker compose up -d --wait', {
11+
cwd: __dirname,
12+
stdio: 'inherit',
13+
});
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { execSync } from 'child_process';
2+
import { dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
export default async function globalTeardown() {
8+
execSync('docker compose down --volumes', {
9+
cwd: __dirname,
10+
stdio: 'inherit',
11+
});
12+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "cloudflare-orchestrion-mysql",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev",
8+
"build": "vite build",
9+
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
10+
"test": "playwright test",
11+
"typecheck": "tsc --noEmit",
12+
"test:build": "pnpm install && pnpm build",
13+
"test:assert": "pnpm typecheck && pnpm test"
14+
},
15+
"dependencies": {
16+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
17+
"mysql": "2.18.1"
18+
},
19+
"devDependencies": {
20+
"@cloudflare/vite-plugin": "^1.35.0",
21+
"@playwright/test": "~1.56.0",
22+
"@cloudflare/workers-types": "^4.20260629.0",
23+
"@sentry-internal/test-utils": "link:../../../test-utils",
24+
"@types/node": "^24.12.4",
25+
"typescript": "^5.5.2",
26+
"vite": "7.3.2",
27+
"wrangler": "^4.61.0",
28+
"ws": "^8.18.3"
29+
},
30+
"volta": {
31+
"node": "24.15.0",
32+
"extends": "../../package.json"
33+
}
34+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
// `vite build` (where the Sentry plugin's orchestrion transform runs) produces
4+
// the worker; `pnpm preview` (`wrangler dev`, following the vite plugin's
5+
// `.wrangler/deploy` redirect to the built output) serves it. `globalSetup`
6+
// spins up the MySQL container the worker connects to.
7+
const config = getPlaywrightConfig(
8+
{
9+
startCommand: 'pnpm preview',
10+
port: 8787,
11+
},
12+
{
13+
workers: '100%',
14+
retries: 0,
15+
},
16+
);
17+
18+
export default {
19+
...config,
20+
globalSetup: './global-setup.mjs',
21+
globalTeardown: './global-teardown.mjs',
22+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
interface Env {
2+
E2E_TEST_DSN: string;
3+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime.
3+
import mysql from 'mysql';
4+
5+
// The `@sentry/cloudflare/vite` plugin's orchestrion transform injects the
6+
// `orchestrion:mysql:query` diagnostics channel into the bundled `mysql`
7+
// package at build time. The SDK detects the injection and subscribes to the
8+
// channel, so the queries below produce `db` spans with no OTel require-hook —
9+
// which wouldn't work in workerd anyway.
10+
11+
interface Connection {
12+
query(sql: string, cb: (err: unknown, results?: unknown) => void): void;
13+
end(cb?: (err: unknown) => void): void;
14+
on(event: string, cb: (err: unknown) => void): void;
15+
}
16+
17+
interface MysqlModule {
18+
createConnection(opts: { host: string; port: number; user: string; password: string }): Connection;
19+
}
20+
21+
export default Sentry.withSentry(
22+
(env: Env) => ({
23+
dsn: env.E2E_TEST_DSN,
24+
environment: 'qa',
25+
tunnel: 'http://localhost:3031/',
26+
tracesSampleRate: 1.0,
27+
transportOptions: {
28+
bufferSize: 1000,
29+
},
30+
}),
31+
{
32+
async fetch(request: Request): Promise<Response> {
33+
const url = new URL(request.url);
34+
35+
// Runs two queries, the second NESTED inside the first's callback. mysql
36+
// dispatches that callback from its socket data handler (a fresh async
37+
// context), so the nested query's span only lands on this request's
38+
// http.server transaction if the channel subscriber restored the parent
39+
// span across that async boundary.
40+
if (url.pathname === '/test-mysql') {
41+
// The connection is created inside the handler: workerd forbids I/O in
42+
// global scope, and mysql opens its socket lazily on the first query.
43+
const connection = (mysql as MysqlModule).createConnection({
44+
host: '127.0.0.1',
45+
port: 3306,
46+
user: 'root',
47+
password: 'password',
48+
});
49+
50+
// Swallow connection-level errors so a socket hiccup doesn't become an
51+
// uncaught exception that fails the request unrelated to the spans.
52+
connection.on('error', () => {
53+
// no-op
54+
});
55+
56+
await new Promise<void>((resolve, reject) => {
57+
connection.query('SELECT 1 + 1 AS solution', (err: unknown) => {
58+
if (err) return reject(err);
59+
connection.query('SELECT NOW()', (err2: unknown) => {
60+
connection.end();
61+
if (err2) return reject(err2);
62+
resolve();
63+
});
64+
});
65+
});
66+
67+
return Response.json({ status: 'ok' });
68+
}
69+
70+
return new Response('Not found', { status: 404 });
71+
},
72+
} satisfies ExportedHandler<Env>,
73+
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-orchestrion-mysql',
6+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ baseURL }) => {
5+
// Each incoming request gets a Sentry http.server transaction; the mysql
6+
// queries run inside it, so their db spans attach to it. The
7+
// `orchestrion:mysql:query` channel was injected into the bundled `mysql`
8+
// package at build time by `@sentry/cloudflare/vite`, and the Cloudflare SDK
9+
// subscribes to it once it detects the injection.
10+
const transactionPromise = waitForTransaction('cloudflare-orchestrion-mysql', event => {
11+
return (
12+
event?.contexts?.trace?.op === 'http.server' &&
13+
(event.request?.url ?? '').includes('/test-mysql') &&
14+
(event.spans?.some(span => span.op === 'db') ?? false)
15+
);
16+
});
17+
18+
const res = await fetch(`${baseURL}/test-mysql`);
19+
expect(res.status).toBe(200);
20+
await res.json();
21+
22+
const transaction = await transactionPromise;
23+
const dbSpans = transaction.spans!.filter(span => span.op === 'db');
24+
25+
const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
26+
expect(firstQuery).toBeDefined();
27+
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.orchestrion.mysql');
28+
expect(firstQuery!.data?.['db.system']).toBe('mysql');
29+
expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution');
30+
expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1');
31+
expect(firstQuery!.data?.['net.peer.port']).toBe(3306);
32+
expect(firstQuery!.data?.['db.user']).toBe('root');
33+
});
34+
35+
test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => {
36+
// The second query runs inside the first query's callback — i.e. across
37+
// mysql's async socket-callback dispatch. Both spans appearing on the SAME
38+
// http.server transaction proves the channel subscriber restored the parent
39+
// span across that async boundary (otherwise the nested query would start its
40+
// own trace and never join this transaction).
41+
const transactionPromise = waitForTransaction('cloudflare-orchestrion-mysql', event => {
42+
return (
43+
event?.contexts?.trace?.op === 'http.server' &&
44+
(event.request?.url ?? '').includes('/test-mysql') &&
45+
(event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
46+
);
47+
});
48+
49+
const res = await fetch(`${baseURL}/test-mysql`);
50+
expect(res.status).toBe(200);
51+
await res.json();
52+
53+
const transaction = await transactionPromise;
54+
const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description);
55+
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
56+
expect(descriptions).toContain('SELECT NOW()');
57+
});

0 commit comments

Comments
 (0)