Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
19 changes: 17 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,10 @@
"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 TEST_ENV=production playwright test tunnel",
"test:assert:tunnel-dev": "PUBLIC_E2E_TEST_TUNNEL=1 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 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 +30,18 @@
"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)"
},
{
"label": "astro-7 (tunnel-dev)",
"build-command": "pnpm install",
"assert-command": "pnpm test:assert:tunnel-dev"
}
]
}
}
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,51 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';

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

const DSN = 'http://public@localhost:3031/1337';
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 first request this server sees, so no SSR page has run `Sentry.init` yet.
const coldTunnelResponse = await page.request.post('/docs/qpo967/', {
headers: { 'content-type': 'application/x-sentry-envelope' },
data: `${JSON.stringify({ dsn: DSN })}\n`,
});
expect(coldTunnelResponse.status()).toBe(200);

// The variant's auth middleware rejects every other POST.
expect((await page.request.post('/docs/anything/', { data: 'x' })).status()).toBe(401);

const tunnelServerSpanPromise = waitForStreamedSpan(
'astro-7',
span =>
getSpanOp(span) === 'http.server' &&
/\/docs\/[a-z0-9]{1,7}\/?$/.test(String(span.attributes['url.path']?.value ?? '')),
);
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(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);

// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
// would arrive first.
const anchorSpanPromise = waitForStreamedSpan(
'astro-7',
span =>
getSpanOp(span) === 'http.server' &&
String(span.attributes['url.path']?.value ?? '').startsWith('/docs/test-ssr'),
);
await page.request.get('/docs/test-ssr/');

const winner = await Promise.race([
tunnelServerSpanPromise.then(() => 'tunnel' as const),
anchorSpanPromise.then(() => 'anchor' as const),
]);
expect(winner).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
30 changes: 22 additions & 8 deletions packages/astro/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { createRequire } from 'module';
import * as path from 'path';
import type { VitePlugin } from './cloudflare';
import { sentryCloudflareNodeWarningPlugin, sentryCloudflareVitePlugin } from './cloudflare';
import { makeSentryMiddlewareVitePlugin, SENTRY_MIDDLEWARE_MODULE } from './middleware/virtual';
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 +130,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 @@ -174,18 +182,23 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
}
}

let serverInitSnippet = '';
if (sdkEnabled.server) {
const pathToServerInit = serverInitPath ? path.resolve(serverInitPath) : findDefaultSdkInitFile('server');

if (pathToServerInit) {
debug && logger.info(`Using ${pathToServerInit} for server init.`);
// 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));
serverInitSnippet = tunnelOptionsImport + buildSdkInitFileImportSnippet(pathToServerInit);
} else if (!usesCloudflareVitePlugin) {
debug && logger.info('Using default server init.');
injectScript('page-ssr', buildServerSnippet(options || {}));
serverInitSnippet = tunnelOptionsImport + buildServerSnippet(options || {});
}

// 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.
if (serverInitSnippet) {
injectScript('page-ssr', serverInitSnippet);
}

if (isCloudflareWorkers && command !== 'dev') {
Expand Down Expand Up @@ -232,9 +245,10 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
const shouldAddMiddleware = sdkEnabled.server && autoInstrumentation?.requestHandler !== false;

if (isSSR && shouldAddMiddleware) {
updateConfig({ vite: { plugins: [makeSentryMiddlewareVitePlugin(serverInitSnippet)] } });
addMiddleware({
order: 'pre',
entrypoint: '@sentry/astro/middleware',
entrypoint: SENTRY_MIDDLEWARE_MODULE,
});
}
},
Expand Down
22 changes: 22 additions & 0 deletions packages/astro/src/integration/middleware/virtual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { VitePlugin } from '../cloudflare';

/**
* The middleware entrypoint the integration registers instead of `@sentry/astro/middleware`.
*/
export const SENTRY_MIDDLEWARE_MODULE = 'virtual:@sentry/astro/middleware';
const RESOLVED_SENTRY_MIDDLEWARE_MODULE = `\0${SENTRY_MIDDLEWARE_MODULE}`;

/**
* Serves the Sentry middleware behind the server init. `page-ssr` scripts only run once an SSR page loads, so without
* this a request that renders no page, like a tunnel request on a cold serverless instance, would find no client.
*/
export function makeSentryMiddlewareVitePlugin(serverInitSnippet: string): VitePlugin {
return {
name: 'sentry-astro-middleware',
resolveId: id => (id === SENTRY_MIDDLEWARE_MODULE ? RESOLVED_SENTRY_MIDDLEWARE_MODULE : null),
load: id =>
id === RESOLVED_SENTRY_MIDDLEWARE_MODULE
? `${serverInitSnippet}\nexport { onRequest } from "@sentry/astro/middleware";\n`
: null,
};
}
32 changes: 32 additions & 0 deletions packages/astro/src/integration/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { TunnelOptions } from '@sentry/core';
import type { AstroConfig } from 'astro';
import { TUNNEL_OPTIONS_GLOBAL } from '../tunnel';
import 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. */
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 =>
id === RESOLVED_TUNNEL_OPTIONS_MODULE
? `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`
: null,
};
}
6 changes: 6 additions & 0 deletions packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
winterCGRequestToRequestData,
filterCollectedUrl,
filterCollectedUrlQuery,
handleTunnelRequestIfMatched,
} from '@sentry/core';
import { flushIfServerless } from '@sentry/core/server';
import {
Expand Down Expand Up @@ -94,6 +95,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
2 changes: 2 additions & 0 deletions packages/astro/src/server/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op';
import { applySdkMetadata } from '@sentry/core';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { init as initNodeSdk } from '@sentry/node';
import { applyAstroTunnelOptions } from '../tunnel';

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

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

opts.ignoreSpans = [
...(opts.ignoreSpans || []),
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);
}
25 changes: 24 additions & 1 deletion packages/astro/test/integration/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,10 +680,33 @@ describe('sentryAstro integration', () => {
expect(addMiddleware).toHaveBeenCalledTimes(1);
expect(addMiddleware).toHaveBeenCalledWith({
order: 'pre',
entrypoint: '@sentry/astro/middleware',
entrypoint: 'virtual:@sentry/astro/middleware',
});
});

it('runs the server init before the middleware, so requests that render no page find a client', async () => {
const integration = sentryAstro({});
const updateConfig = vi.fn();
const injectScript = vi.fn();

// @ts-expect-error - the hook exists and we only need to pass what we actually use
await integration.hooks['astro:config:setup']({
// @ts-expect-error - we only need to pass what we actually use
config: { output: 'server' },
addMiddleware: vi.fn(),
updateConfig,
injectScript,
});

const plugin = updateConfig.mock.calls
.flatMap(([config]) => config.vite?.plugins ?? [])
.find(plugin => plugin.name === 'sentry-astro-middleware');
const resolvedId = plugin.resolveId('virtual:@sentry/astro/middleware');
const serverInit = injectScript.mock.calls.find(([stage]) => stage === 'page-ssr')?.[1];

expect(plugin.load(resolvedId)).toBe(`${serverInit}\nexport { onRequest } from "@sentry/astro/middleware";\n`);
});

it.each([{ output: 'static' }, { output: undefined }])(
"doesn't add middleware if in static mode (config %s)",
async (config: any) => {
Expand Down
21 changes: 21 additions & 0 deletions packages/astro/test/server/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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(SentryCore, '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