Skip to content

Commit fa27ebe

Browse files
chargomeclaude
andauthored
feat(astro)!: Enable orchestrion instrumentation on Cloudflare Workers (#23003)
Wire up orchestrion build-time instrumentation for the Astro Cloudflare Workers adapter, which was previously skipped. Ref: #22764 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 03df3fb commit fa27ebe

9 files changed

Lines changed: 208 additions & 11 deletions

File tree

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-astro-6-cf-workers-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: docker
13+
healthcheck:
14+
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker']
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+
}

dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@sentry/astro": "file:../../packed/sentry-astro-packed.tgz",
2020
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
2121
"astro": "^6.0.0",
22+
"mysql": "2.18.1",
2223
"wrangler": "^4.72.0"
2324
},
2425
"volta": {

dev-packages/e2e-tests/test-applications/astro-6-cf-workers/playwright.config.mjs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ if (!testEnv) {
66
throw new Error('No test env defined');
77
}
88

9-
const config = getPlaywrightConfig({
10-
startCommand: 'pnpm start',
11-
});
9+
const config = getPlaywrightConfig(
10+
{
11+
startCommand: 'pnpm start',
12+
},
13+
{
14+
globalSetup: './global-setup.mjs',
15+
globalTeardown: './global-teardown.mjs',
16+
},
17+
);
1218

1319
export default config;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import mysql from 'mysql';
2+
3+
// The `@sentry/astro` orchestrion transform injects the `orchestrion:mysql:query` diagnostics
4+
// channel into the bundled `mysql` package at build time. On Cloudflare Workers the transform also
5+
// registers the matching subscriber factory on the global marker, which `@sentry/cloudflare` reads
6+
// in the `withSentry` wrap — so these queries produce `db` spans with no OTel require-hook, which
7+
// wouldn't work in workerd anyway.
8+
export async function GET() {
9+
// The connection is created inside the handler: workerd forbids I/O in global scope, and mysql
10+
// opens its socket lazily on the first query. Explicit host/port because workerd's default
11+
// resolution differs from Node's.
12+
const connection = mysql.createConnection({
13+
host: '127.0.0.1',
14+
port: 3306,
15+
user: 'root',
16+
password: 'docker',
17+
});
18+
19+
// Swallow connection-level errors so a socket hiccup doesn't become an uncaught exception that
20+
// fails the request unrelated to the spans.
21+
connection.on('error', () => {
22+
// no-op
23+
});
24+
25+
try {
26+
// The second query is NESTED inside the first's callback. mysql dispatches that callback from
27+
// its socket data handler (a fresh async context), so the nested query's span only lands on this
28+
// request's http.server transaction if the channel subscriber restored the parent span across
29+
// that async boundary.
30+
await new Promise<void>((resolve, reject) => {
31+
connection.query('SELECT 1 + 1 AS solution', err1 => {
32+
if (err1) return reject(err1);
33+
connection.query('SELECT NOW()', err2 => {
34+
if (err2) return reject(err2);
35+
resolve();
36+
});
37+
});
38+
});
39+
return new Response(JSON.stringify({ status: 'ok' }), { headers: { 'content-type': 'application/json' } });
40+
} finally {
41+
connection.end();
42+
}
43+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 ({ request }) => {
5+
const transactionPromise = waitForTransaction('astro-6-cf-workers', transactionEvent => {
6+
return (
7+
transactionEvent.contexts?.trace?.op === 'http.server' &&
8+
(transactionEvent.spans?.some(span => span.op === 'db') ?? false)
9+
);
10+
});
11+
12+
const res = await request.get('/db-mysql');
13+
expect(res.status()).toBe(200);
14+
15+
const transactionEvent = await transactionPromise;
16+
const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db');
17+
18+
const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution');
19+
expect(firstQuery).toBeDefined();
20+
expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql');
21+
expect(firstQuery!.data?.['db.system']).toBe('mysql');
22+
expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution');
23+
expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1');
24+
expect(firstQuery!.data?.['net.peer.port']).toBe(3306);
25+
expect(firstQuery!.data?.['db.user']).toBe('root');
26+
});
27+
28+
test('a nested query lands on the same transaction (async context restored)', async ({ request }) => {
29+
const transactionPromise = waitForTransaction('astro-6-cf-workers', transactionEvent => {
30+
return (
31+
transactionEvent.contexts?.trace?.op === 'http.server' &&
32+
(transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2
33+
);
34+
});
35+
36+
const res = await request.get('/db-mysql');
37+
expect(res.status()).toBe(200);
38+
39+
const transactionEvent = await transactionPromise;
40+
const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description);
41+
expect(descriptions).toContain('SELECT 1 + 1 AS solution');
42+
expect(descriptions).toContain('SELECT NOW()');
43+
});

packages/astro/src/integration/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,23 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
174174
// `mysql`, `ioredis`) get `diagnostics_channel` publishers injected into the SSR bundle at
175175
// build time, with no manual plugin setup. The plugin opts out internally when
176176
// `buildTimeInstrumentation` is `false`.
177-
// TODO: Cloudflare/workerd needs different wiring — skipped for now.
178177
if (sdkEnabled.server && !isCloudflare) {
179178
updateConfig({
180179
vite: {
181180
plugins: [sentryOrchestrionPlugin({ buildTimeInstrumentation }) as VitePlugin],
182181
},
183182
});
183+
} else if (sdkEnabled.server && isCloudflareWorkers) {
184+
// On Cloudflare Workers, subscribers are wired via a build-time marker the SDK reads at
185+
// runtime (through the `withSentry` wrap added below). Cloudflare Pages is skipped: it gets
186+
// no `withSentry` wrap, so there'd be nothing to read the marker.
187+
updateConfig({
188+
vite: {
189+
plugins: [
190+
sentryOrchestrionPlugin({ buildTimeInstrumentation, injectChannelSubscribers: true }) as VitePlugin,
191+
],
192+
},
193+
});
184194
}
185195

186196
if (isCloudflare) {

packages/astro/test/integration/index.test.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type * as FsModule from 'fs';
12
import type { AstroConfig, AstroIntegrationLogger } from 'astro';
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
34
import { _getUpdatedSourceMapSettings, sentryAstro } from '../../src/integration';
@@ -12,11 +13,14 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({
1213

1314
// Stub the orchestrion plugin so these stay pure wiring tests (no apm code transformer pulled in).
1415
// Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant.
15-
const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({
16-
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
17-
}));
16+
const orchestrionVite = vi.fn(
17+
(options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) => ({
18+
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-vite',
19+
}),
20+
);
1821
vi.mock('@sentry/server-utils/orchestrion/vite', () => ({
19-
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options),
22+
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean; injectChannelSubscribers?: boolean }) =>
23+
orchestrionVite(options),
2024
}));
2125

2226
// The cloudflare adapter path resolves `@sentry/cloudflare` via `createRequire` and calls
@@ -30,6 +34,22 @@ vi.mock('module', async requireActual => {
3034
};
3135
});
3236

37+
// `isCloudflarePages()` probes for a wrangler config with `pages_build_output_dir`. By default no
38+
// such file exists (Workers); the Pages test flips `wranglerPagesConfig` to a Pages config.
39+
let wranglerPagesConfig: string | undefined;
40+
vi.mock('fs', async requireActual => {
41+
const actual = await requireActual<typeof FsModule>();
42+
return {
43+
...actual,
44+
existsSync: (p: unknown) =>
45+
wranglerPagesConfig !== undefined && String(p).endsWith('wrangler.jsonc') ? true : actual.existsSync(p as string),
46+
readFileSync: (p: unknown, ...rest: unknown[]) =>
47+
wranglerPagesConfig !== undefined && String(p).endsWith('wrangler.jsonc')
48+
? wranglerPagesConfig
49+
: (actual.readFileSync as (...args: unknown[]) => string)(p, ...rest),
50+
};
51+
});
52+
3353
process.env = {
3454
...process.env,
3555
SENTRY_AUTH_TOKEN: 'my-token',
@@ -431,7 +451,7 @@ describe('sentryAstro integration', () => {
431451
});
432452
});
433453

434-
it("doesn't add the orchestrion plugin for the cloudflare adapter", async () => {
454+
it('adds the orchestrion plugin with channel-subscriber injection for the cloudflare workers adapter', async () => {
435455
const integration = sentryAstro({});
436456

437457
const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig;
@@ -445,14 +465,44 @@ describe('sentryAstro integration', () => {
445465
config: cloudflareConfig,
446466
});
447467

448-
expect(orchestrionVite).not.toHaveBeenCalled();
449-
expect(updateConfig).not.toHaveBeenCalledWith({
468+
// No wrangler config with `pages_build_output_dir` is present, so this resolves as Workers.
469+
expect(orchestrionVite).toHaveBeenCalledWith(expect.objectContaining({ injectChannelSubscribers: true }));
470+
expect(updateConfig).toHaveBeenCalledWith({
450471
vite: {
451472
plugins: [{ name: 'sentry-orchestrion-vite' }],
452473
},
453474
});
454475
});
455476

477+
it("doesn't add the orchestrion plugin for the cloudflare pages adapter", async () => {
478+
// Simulate a Pages project: a wrangler config containing `pages_build_output_dir`.
479+
wranglerPagesConfig = '{ "pages_build_output_dir": "./dist" }';
480+
481+
try {
482+
const integration = sentryAstro({});
483+
const cloudflareConfig = { ...config, adapter: { name: '@astrojs/cloudflare' } } as AstroConfig;
484+
485+
expect(integration.hooks['astro:config:setup']).toBeDefined();
486+
// @ts-expect-error - the hook exists and we only need to pass what we actually use
487+
await integration.hooks['astro:config:setup']({
488+
...baseConfigHookObject,
489+
updateConfig,
490+
injectScript,
491+
config: cloudflareConfig,
492+
});
493+
494+
// Pages has no `withSentry` wrap to read the marker, so orchestrion stays off there.
495+
expect(orchestrionVite).not.toHaveBeenCalled();
496+
expect(updateConfig).not.toHaveBeenCalledWith({
497+
vite: {
498+
plugins: [{ name: 'sentry-orchestrion-vite' }],
499+
},
500+
});
501+
} finally {
502+
wranglerPagesConfig = undefined;
503+
}
504+
});
505+
456506
it("doesn't warn about deprecated options when `buildTimeInstrumentation` is set", async () => {
457507
const integration = sentryAstro({ buildTimeInstrumentation: false });
458508

0 commit comments

Comments
 (0)