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,43 @@
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 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 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
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
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,
};
}
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
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);
}
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
72 changes: 72 additions & 0 deletions packages/astro/test/tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { GLOBAL_OBJ } from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { getAstroTunnelOptions, makeTunnelOptionsVitePlugin } from '../src/integration/tunnel';
import { applyAstroTunnelOptions, TUNNEL_OPTIONS_GLOBAL } from '../src/tunnel';

const globalWithOptions = GLOBAL_OBJ as { __sentry_astro_tunnel_options?: unknown };

describe('getAstroTunnelOptions', () => {
it('maps `base` and `trailingSlash: always` onto tunnel options', () => {
expect(getAstroTunnelOptions({ base: '/docs/', trailingSlash: 'always' })).toEqual({
basePath: '/docs',
trailingSlash: true,
});
expect(getAstroTunnelOptions({ base: '/docs', trailingSlash: 'ignore' })).toEqual({ basePath: '/docs' });
});

it('returns nothing for the defaults', () => {
expect(getAstroTunnelOptions({ base: '/', trailingSlash: 'ignore' })).toBeUndefined();
});
});

describe('makeTunnelOptionsVitePlugin', () => {
it('serves a module that sets the tunnel options global', () => {
const plugin = makeTunnelOptionsVitePlugin({ basePath: '/docs' });
const resolved = (plugin.resolveId as unknown as (id: string) => string | null)(
'virtual:@sentry/astro/tunnel-options',
);

expect((plugin.load as unknown as (id: string) => string | null)(resolved!)).toBe(
`globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = {"basePath":"/docs"};`,
);
});
});

describe('applyAstroTunnelOptions', () => {
afterEach(() => {
delete globalWithOptions.__sentry_astro_tunnel_options;
});

it('adds the Astro routing options to the managed tunnel, letting user options win', () => {
globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true };
const fromTrue: Parameters<typeof applyAstroTunnelOptions>[0] = { tunnel: true };
const fromOptions: Parameters<typeof applyAstroTunnelOptions>[0] = { tunnel: { basePath: '/other' } };

applyAstroTunnelOptions(fromTrue);
applyAstroTunnelOptions(fromOptions);

expect(fromTrue.tunnel).toEqual({ basePath: '/docs', trailingSlash: true });
expect(fromOptions.tunnel).toEqual({ basePath: '/other', trailingSlash: true });
});

it('serves a relative tunnel path with the Astro routing options too', () => {
globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs', trailingSlash: true };
const options: Parameters<typeof applyAstroTunnelOptions>[0] = { tunnel: '/monitor' };

applyAstroTunnelOptions(options);

expect(options.tunnel).toEqual({ basePath: '/docs', trailingSlash: true, path: '/monitor' });
});

it('leaves URL tunnels and a disabled tunnel alone', () => {
globalWithOptions.__sentry_astro_tunnel_options = { basePath: '/docs' };
const url: Parameters<typeof applyAstroTunnelOptions>[0] = { tunnel: 'https://example.com/tunnel' };
const off: Parameters<typeof applyAstroTunnelOptions>[0] = {};

applyAstroTunnelOptions(url);
applyAstroTunnelOptions(off);

expect(url.tunnel).toBe('https://example.com/tunnel');
expect(off.tunnel).toBeUndefined();
});
});
Loading