Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
40f95ac
feat(sveltekit): Serve the managed tunnel from `sentryHandle` under t…
logaretm Oct 15, 2026
f25fb1c
fix(sveltekit): Keep source maps of the hooks files under a base path
logaretm Oct 16, 2026
973b78d
fix(sveltekit): Serve a relative `tunnel` path under the base path too
logaretm Oct 16, 2026
b520a89
test(sveltekit): Check the managed tunnel under `vite dev` too
logaretm Oct 16, 2026
66e0467
test(sveltekit): Check no span of a tunnel request is sent with Kit t…
logaretm Oct 19, 2026
28f46dc
ref(sveltekit): Import the tunnel handler from @sentry/core/server
logaretm Oct 19, 2026
da4e314
test(sveltekit): Send Content-Length with the tunnel test requests
logaretm Oct 19, 2026
c027b46
test(sveltekit): Race the tunnel span with a shared test-utils helper…
logaretm Oct 19, 2026
1e973de
ref(sveltekit): Let core skip an empty base path, and keep only the t…
logaretm Oct 19, 2026
c511ae9
fix(sveltekit): Keep the base path module when tree-shaking treats mo…
logaretm Oct 19, 2026
4827b43
ref: Import the internal tunnel helpers under their `_INTERNAL_` names
logaretm Oct 22, 2026
c7a9e01
test(sveltekit): Read the tunnel variant flag from declared env vars
logaretm Oct 23, 2026
6435834
ref(sveltekit): Serve the managed tunnel under the base path set when…
logaretm Oct 27, 2026
51ea50e
test(sveltekit): Read the tunnel variant flag from declared env vars …
logaretm Oct 27, 2026
4e229d2
fix(sveltekit): Match every hooks file form SvelteKit accepts, with o…
logaretm Oct 27, 2026
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 @@ -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 PUBLIC_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 PUBLIC_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"
Expand All @@ -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"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ export const variables = defineEnvVars({
// `process.env` at runtime, so a dynamic var would never reach the SDK.
E2E_TEST_DSN: { static: true },
PUBLIC_E2E_TEST_DSN: { public: true, static: true },
// Only the tunnel variant sets these, so a missing value means the tunnel is off.
E2E_TEST_TUNNEL: { static: true, schema: (value: string | undefined) => !!value },
PUBLIC_E2E_TEST_TUNNEL: { public: true, static: true, schema: (value: string | undefined) => !!value },
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { PUBLIC_E2E_TEST_DSN } from '$app/env/public';
import { PUBLIC_E2E_TEST_DSN, PUBLIC_E2E_TEST_TUNNEL } from '$app/env/public';
import * as Sentry from '@sentry/sveltekit';

Sentry.init({
environment: 'qa',
dsn: PUBLIC_E2E_TEST_DSN,
tunnel: 'http://localhost:3031/', // proxy server
tunnel: PUBLIC_E2E_TEST_TUNNEL ? true : 'http://localhost:3031/', // proxy server
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { E2E_TEST_DSN } from '$app/env/private';
import { handleErrorWithSentry, initCloudflareSentryHandle, sentryHandle } from '@sentry/sveltekit';
import { E2E_TEST_DSN, E2E_TEST_TUNNEL } from '$app/env/private';
import {
handleErrorWithSentry,
initCloudflareSentryHandle,
sentryHandle,
serveTunnelIntegration,
} from '@sentry/sveltekit';
import { sequence } from '@sveltejs/kit/hooks';

// not logging anything to console to avoid noise in the test output
Expand All @@ -11,6 +16,7 @@ export const handle = sequence(
dsn: E2E_TEST_DSN,
debug: !!process.env.DEBUG,
tunnel: 'http://localhost:3031/', // proxy server
integrations: E2E_TEST_TUNNEL ? [serveTunnelIntegration()] : [],
tracesSampleRate: 1.0,
}),
sentryHandle(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, isServerSpanAt, waitForStreamedSpan, watchForTunnelSpan } from '@sentry-internal/test-utils';

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

const APP_NAME = 'sveltekit-3-cloudflare-workers';
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(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment);
const raceTunnelSpan = watchForTunnelSpan(APP_NAME, isServerSpanAt(TUNNEL_PATH));
const tunnelResponsePromise = page.waitForResponse(
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
);

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

expect((await tunnelResponsePromise).status()).toBe(200);
await expect(pageloadSpanPromise).resolves.toBeDefined();
expect(
await raceTunnelSpan(isServerSpanAt('/app/server-load-error'), () => page.request.get('/app/server-load-error')),
).toBe('anchor');
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ 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({
plugins: [
sentrySvelteKit({
Expand All @@ -17,6 +20,7 @@ export default defineConfig({
prerender: {
handleHttpError: 'ignore',
},
paths: tunnelVariant ? { base: '/app' } : undefined,
}),
],
});
21 changes: 19 additions & 2 deletions dev-packages/e2e-tests/test-applications/sveltekit-3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 PUBLIC_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 PUBLIC_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 PUBLIC_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"
Expand All @@ -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"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ export const variables = defineEnvVars({
// `process.env` — so the DSN would never reach the server SDK.
E2E_TEST_DSN: { static: true },
PUBLIC_E2E_TEST_DSN: { public: true },
// Only the tunnel variant sets these, so a missing value means the tunnel is off.
E2E_TEST_TUNNEL: { static: true, schema: (value: string | undefined) => !!value },
PUBLIC_E2E_TEST_TUNNEL: { public: true, static: true, schema: (value: string | undefined) => !!value },
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { PUBLIC_E2E_TEST_DSN } from '$app/env/public';
import { PUBLIC_E2E_TEST_DSN, PUBLIC_E2E_TEST_TUNNEL } from '$app/env/public';
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: PUBLIC_E2E_TEST_TUNNEL ? true : `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { E2E_TEST_DSN } from '$app/env/private';
import { E2E_TEST_DSN, E2E_TEST_TUNNEL } from '$app/env/private';
import * as Sentry from '@sentry/sveltekit';

// With SvelteKit 3 native instrumentation enabled (`experimental.instrumentation.server`),
Expand All @@ -8,5 +8,6 @@ Sentry.init({
dsn: E2E_TEST_DSN,
debug: !!process.env.DEBUG,
tunnel: `http://localhost:3031/`, // proxy server
integrations: E2E_TEST_TUNNEL ? [Sentry.serveTunnelIntegration()] : [],
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
import { waitForError, watchForTunnelSpan } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry-internal/test-utils';

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

const APP_NAME = 'sveltekit-3';
const TUNNEL_PATH = /^\/app\/[a-z0-9]{1,7}$/;

// Kit's own `sveltekit.handle.root` span starts with only `http.url`, no `url.path`.
function getSpanPath(span: SerializedStreamedSpan): string {
const url = String(span.attributes['http.url']?.value ?? span.attributes['url.path']?.value ?? '');

return url.startsWith('http') ? new URL(url).pathname : url;
}

test('sends client events through the managed tunnel under the base path, without spans of it', async ({ page }) => {
const errorEventPromise = waitForError(APP_NAME, event => event?.exception?.values?.[0]?.value === 'Click Error');
const raceTunnelSpan = watchForTunnelSpan(APP_NAME, span => TUNNEL_PATH.test(getSpanPath(span)));

// 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');

expect((await tunnelResponsePromise).status()).toBe(200);

await page.getByText('Throw error').click();
await expect(errorEventPromise).resolves.toBeDefined();

expect(
await raceTunnelSpan(
span => getSpanPath(span).endsWith('/app/users/123'),
() => page.request.get('/app/users/123'),
),
).toBe('anchor');
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ 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({
plugins: [
sentrySvelteKit({
Expand All @@ -22,6 +25,7 @@ export default defineConfig({
tracing: {
server: true,
},
paths: tunnelVariant ? { base: '/app' } : undefined,
}),
],
});
1 change: 1 addition & 0 deletions dev-packages/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
getSpanOp,
} from './event-proxy-server';
export type { SerializedStreamedSpan } from '@sentry/core';
export { isServerSpanAt, watchForTunnelSpan } from './tunnel';

export {
findAbsolutePathImports,
Expand Down
37 changes: 37 additions & 0 deletions dev-packages/test-utils/src/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { SerializedStreamedSpan } from '@sentry/core';
import { getSpanOp, waitForStreamedSpan } from './event-proxy-server';

type SpanMatcher = (span: SerializedStreamedSpan) => boolean;

/**
* Matches an `http.server` span whose `url.path` equals or matches `path`.
*/
export function isServerSpanAt(path: string | RegExp): SpanMatcher {
return span => {
const urlPath = String(span.attributes['url.path']?.value ?? '');

return getSpanOp(span) === 'http.server' && (typeof path === 'string' ? urlPath === path : path.test(urlPath));
Comment thread
logaretm marked this conversation as resolved.
};
}

/**
* Starts listening for a span of a tunnel request. Call it before the tunnel traffic, then call the returned function
* with a regular request made after it. Spans flush in order, so it resolves to `'tunnel'` if the tunnel request
* created a span, and to `'anchor'` otherwise, without waiting on a timeout.
*/
export function watchForTunnelSpan(
proxyServerName: string,
isTunnelSpan: SpanMatcher,
): (isAnchorSpan: SpanMatcher, requestAnchor: () => Promise<unknown>) => Promise<'tunnel' | 'anchor'> {
const tunnelSpanPromise = waitForStreamedSpan(proxyServerName, isTunnelSpan);

return async (isAnchorSpan, requestAnchor) => {
const anchorSpanPromise = waitForStreamedSpan(proxyServerName, isAnchorSpan);
await requestAnchor();

return Promise.race([
tunnelSpanPromise.then(() => 'tunnel' as const),
anchorSpanPromise.then(() => 'anchor' as const),
]);
};
}
2 changes: 2 additions & 0 deletions packages/sveltekit/src/client/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions packages/sveltekit/src/common/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Options } from '@sentry/core';
import { _INTERNAL_applyTunnelDefaults as 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';

/**
* Returns the app's `paths.base`, once a module `sentrySvelteKit()` prepends it to has loaded.
*/
export function getTunnelBasePath(): string | undefined {
return (GLOBAL_OBJ as { [TUNNEL_BASE_PATH_GLOBAL]?: string })[TUNNEL_BASE_PATH_GLOBAL];
}

/**
* Sends to 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<Options, 'tunnel'>): void {
options.tunnel = applyTunnelDefaults(options.tunnel, { basePath: getTunnelBasePath() });
}
16 changes: 15 additions & 1 deletion packages/sveltekit/src/server-common/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {
withIsolationScope,
filterCollectedUrl,
} from '@sentry/core';
import { flushIfServerless } from '@sentry/core/server';
import {
flushIfServerless,
handleTunnelRequestIfMatched,
_INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults,
} from '@sentry/core/server';
import { getTunnelBasePath } from '../common/tunnel';
import type { Handle, ResolveOptions } from '@sveltejs/kit';
import { DEBUG_BUILD } from '../common/debug-build';
import { getTracePropagationData, sendErrorToSentry } from './utils';
Expand Down Expand Up @@ -284,13 +289,22 @@ async function instrumentHandle(
* ```
*/
export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle {
// The hooks file creates this after the `sentrySvelteKit()` base path module ran, however the SDK was started
// (`--import`, a custom server), and later per-request clients (Cloudflare) start with it too.
setManagedTunnelDefaults({ basePath: getTunnelBasePath() });

const { handleUnknownRoutes, ...rest } = handlerOptions ?? {};
const options = {
handleUnknownRoutes: handleUnknownRoutes ?? false,
...rest,
};

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)
Expand Down
Loading
Loading