diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json index 1844e0e7b5cd..6f8a4665e604 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json @@ -11,7 +11,9 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:e2e": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:e2e" + "test:assert": "pnpm test:e2e", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -30,5 +32,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3-cloudflare-workers (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts index 4fc6bc229fa5..ea92ff23f5d3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/app.d.ts @@ -1,6 +1,8 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // `App.Platform` (including `platform.ctx`) is declared by `@sveltejs/adapter-cloudflare`. declare global { + const __E2E_TEST_TUNNEL__: boolean; + namespace App { // interface Error {} // interface Locals {} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts index ba1d3dfa2be0..2c640017c085 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.client.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', dsn: PUBLIC_E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts index 1b8dd4528c36..5fd10e38d8ab 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/src/hooks.server.ts @@ -10,7 +10,7 @@ export const handle = sequence( environment: 'qa', // dynamic sampling bias to keep spans dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, - tunnel: 'http://localhost:3031/', // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }), sentryHandle(), diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts new file mode 100644 index 000000000000..b81ae9c2b575 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/tests/tunnel.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('serves the managed tunnel from the Worker under the base path', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => getSpanOp(span) === 'pageload' && span.is_segment, + ); + const tunnelServerSpanPromise = waitForStreamedSpan( + 'sveltekit-3-cloudflare-workers', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.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('/app/'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + await expect(pageloadSpanPromise).resolves.toBeDefined(); + + // 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( + 'sveltekit-3-cloudflare-workers', + span => + getSpanOp(span) === 'http.server' && + String(span.attributes['url.path']?.value ?? '').startsWith('/app/server-load-error'), + ); + await page.request.get('/app/server-load-error'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js index f83d55aa81e8..4f950627ee7e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/vite.config.js @@ -4,7 +4,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant), + }, plugins: [ sentrySvelteKit({ autoUploadSourceMaps: false, @@ -17,6 +23,7 @@ export default defineConfig({ prerender: { handleHttpError: 'ignore', }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 7bf8da3e4e00..b57949e2b20b 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -12,7 +12,10 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod" + "test:assert": "pnpm test:prod", + "test:build:tunnel": "pnpm install && E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", + "test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=production playwright test tunnel", + "test:assert:tunnel-dev": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3031/1337 PUBLIC_E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development playwright test tunnel" }, "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz" @@ -31,5 +34,19 @@ "volta": { "extends": "../../package.json" }, - "type": "module" + "type": "module", + "sentryTest": { + "variants": [ + { + "label": "sveltekit-3 (tunnel)", + "build-command": "pnpm test:build:tunnel", + "assert-command": "pnpm test:assert:tunnel" + }, + { + "label": "sveltekit-3 (tunnel-dev)", + "build-command": "pnpm install", + "assert-command": "pnpm test:assert:tunnel-dev" + } + ] + } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts index ede601ab93e2..bcf9f139abc1 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/app.d.ts @@ -1,6 +1,8 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { + const __E2E_TEST_TUNNEL__: boolean; + namespace App { // interface Error {} // interface Locals {} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts index 4d4723a527d3..1c9774fda5d2 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/hooks.client.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/sveltekit'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: PUBLIC_E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts index 18c0b65b6db7..c0041a8ffe8e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/instrumentation.server.ts @@ -7,6 +7,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: E2E_TEST_DSN, debug: !!process.env.DEBUG, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: __E2E_TEST_TUNNEL__ ? true : `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts new file mode 100644 index 000000000000..9da2f72ddb26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tunnel.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant'); + +const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/; + +test('sends client events through the managed tunnel under the base path', async ({ page }) => { + const errorEventPromise = waitForError( + 'sveltekit-3', + event => event?.exception?.values?.[0]?.value === 'Click Error', + ); + const tunnelServerSpanPromise = waitForStreamedSpan( + 'sveltekit-3', + span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')), + ); + + // The pageload envelope is the first tunnel request, and it's only sent once the app hydrated. + const tunnelResponsePromise = page.waitForResponse( + response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname), + ); + await page.goto('/app/client-error'); + const tunnelResponse = await tunnelResponsePromise; + + expect(tunnelResponse.status()).toBe(200); + expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin); + + await page.getByText('Throw error').click(); + await expect(errorEventPromise).resolves.toBeDefined(); + + // 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( + 'sveltekit-3', + span => + getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '').startsWith('/app/users'), + ); + await page.request.get('/app/users/123'); + + const winner = await Promise.race([ + tunnelServerSpanPromise.then(() => 'tunnel' as const), + anchorSpanPromise.then(() => 'anchor' as const), + ]); + expect(winner).toBe('anchor'); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js index 3e789da133e5..19109a90b019 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/vite.config.js @@ -4,7 +4,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vite'; +// The tunnel variant serves the app under a base path to cover that the managed tunnel follows it. +const tunnelVariant = !!process.env.E2E_TEST_TUNNEL; + export default defineConfig({ + define: { + __E2E_TEST_TUNNEL__: JSON.stringify(tunnelVariant), + }, plugins: [ sentrySvelteKit({ autoUploadSourceMaps: false, @@ -22,6 +28,7 @@ export default defineConfig({ tracing: { server: true, }, + paths: tunnelVariant ? { base: '/app' } : undefined, }), ], }); diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index a6294ad25977..4c1dc3c2edc6 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { BrowserOptions } from '@sentry/svelte'; import { getDefaultIntegrations as getDefaultSvelteIntegrations, init as initSvelteSdk, WINDOW } from '@sentry/svelte'; +import { applyTunnelBasePath } from '../common/tunnel'; import { browserTracingIntegration as svelteKitBrowserTracingIntegration } from './browserTracingIntegration'; type WindowWithSentryFetchProxy = typeof WINDOW & { @@ -23,6 +24,7 @@ export function init(options: BrowserOptions): Client | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'svelte']); + applyTunnelBasePath(opts); // 1. Switch window.fetch to our fetch proxy we injected earlier const actualFetch = switchToFetchProxy(); diff --git a/packages/sveltekit/src/common/tunnel.ts b/packages/sveltekit/src/common/tunnel.ts new file mode 100644 index 000000000000..a7e6a0ff57e8 --- /dev/null +++ b/packages/sveltekit/src/common/tunnel.ts @@ -0,0 +1,14 @@ +import type { Options } from '@sentry/core'; +import { applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core'; + +/** Set by `sentrySvelteKit()` to the app's `paths.base`. */ +export const TUNNEL_BASE_PATH_GLOBAL = '__sentry_sveltekit_base_path'; + +/** + * Serves the managed tunnel under the app's base path, which SvelteKit requires for every request it handles. + * A `basePath` the user set wins. + */ +export function applyTunnelBasePath(options: Pick): void { + const basePath = (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL]; + options.tunnel = applyTunnelDefaults(options.tunnel, basePath ? { basePath } : undefined); +} diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index aa78ab6da7b8..13bceea99cb0 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -7,6 +7,7 @@ import { getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, + handleTunnelRequestIfMatched, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -291,6 +292,11 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { }; const sentryRequestHandler: Handle = input => { + const tunnelResponse = handleTunnelRequestIfMatched(input.event.request); + if (tunnelResponse) { + return tunnelResponse; + } + const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent; // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index 75fe3fcc9021..8500511815a3 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -1,6 +1,7 @@ import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations, init as initNodeSdk } from '@sentry/node'; +import { applyTunnelBasePath } from '../common/tunnel'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans'; import { httpIntegration } from './integrations/http'; @@ -26,6 +27,7 @@ export function init(options: NodeOptions): NodeClient | undefined { }; applySdkMetadata(opts, 'sveltekit', ['sveltekit', 'node']); + applyTunnelBasePath(opts); return initNodeSdk(opts); } diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index d4bf6ead91ec..b0fe5739d283 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -11,6 +11,7 @@ import { makeGlobalValuesInjectionPlugin } from './injectGlobalValues'; import { createKitConfigResolver } from './kitConfig'; import { makeCustomSentryVitePlugins } from './sourceMaps'; import { getAdapterOutputDir } from './svelteConfig'; +import { makeTunnelBasePathPlugin } from './tunnelBasePath'; import type { CustomSentryVitePluginOptions, SentrySvelteKitPluginOptions } from './types'; const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = { @@ -56,6 +57,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} kitConfigResolver.plugin, makeBrowserTracingVariantResolverPlugin(), makeOpenTelemetryApiResolverPlugin(), + makeTunnelBasePathPlugin(getKitConfig), ]; if (mergedOptions.autoInstrument) { diff --git a/packages/sveltekit/src/vite/tunnelBasePath.ts b/packages/sveltekit/src/vite/tunnelBasePath.ts new file mode 100644 index 000000000000..c8197d8d74a2 --- /dev/null +++ b/packages/sveltekit/src/vite/tunnelBasePath.ts @@ -0,0 +1,50 @@ +import { escapeStringForRegex } from '@sentry/core'; +import MagicString from 'magic-string'; +import type { Plugin } from 'vite'; +import { TUNNEL_BASE_PATH_GLOBAL } from '../common/tunnel'; +import type { ResolvedKitConfig } from './kitConfig'; +import { getHooksFileName } from './svelteConfig'; + +const VIRTUAL_BASE_PATH_FILE = '\0sentry-sveltekit-tunnel-base-path'; + +/** + * Hands the app's `paths.base` to the client and server SDKs so `tunnel: true` works under a base path without + * the user repeating it. It's imported first in the hooks files, so it's set before any `init` they import. + */ +export function makeTunnelBasePathPlugin(getKitConfig: () => Promise): Plugin { + let basePath = ''; + let entryFileRegexp: RegExp | undefined; + + return { + name: 'sentry-sveltekit-tunnel-base-path', + + configResolved: async () => { + const kitConfig = await getKitConfig(); + basePath = kitConfig.paths?.base || ''; + + const hooksFiles = [getHooksFileName(kitConfig, 'server'), getHooksFileName(kitConfig, 'client')] + .map(escapeStringForRegex) + .join('|'); + // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway + entryFileRegexp = new RegExp(`/(?:${hooksFiles}|src/instrumentation\\.server)\\.(?:js|ts|mjs|mts)$`); + }, + + resolveId: id => (id === VIRTUAL_BASE_PATH_FILE ? id : null), + + load: id => + id === VIRTUAL_BASE_PATH_FILE + ? `globalThis[${JSON.stringify(TUNNEL_BASE_PATH_GLOBAL)}] = ${JSON.stringify(basePath)};` + : null, + + transform: (code, id) => { + if (!basePath || !entryFileRegexp?.test(id)) { + return null; + } + + const ms = new MagicString(code); + ms.prepend(`import ${JSON.stringify(VIRTUAL_BASE_PATH_FILE)};\n`); + + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..92e72386d69b 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -6,6 +6,7 @@ import { } from '@sentry/cloudflare'; import { addNonEnumerableProperty } from '@sentry/core'; import type { Handle } from '@sveltejs/kit'; +import { applyTunnelBasePath } from '../common/tunnel'; import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration'; import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans'; import { getCloudflareExecutionContext } from '../server-common/utils'; @@ -31,6 +32,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { ...options, }; + applyTunnelBasePath(opts); setAsyncLocalStorageAsyncContextStrategy(); const handleInitSentry: Handle = ({ event, resolve }) => { diff --git a/packages/sveltekit/test/common/tunnel.test.ts b/packages/sveltekit/test/common/tunnel.test.ts new file mode 100644 index 000000000000..0f1038c81733 --- /dev/null +++ b/packages/sveltekit/test/common/tunnel.test.ts @@ -0,0 +1,57 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyTunnelBasePath, TUNNEL_BASE_PATH_GLOBAL } from '../../src/common/tunnel'; + +const globalWithBasePath = GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string }; + +describe('applyTunnelBasePath', () => { + afterEach(() => { + delete globalWithBasePath.__sentry_sveltekit_base_path; + }); + + it('adds the injected base path to the managed tunnel', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const fromTrue: Parameters[0] = { tunnel: true }; + const fromOptions: Parameters[0] = { tunnel: { allowedDsns: ['dsn'] } }; + + applyTunnelBasePath(fromTrue); + applyTunnelBasePath(fromOptions); + + expect(fromTrue.tunnel).toEqual({ basePath: '/app' }); + expect(fromOptions.tunnel).toEqual({ basePath: '/app', allowedDsns: ['dsn'] }); + }); + + it('keeps a base path the user set', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: { basePath: '/other' } }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/other' }); + }); + + it('serves a relative tunnel path under the base path too', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const options: Parameters[0] = { tunnel: '/monitor' }; + + applyTunnelBasePath(options); + + expect(options.tunnel).toEqual({ basePath: '/app', path: '/monitor' }); + }); + + it('leaves URL tunnels, a disabled tunnel, and apps without a base path alone', () => { + globalWithBasePath[TUNNEL_BASE_PATH_GLOBAL] = '/app'; + const url: Parameters[0] = { tunnel: 'https://example.com/tunnel' }; + const off: Parameters[0] = {}; + applyTunnelBasePath(url); + applyTunnelBasePath(off); + + delete globalWithBasePath.__sentry_sveltekit_base_path; + const noBase: Parameters[0] = { tunnel: true }; + applyTunnelBasePath(noBase); + + expect(url.tunnel).toBe('https://example.com/tunnel'); + expect(off.tunnel).toBeUndefined(); + expect(noBase.tunnel).toBe(true); + }); +}); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 8843e63ed1e6..3d081a2df4a0 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -141,6 +141,44 @@ describe('sentryHandle with span streaming', () => { }); }); +describe('sentryHandle with the managed tunnel', () => { + const DSN = 'https://public@o1.ingest.sentry.io/1337'; + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + setCurrentClient(new NodeClient(getDefaultNodeClientOptions({ dsn: DSN, _managedTunnel: {} }))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('answers tunnel requests without resolving the route', async () => { + const tunnelPath = SentryCore.getTunnelPath(DSN)!; + const request = new Request(`http://localhost:3000${tunnelPath}`, { + method: 'POST', + body: SentryCore.serializeEnvelope(SentryCore.createEnvelope({ dsn: DSN }, [])) as BodyInit, + }); + const mockResolve = vi.fn(); + + const response = await sentryHandle()({ event: mockEvent({ request, route: { id: null } }), resolve: mockResolve }); + + expect(await response.text()).toBe('forwarded'); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('resolves every other request', async () => { + const mockResolve = vi.fn(() => Promise.resolve(new Response('app'))); + + const response = await sentryHandle()({ event: mockEvent(), resolve: mockResolve }); + + expect(await response.text()).toBe('app'); + }); +}); + describe('sentryHandle', () => { describe.each([ // isSync, isError, expectedResponse diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index c78099729998..7cae5e1097c4 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -68,10 +68,10 @@ describe('sentrySvelteKit()', () => { expect(plugins).toBeInstanceOf(Array); // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver - // + 1 auto instrument plugin + // + 1 tunnel base path plugin + 1 auto instrument plugin // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(11); + expect(plugins).toHaveLength(12); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -84,6 +84,8 @@ describe('sentrySvelteKit()', () => { 'sentry-sveltekit-browser-tracing-variant', // OpenTelemetry API resolver: 'sentry-sveltekit-opentelemetry-api', + // tunnel base path plugin: + 'sentry-sveltekit-tunnel-base-path', // auto instrument plugin: 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: @@ -103,7 +105,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -111,9 +113,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[3]; + const instrumentPlugin = plugins[4]; - expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + global values injection + expect(plugins).toHaveLength(8); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + auto instrument + orchestrion + build flag + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -122,7 +124,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(10); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(11); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + tunnel base path + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -234,7 +236,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[3]!; + const plugin = plugins[4]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ diff --git a/packages/sveltekit/test/vite/tunnelBasePath.test.ts b/packages/sveltekit/test/vite/tunnelBasePath.test.ts new file mode 100644 index 000000000000..a2c7d04a7a63 --- /dev/null +++ b/packages/sveltekit/test/vite/tunnelBasePath.test.ts @@ -0,0 +1,44 @@ +import type { Plugin } from 'vite'; +import { describe, expect, it } from 'vitest'; +import type { ResolvedKitConfig } from '../../src/vite/kitConfig'; +import { makeTunnelBasePathPlugin } from '../../src/vite/tunnelBasePath'; + +type Hook = (...args: unknown[]) => unknown; + +async function setup(kitConfig: ResolvedKitConfig): Promise { + const plugin = makeTunnelBasePathPlugin(() => Promise.resolve(kitConfig)); + await (plugin.configResolved as Hook)({}); + + return plugin; +} + +describe('makeTunnelBasePathPlugin', () => { + it('imports the base path first in the hooks and instrumentation files', async () => { + const plugin = await setup({ paths: { base: '/app' } }); + const transform = plugin.transform as Hook; + + for (const id of ['/p/src/hooks.server.ts', '/p/src/hooks.client.js', '/p/src/instrumentation.server.ts']) { + expect(transform('const a = 1;', id)).toEqual({ + code: `import ${JSON.stringify('\0sentry-sveltekit-tunnel-base-path')};\nconst a = 1;`, + map: expect.objectContaining({ mappings: expect.any(String) }), + }); + } + expect(transform('const a = 1;', '/p/src/routes/+page.svelte')).toBeNull(); + expect((plugin.load as Hook)('\0sentry-sveltekit-tunnel-base-path')).toBe( + 'globalThis["__sentry_sveltekit_base_path"] = "/app";', + ); + }); + + it('follows custom hooks file locations', async () => { + const plugin = await setup({ paths: { base: '/app' }, files: { hooks: { server: 'src/server/hooks' } } }); + + expect((plugin.transform as Hook)('', '/p/src/server/hooks.ts')).not.toBeNull(); + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); + + it('does nothing without a base path', async () => { + const plugin = await setup({}); + + expect((plugin.transform as Hook)('', '/p/src/hooks.server.ts')).toBeNull(); + }); +});