Skip to content

Commit 3dfbedc

Browse files
committed
feat(astro): Serve the managed tunnel from the Sentry middleware
The Sentry middleware runs first and answers tunnel requests. The integration hands Astro's `base` and `trailingSlash: 'always'` to both SDKs, so `tunnel: true` follows Astro's routing without repeating them.
1 parent ab2c887 commit 3dfbedc

14 files changed

Lines changed: 230 additions & 8 deletions

File tree

‎dev-packages/e2e-tests/test-applications/astro-7/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

55
import node from '@astrojs/node';
66

7+
// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel
8+
// follows both.
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-7/package.json‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"test:build": "pnpm install && pnpm build",
1212
"test:assert": "pnpm test:prod && pnpm test:dev",
1313
"test:prod": "TEST_ENV=production playwright test",
14-
"test:dev": "TEST_ENV=development playwright test db"
14+
"test:dev": "TEST_ENV=development playwright test db",
15+
"test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
16+
"test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel"
1517
},
1618
"//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels",
1719
"dependencies": {
@@ -27,6 +29,13 @@
2729
"extends": "../../package.json"
2830
},
2931
"sentryTest": {
30-
"optional": true
32+
"optional": true,
33+
"variants": [
34+
{
35+
"build-command": "pnpm test:build:tunnel",
36+
"assert-command": "pnpm test:assert:tunnel",
37+
"label": "astro-7 (tunnel)"
38+
}
39+
]
3140
}
3241
}

‎dev-packages/e2e-tests/test-applications/astro-7/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 => {

‎dev-packages/e2e-tests/test-applications/astro-7/sentry.server.config.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ 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
debug: true,
99
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineMiddleware } from 'astro:middleware';
2+
3+
// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one.
4+
export const onRequest = defineMiddleware((context, next) => {
5+
if (
6+
import.meta.env.PUBLIC_E2E_TEST_TUNNEL &&
7+
context.request.method === 'POST' &&
8+
context.request.headers.get('x-auth') !== 'ok'
9+
) {
10+
return new Response('blocked by user auth', { status: 401 });
11+
}
12+
13+
return next();
14+
});
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 { 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 before the app middleware', async ({ page }) => {
9+
// The variant's auth middleware rejects every other POST.
10+
expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401);
11+
12+
const tunnelServerSpanPromise = waitForStreamedSpan(
13+
'astro-7',
14+
span =>
15+
getSpanOp(span) === 'http.server' &&
16+
/\/docs\/[a-z0-9]{1,7}\/?$/.test(String(span.attributes['url.path']?.value ?? '')),
17+
);
18+
const tunnelResponsePromise = page.waitForResponse(
19+
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
20+
);
21+
22+
await page.goto('/docs/');
23+
const tunnelResponse = await tunnelResponsePromise;
24+
25+
expect(tunnelResponse.status()).toBe(200);
26+
expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);
27+
28+
// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
29+
// would arrive first.
30+
const anchorSpanPromise = waitForStreamedSpan(
31+
'astro-7',
32+
span =>
33+
getSpanOp(span) === 'http.server' &&
34+
String(span.attributes['url.path']?.value ?? '').startsWith('/docs/test-ssr'),
35+
);
36+
await page.request.get('/docs/test-ssr/');
37+
38+
const winner = await Promise.race([
39+
tunnelServerSpanPromise.then(() => 'tunnel' as const),
40+
anchorSpanPromise.then(() => 'anchor' as const),
41+
]);
42+
expect(winner).toBe('anchor');
43+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { BrowserOptions } from '@sentry/browser';
22
import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser';
33
import type { Client, Integration } from '@sentry/core';
44
import { applySdkMetadata } from '@sentry/core';
5+
import { applyAstroTunnelOptions } from '../tunnel';
56
import { browserTracingIntegration } from './browserTracingIntegration';
67
import { createAstroRouteProvider } from './routeProvider';
78

@@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined {
2324
};
2425

2526
applySdkMetadata(opts, 'astro', ['astro', 'browser']);
27+
applyAstroTunnelOptions(opts);
2628

2729
return initBrowserSdk(opts);
2830
}

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as path from 'path';
88
import type { VitePlugin } from './cloudflare';
99
import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare';
1010
import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets';
11+
import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel';
1112
import type { SentryOptions } from './types';
1213

1314
const PKG_NAME = '@sentry/astro';
@@ -128,15 +129,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
128129
});
129130
}
130131

132+
const tunnelOptions = getAstroTunnelOptions(config);
133+
if (tunnelOptions) {
134+
updateConfig({ vite: { plugins: [makeTunnelOptionsVitePlugin(tunnelOptions)] } });
135+
}
136+
const tunnelOptionsImport = tunnelOptions ? TUNNEL_OPTIONS_IMPORT : '';
137+
131138
if (sdkEnabled.client) {
132139
const pathToClientInit = clientInitPath ? path.resolve(clientInitPath) : findDefaultSdkInitFile('client');
133140

134141
if (pathToClientInit) {
135142
debug && logger.info(`Using ${pathToClientInit} for client init.`);
136-
injectScript('page', buildSdkInitFileImportSnippet(pathToClientInit));
143+
injectScript('page', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToClientInit));
137144
} else {
138145
debug && logger.info('Using default client init.');
139-
injectScript('page', buildClientSnippet(options || {}));
146+
injectScript('page', tunnelOptionsImport + buildClientSnippet(options || {}));
140147
}
141148
}
142149

@@ -178,10 +185,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
178185
// Always inject the server config via `injectScript('page-ssr')`.
179186
// This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire)
180187
// and also serves as the fallback for non-Cloudflare adapters in production.
181-
injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit));
188+
injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit));
182189
} else {
183190
debug && logger.info('Using default server init.');
184-
injectScript('page-ssr', buildServerSnippet(options || {}));
191+
injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {}));
185192
}
186193

187194
if (isCloudflareWorkers && command !== 'dev') {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { TunnelOptions } from '@sentry/core';
2+
import type { AstroConfig } from 'astro';
3+
import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel';
4+
import type { VitePlugin } from './cloudflare';
5+
6+
const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options';
7+
const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`;
8+
9+
/** The tunnel options Astro's routing requires, or `undefined` if the defaults work. */
10+
export function getAstroTunnelOptions(config: Pick<AstroConfig, 'base' | 'trailingSlash'>): TunnelOptions | undefined {
11+
const basePath = config.base?.replace(/\/$/, '');
12+
const trailingSlash = config.trailingSlash === 'always';
13+
14+
return basePath || trailingSlash
15+
? { ...(basePath && { basePath }), ...(trailingSlash && { trailingSlash }) }
16+
: undefined;
17+
}
18+
19+
/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */
20+
export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`;
21+
22+
/** Serves the module that hands the tunnel options to the SDKs. */
23+
export function makeTunnelOptionsVitePlugin(tunnelOptions: TunnelOptions): VitePlugin {
24+
return {
25+
name: 'sentry-astro-tunnel-options',
26+
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+
};
32+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
winterCGRequestToRequestData,
2626
filterCollectedUrl,
2727
filterCollectedUrlQuery,
28+
handleTunnelRequestIfMatched,
2829
} from '@sentry/core';
2930
import { flushIfServerless } from '@sentry/core/server';
3031
import {
@@ -94,6 +95,11 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler =
9495
return next();
9596
}
9697

98+
const tunnelResponse = handleTunnelRequestIfMatched(ctx.request);
99+
if (tunnelResponse) {
100+
return tunnelResponse;
101+
}
102+
97103
const isDynamicPageRequest = checkIsDynamicPageRequest(ctx);
98104

99105
// For static (prerendered) routes, we only want to inject the parametrized route meta tags

0 commit comments

Comments
 (0)