Skip to content

Commit ccccc31

Browse files
committed
fix(astro): Serve the managed tunnel under base on Cloudflare Workers
The server's tunnel base path only reached `@sentry/astro`'s own server init. On Workers the client comes from `@sentry/cloudflare`, so the browser posted under `base` and got a 404. The server side of the tunnel options module now goes through core's managed tunnel defaults, and the Worker entry imports it first.
1 parent 8172115 commit ccccc31

9 files changed

Lines changed: 80 additions & 12 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config';
44
import cloudflare from '@astrojs/cloudflare';
55
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
66

7+
// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel
8+
// follows both on Workers too.
9+
const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL;
10+
711
// https://astro.build/config
812
export default defineConfig({
13+
...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }),
914
integrations: [
1015
sentry({
1116
debug: true,

‎dev-packages/e2e-tests/test-applications/astro-6-cf-workers/instrument.server.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ export default defineCloudflareOptions(env => ({
44
dsn: env.E2E_TEST_DSN,
55
environment: 'qa',
66
tracesSampleRate: 1.0,
7-
tunnel: 'http://localhost:3031/', // proxy server
7+
tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
88
debug: true,
99
}));

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"start": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030",
1111
"test:build": "pnpm install && pnpm build",
1212
"test:assert": "TEST_ENV=production playwright test",
13-
"cf-typegen": "wrangler types --include-runtime=false"
13+
"cf-typegen": "wrangler types --include-runtime=false",
14+
"test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
15+
"test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel"
1416
},
1517
"dependencies": {
1618
"@astrojs/cloudflare": "^13.0.2",
@@ -24,5 +26,14 @@
2426
},
2527
"volta": {
2628
"extends": "../../package.json"
29+
},
30+
"sentryTest": {
31+
"variants": [
32+
{
33+
"build-command": "pnpm test:build:tunnel",
34+
"assert-command": "pnpm test:assert:tunnel",
35+
"label": "astro-6-cf-workers (tunnel)"
36+
}
37+
]
2738
}
2839
}

‎dev-packages/e2e-tests/test-applications/astro-6-cf-workers/sentry.client.config.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Sentry.init({
44
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
55
environment: 'qa',
66
tracesSampleRate: 1.0,
7-
tunnel: 'http://localhost:3031/', // proxy server
7+
tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
88
integrations: [
99
Sentry.browserTracingIntegration({
1010
beforeStartSpan: opts => {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect, test } from '@playwright/test';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
3+
4+
test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');
5+
6+
const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/;
7+
8+
test('answers the managed tunnel under the base path, without tunnel spans', async ({ page }) => {
9+
const tunnelServerSpanPromise = waitForStreamedSpan(
10+
'astro-6-cf-workers',
11+
span =>
12+
getSpanOp(span) === 'http.server' &&
13+
/\/docs\/[a-z0-9]{1,7}\/?$/.test(
14+
String(span.attributes['url.path']?.value ?? span.attributes['url.full']?.value ?? span.name),
15+
),
16+
);
17+
const tunnelResponsePromise = page.waitForResponse(
18+
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
19+
);
20+
21+
await page.goto('/docs/');
22+
const tunnelResponse = await tunnelResponsePromise;
23+
24+
expect(tunnelResponse.status()).toBe(200);
25+
expect(tunnelResponse.headers()['x-sentry-tunnel']).toBeDefined();
26+
27+
// Spans flush in order, so a tunnel span would arrive before this anchor.
28+
const anchorSpanPromise = waitForStreamedSpan(
29+
'astro-6-cf-workers',
30+
span => getSpanOp(span) === 'http.server' && span.is_segment && span.name.includes('test-ssr'),
31+
);
32+
await page.request.get('/docs/test-ssr/');
33+
34+
const winner = await Promise.race([
35+
tunnelServerSpanPromise.then(() => 'tunnel' as const),
36+
anchorSpanPromise.then(() => 'anchor' as const),
37+
]);
38+
expect(winner).toBe('anchor');
39+
});

‎packages/astro/src/integration/cloudflare.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function sentryCloudflareNodeWarningPlugin(): VitePlugin {
3939

4040
// The Worker entry module: `@astrojs/cloudflare` v12 builds its own virtual entry, and v13+ builds
4141
// through `@cloudflare/vite-plugin`, whose entry ends in `export default mod.default ?? {};`.
42-
const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry'];
42+
export const WORKER_ENTRY_IDS = ['astrojs-ssr-virtual-entry', 'virtual:cloudflare/worker-entry'];
4343

4444
/**
4545
* A Vite plugin that ensures the Sentry server config is loaded at the

‎packages/astro/src/integration/middleware/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { handleRequest } from '../../server/middleware';
22

3+
// The one `@sentry/astro` entry that resolves to server code on every runtime, so the integration's virtual module
4+
// imports it from here.
5+
export { setManagedTunnelDefaults } from '@sentry/core/server';
6+
37
type MiddlewareNext = () => Promise<Response>;
48
type MiddlewareHandler = (ctx: unknown, next: MiddlewareNext) => Promise<Response> | Response | Promise<void> | void;
59

‎packages/astro/src/integration/tunnel.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { TunnelOptions } from '@sentry/core';
22
import type { AstroConfig } from 'astro';
33
import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel';
4-
import type { VitePlugin } from './cloudflare';
4+
import { WORKER_ENTRY_IDS, type VitePlugin } from './cloudflare';
55

66
const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options';
77
const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`;
@@ -19,14 +19,25 @@ export function getAstroTunnelOptions(config: Pick<AstroConfig, 'base' | 'traili
1919
/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */
2020
export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`;
2121

22-
/** Serves the module that hands the tunnel options to the SDKs. */
22+
/**
23+
* Serves the module that hands the tunnel options to the SDKs. On the server it goes through core's managed tunnel
24+
* defaults, because the server client isn't always `@sentry/astro`'s: on Cloudflare Workers it's `@sentry/cloudflare`'s,
25+
* created per request. The Worker entry imports the module first, so the defaults are set before any client exists.
26+
*/
2327
export function makeTunnelOptionsVitePlugin(tunnelOptions: TunnelOptions): VitePlugin {
2428
return {
2529
name: 'sentry-astro-tunnel-options',
2630
resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null),
27-
load: id =>
28-
id === RESOLVED_TUNNEL_OPTIONS_MODULE
29-
? `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`
30-
: null,
31+
load: (id, options) => {
32+
if (id !== RESOLVED_TUNNEL_OPTIONS_MODULE) {
33+
return null;
34+
}
35+
36+
return options?.ssr
37+
? `import { setManagedTunnelDefaults } from '@sentry/astro/middleware';\nsetManagedTunnelDefaults(${JSON.stringify({ basePath: tunnelOptions.basePath })});`
38+
: `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`;
39+
},
40+
transform: (code, id) =>
41+
WORKER_ENTRY_IDS.some(entryId => id.includes(entryId)) ? TUNNEL_OPTIONS_IMPORT + code : null,
3142
};
3243
}

‎packages/astro/src/server/sdk.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { HTTP_SERVER } from '@sentry/conventions/op';
22
import { applySdkMetadata } from '@sentry/core';
33
import type { NodeClient, NodeOptions } from '@sentry/node';
44
import { init as initNodeSdk } from '@sentry/node';
5-
import { applyAstroTunnelOptions } from '../tunnel';
65

76
/**
87
*
@@ -14,7 +13,6 @@ export function init(options: NodeOptions): NodeClient | undefined {
1413
};
1514

1615
applySdkMetadata(opts, 'astro', ['astro', 'node']);
17-
applyAstroTunnelOptions(opts);
1816

1917
opts.ignoreSpans = [
2018
...(opts.ignoreSpans || []),

0 commit comments

Comments
 (0)