Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';

// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel
// follows both on Workers too.
const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL;

// https://astro.build/config
export default defineConfig({
...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }),
integrations: [
sentry({
debug: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export default defineCloudflareOptions(env => ({
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
tunnel: 'http://localhost:3031/', // proxy server
tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
debug: true,
}));
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"start": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030",
"test:build": "pnpm install && pnpm build",
"test:assert": "TEST_ENV=production playwright test",
"cf-typegen": "wrangler types --include-runtime=false"
"cf-typegen": "wrangler types --include-runtime=false",
"test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
"test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel"
},
"dependencies": {
"@astrojs/cloudflare": "^13.0.2",
Expand All @@ -24,5 +26,14 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build:tunnel",
"assert-command": "pnpm test:assert:tunnel",
"label": "astro-6-cf-workers (tunnel)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Sentry.init({
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
tunnel: 'http://localhost:3031/', // proxy server
tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
integrations: [
Sentry.browserTracingIntegration({
beforeStartSpan: opts => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, watchForTunnelSpan } from '@sentry-internal/test-utils';

test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');

const APP_NAME = 'astro-6-cf-workers';
const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/;

test('answers the managed tunnel under the base path, without tunnel spans', async ({ page }) => {
const raceTunnelSpan = watchForTunnelSpan(
APP_NAME,
span =>
getSpanOp(span) === 'http.server' &&
/\/docs\/[a-z0-9]{1,7}\/?$/.test(
String(span.attributes['url.path']?.value ?? span.attributes['url.full']?.value ?? span.name),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test uses hardcoded span attributes

Low Severity

This was flagged because the PR review rules require test assertions on span ops and attributes to use @sentry/conventions constants. The new tunnel E2E test compares getSpanOp to 'http.server' and reads 'url.path' / 'url.full' as string literals instead of HTTP_SERVER, URL_PATH, and URL_FULL.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit ba30e6a. Configure here.

),
);
const tunnelResponsePromise = page.waitForResponse(
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
);

await page.goto('/docs/');
const tunnelResponse = await tunnelResponsePromise;

expect(tunnelResponse.status()).toBe(200);
expect(tunnelResponse.headers()['x-sentry-tunnel']).toBeDefined();
expect(
await raceTunnelSpan(
span => getSpanOp(span) === 'http.server' && span.is_segment && span.name.includes('test-ssr'),
() => page.request.get('/docs/test-ssr/'),
),
).toBe('anchor');
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import { defineConfig } from 'astro/config';

import node from '@astrojs/node';

// The tunnel variant serves the app under a base path with trailing slashes to cover that the managed tunnel
// follows both.
const tunnelVariant = !!process.env.PUBLIC_E2E_TEST_TUNNEL;

// https://astro.build/config
export default defineConfig({
...(tunnelVariant && { base: '/docs', trailingSlash: 'always' }),
integrations: [
sentry({
debug: true,
Expand Down
13 changes: 11 additions & 2 deletions dev-packages/e2e-tests/test-applications/astro-7/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test db"
"test:dev": "TEST_ENV=development playwright test db",
"test:build:tunnel": "pnpm install && PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
"test:assert:tunnel": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 sh -c 'TEST_ENV=production playwright test tunnel && TEST_ENV=development playwright test tunnel'"
},
"//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels",
"dependencies": {
Expand All @@ -27,6 +29,13 @@
"extends": "../../package.json"
},
"sentryTest": {
"optional": true
"optional": true,
"variants": [
{
"build-command": "pnpm test:build:tunnel",
"assert-command": "pnpm test:assert:tunnel",
"label": "astro-7 (tunnel)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Sentry.init({
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
tunnel: 'http://localhost:3031/', // proxy server
tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
integrations: [
Sentry.browserTracingIntegration({
beforeStartSpan: opts => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ Sentry.init({
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
environment: 'qa',
tracesSampleRate: 1.0,
tunnel: 'http://localhost:3031/', // proxy server
tunnel: import.meta.env.PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
debug: true,
});
14 changes: 14 additions & 0 deletions dev-packages/e2e-tests/test-applications/astro-7/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineMiddleware } from 'astro:middleware';

// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one.
export const onRequest = defineMiddleware((context, next) => {
if (
import.meta.env.PUBLIC_E2E_TEST_TUNNEL &&
context.request.method === 'POST' &&
context.request.headers.get('x-auth') !== 'ok'
) {
return new Response('blocked by user auth', { status: 401 });
}

return next();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils';

test.skip(!process.env.PUBLIC_E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');

const TUNNEL_PATH = /^\/docs\/[a-z0-9]{1,7}\/$/;

test('answers the managed tunnel under the base path before the app middleware', async ({ page }) => {
// The variant's auth middleware rejects every other POST.
expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401);

const raceTunnelSpan = watchForTunnelSpan('astro-7', isServerSpanAt(/\/docs\/[a-z0-9]{1,7}\/?$/));
const tunnelResponsePromise = page.waitForResponse(
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
);

await page.goto('/docs/');

expect((await tunnelResponsePromise).status()).toBe(200);
expect(await raceTunnelSpan(isServerSpanAt(/^\/docs\/test-ssr/), () => page.request.get('/docs/test-ssr/'))).toBe(
'anchor',
);
});
2 changes: 2 additions & 0 deletions packages/astro/src/client/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { BrowserOptions } from '@sentry/browser';
import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser';
import type { Client, Integration } from '@sentry/core';
import { applySdkMetadata } from '@sentry/core';
import { applyAstroTunnelOptions } from '../tunnel';
import { browserTracingIntegration } from './browserTracingIntegration';
import { createAstroRouteProvider } from './routeProvider';

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

applySdkMetadata(opts, 'astro', ['astro', 'browser']);
applyAstroTunnelOptions(opts);

return initBrowserSdk(opts);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/integration/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function sentryCloudflareNodeWarningPlugin(): VitePlugin {

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

/**
* A Vite plugin that ensures the Sentry server config is loaded at the
Expand Down
15 changes: 11 additions & 4 deletions packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as path from 'path';
import type { VitePlugin } from './cloudflare';
import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare';
import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets';
import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin, TUNNEL_OPTIONS_IMPORT } from './tunnel';
import type { SentryOptions } from './types';

const PKG_NAME = '@sentry/astro';
Expand Down Expand Up @@ -128,15 +129,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
});
}

const tunnelOptions = getAstroTunnelOptions(config);
if (tunnelOptions) {
updateConfig({ vite: { plugins: [makeTunnelOptionsVitePlugin(tunnelOptions)] } });
}
const tunnelOptionsImport = tunnelOptions ? TUNNEL_OPTIONS_IMPORT : '';

if (sdkEnabled.client) {
const pathToClientInit = clientInitPath ? path.resolve(clientInitPath) : findDefaultSdkInitFile('client');

if (pathToClientInit) {
debug && logger.info(`Using ${pathToClientInit} for client init.`);
injectScript('page', buildSdkInitFileImportSnippet(pathToClientInit));
injectScript('page', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToClientInit));
} else {
debug && logger.info('Using default client init.');
injectScript('page', buildClientSnippet(options || {}));
injectScript('page', tunnelOptionsImport + buildClientSnippet(options || {}));
}
}

Expand Down Expand Up @@ -182,10 +189,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
// Always inject the server config via `injectScript('page-ssr')`.
// This ensures Sentry.init() runs in dev mode (where the Vite plugin doesn't fire)
// and also serves as the fallback for non-Cloudflare adapters in production.
injectScript('page-ssr', buildSdkInitFileImportSnippet(pathToServerInit));
injectScript('page-ssr', tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit));
} else if (!usesCloudflareVitePlugin) {
debug && logger.info('Using default server init.');
injectScript('page-ssr', buildServerSnippet(options || {}));
injectScript('page-ssr', tunnelOptionsImport + buildServerSnippet(options || {}));
}

if (isCloudflareWorkers && command !== 'dev') {
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/integration/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { handleRequest } from '../../server/middleware';

// The one `@sentry/astro` entry that resolves to server code on every runtime, so the integration's virtual module
// imports it from here.
export { setManagedTunnelDefaults } from '@sentry/core/server';

type MiddlewareNext = () => Promise<Response>;
type MiddlewareHandler = (ctx: unknown, next: MiddlewareNext) => Promise<Response> | Response | Promise<void> | void;

Expand Down
43 changes: 43 additions & 0 deletions packages/astro/src/integration/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { TunnelOptions } from '@sentry/core';
import type { AstroConfig } from 'astro';
import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel';
import { WORKER_ENTRY_IDS, type VitePlugin } from './cloudflare';

const TUNNEL_OPTIONS_MODULE = 'virtual:@sentry/astro/tunnel-options';
const RESOLVED_TUNNEL_OPTIONS_MODULE = `\0${TUNNEL_OPTIONS_MODULE}`;

/** The tunnel options Astro's routing requires, or `undefined` if the defaults work. */
export function getAstroTunnelOptions(config: Pick<AstroConfig, 'base' | 'trailingSlash'>): TunnelOptions | undefined {
const basePath = config.base?.replace(/\/$/, '');
const trailingSlash = config.trailingSlash === 'always';

return basePath || trailingSlash
? { ...(basePath && { basePath }), ...(trailingSlash && { trailingSlash }) }
: undefined;
}

/** Imported first in the injected init scripts, so the options are set before `Sentry.init` runs. */
export const TUNNEL_OPTIONS_IMPORT = `import "${TUNNEL_OPTIONS_MODULE}";\n`;

/**
* Serves the module that hands the tunnel options to the SDKs. On the server it goes through core's managed tunnel
* defaults, because the server client isn't always `@sentry/astro`'s: on Cloudflare Workers it's `@sentry/cloudflare`'s,
* created per request. The Worker entry imports the module first, so the defaults are set before any client exists.
*/
export function makeTunnelOptionsVitePlugin(tunnelOptions: TunnelOptions): VitePlugin {
return {
name: 'sentry-astro-tunnel-options',
resolveId: id => (id === TUNNEL_OPTIONS_MODULE ? RESOLVED_TUNNEL_OPTIONS_MODULE : null),
load: (id, options) => {
if (id !== RESOLVED_TUNNEL_OPTIONS_MODULE) {
return null;
}

return options?.ssr
? `import { setManagedTunnelDefaults } from '@sentry/astro/middleware';\nsetManagedTunnelDefaults(${JSON.stringify({ basePath: tunnelOptions.basePath })});`
: `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`;
},
transform: (code, id) =>
WORKER_ENTRY_IDS.some(entryId => id.includes(entryId)) ? TUNNEL_OPTIONS_IMPORT + code : null,
};
}
7 changes: 6 additions & 1 deletion packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
filterCollectedUrl,
filterCollectedUrlQuery,
} from '@sentry/core';
import { flushIfServerless } from '@sentry/core/server';
import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server';
import {
captureException,
continueTrace,
Expand Down Expand Up @@ -94,6 +94,11 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler =
return next();
}

const tunnelResponse = handleTunnelRequestIfMatched(ctx.request);
if (tunnelResponse) {
return tunnelResponse;
}
Comment thread
cursor[bot] marked this conversation as resolved.

const isDynamicPageRequest = checkIsDynamicPageRequest(ctx);

// For static (prerendered) routes, we only want to inject the parametrized route meta tags
Expand Down
14 changes: 14 additions & 0 deletions packages/astro/src/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Options, TunnelOptions } from '@sentry/core';
import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core';

/** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */
export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options';

/**
* Serves the managed tunnel under the app's base path and with Astro's trailing slash policy, which Astro enforces
* before any middleware runs. Options the user set win.
*/
export function applyAstroTunnelOptions(options: Pick<Options, 'tunnel'>): void {
const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: TunnelOptions })[TUNNEL_OPTIONS_GLOBAL];
options.tunnel = applyTunnelDefaults(options.tunnel, astroTunnelOptions);
}
22 changes: 22 additions & 0 deletions packages/astro/test/server/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } f
import type { Client, Span } from '@sentry/core';

import * as SentryCore from '@sentry/core';
import * as SentryCoreServer from '@sentry/core/server';
import * as SentryNode from '@sentry/node';
import type { APIContext } from 'astro';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -33,6 +34,27 @@ const STATIC_REQUEST_CONTEXT = {
url: new URL('https://myDomain.io/users/'),
};

describe('sentryMiddleware with the managed tunnel', () => {
it('answers tunnel requests without calling the rest of the middleware chain', async () => {
const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client);
const tunnelResponse = new Response('forwarded');
const matcherSpy = vi
.spyOn(SentryCoreServer, 'handleTunnelRequestIfMatched')
.mockReturnValue(Promise.resolve(tunnelResponse));
const next = vi.fn();
const request = new Request('https://myDomain.io/abc123', { method: 'POST' });

const response = await handleRequest()({ ...DYNAMIC_REQUEST_CONTEXT, request } as unknown as APIContext, next);

expect(response).toBe(tunnelResponse);
expect(matcherSpy).toHaveBeenCalledWith(request);
expect(next).not.toHaveBeenCalled();

getClientSpy.mockRestore();
matcherSpy.mockRestore();
});
});

describe('sentryMiddleware', () => {
const startSpanSpy = vi.spyOn(SentryNode, 'startSpan');

Expand Down
Loading
Loading