Skip to content

Commit 75a299e

Browse files
committed
feat: Accept tunnel: true in init to enable the managed tunnel
Setting `tunnel: true` (or `{ allowedDsns, basePath, trailingSlash }`) in both the browser and the server `init` enables the managed tunnel. The browser resolves it to the DSN-derived same-origin path and sends envelopes as `application/x-sentry-envelope`, because SvelteKit and Astro CSRF checks reject a same-origin `text/plain` POST. Server SDKs keep their own envelopes going directly to Sentry and set `_managedTunnel` for their request handlers.
1 parent c544ac0 commit 75a299e

11 files changed

Lines changed: 195 additions & 8 deletions

File tree

‎packages/aws-serverless/src/init.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
7676
// 2. Environment variable SENTRY_LAYER_EXTENSION (if set)
7777
// 3. Default logic based on sdkSource, tunnel, and proxy settings
7878
const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true });
79-
const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere;
79+
const defaultUseLayerExtension =
80+
sdkSource === 'aws-lambda-layer' && typeof options.tunnel !== 'string' && !proxyWouldInterfere;
8081
const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension;
8182

8283
const opts = {
@@ -88,8 +89,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
8889
if (opts.useLayerExtension) {
8990
if (sdkSource !== 'aws-lambda-layer') {
9091
DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.');
91-
} else if (opts.tunnel || proxyWouldInterfere) {
92-
if (opts.tunnel) {
92+
} else if (typeof opts.tunnel === 'string' || proxyWouldInterfere) {
93+
if (typeof opts.tunnel === 'string') {
9394
DEBUG_BUILD &&
9495
debug.warn(
9596
`Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`,

‎packages/browser/src/sdk.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { defaultStackParser } from './stack-parsers';
2424
import { makeFetchTransport } from './transports/fetch';
2525
import { normalizeStringifyValue } from './normalizeStringifyValue';
2626
import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension';
27+
import { resolveBrowserTunnelOption } from './utils/managedTunnel';
2728

2829
/** Get the default integrations for the browser SDK. */
2930
export function getDefaultIntegrations(_options: Options): Integration[] {
@@ -117,6 +118,7 @@ export function init(options: BrowserOptions = {}): Client | undefined {
117118

118119
const clientOptions: BrowserClientOptions = {
119120
...options,
121+
...resolveBrowserTunnelOption(options),
120122
enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled,
121123
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
122124
integrations,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { TunnelOptions } from '@sentry/core';
2+
import { debug, getTunnelPath } from '@sentry/core';
3+
import { DEBUG_BUILD } from '../debug-build';
4+
import type { BrowserOptions } from '../client';
5+
6+
/**
7+
* Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin
8+
* path the server SDK derives from the same DSN.
9+
*/
10+
export function resolveBrowserTunnelOption(
11+
options: BrowserOptions,
12+
): Pick<BrowserOptions, 'transportOptions'> & { tunnel: string | undefined } {
13+
const { tunnel, dsn, transportOptions } = options;
14+
if (typeof tunnel === 'string' || !tunnel) {
15+
return { tunnel, transportOptions };
16+
}
17+
18+
const tunnelOptions: TunnelOptions = tunnel === true ? {} : tunnel;
19+
const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn));
20+
if (!tunnelPath) {
21+
return { tunnel: undefined, transportOptions };
22+
}
23+
24+
const basePath = tunnelOptions.basePath?.replace(/\/$/, '') ?? '';
25+
const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`;
26+
27+
DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`);
28+
29+
return {
30+
tunnel: resolvedTunnel,
31+
// The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no
32+
// preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission.
33+
transportOptions: {
34+
...transportOptions,
35+
headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers },
36+
},
37+
};
38+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { EventEnvelope, EventItem } from '@sentry/core';
2+
import { createEnvelope, getTunnelPath, isTunnelRequest } from '@sentry/core';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
import { getClient, getCurrentScope, init } from '../../src';
5+
import { makeFetchTransport } from '../../src/transports/fetch';
6+
import { resolveBrowserTunnelOption } from '../../src/utils/managedTunnel';
7+
8+
const DSN = 'https://public@o123.ingest.sentry.io/1337';
9+
10+
describe('resolveBrowserTunnelOption', () => {
11+
it('resolves `tunnel: true` to the path the server derives from the same DSN', () => {
12+
const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: true });
13+
14+
expect(tunnel).toBe(getTunnelPath(DSN));
15+
expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN] })).toBe(true);
16+
});
17+
18+
it('prefixes the base path and appends a trailing slash when asked', () => {
19+
const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs/', trailingSlash: true } });
20+
21+
expect(tunnel).toBe(`/docs${getTunnelPath(DSN)}/`);
22+
expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN], basePath: '/docs' })).toBe(true);
23+
});
24+
25+
it('uses a fixed path instead of the DSN-derived one', () => {
26+
const { tunnel, transportOptions } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } });
27+
28+
expect(tunnel).toBe('/monitor');
29+
expect(transportOptions?.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' });
30+
});
31+
32+
it('keeps a URL tunnel and its transport options untouched', () => {
33+
const transportOptions = { headers: { 'x-custom': '1' } };
34+
35+
expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel', transportOptions })).toEqual({
36+
tunnel: '/my-tunnel',
37+
transportOptions,
38+
});
39+
});
40+
41+
it('turns the tunnel off without a valid DSN', () => {
42+
expect(resolveBrowserTunnelOption({ tunnel: true }).tunnel).toBeUndefined();
43+
expect(resolveBrowserTunnelOption({ dsn: 'not-a-dsn', tunnel: true }).tunnel).toBeUndefined();
44+
});
45+
46+
it('sends envelopes as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => {
47+
const { tunnel, transportOptions } = resolveBrowserTunnelOption({
48+
dsn: DSN,
49+
tunnel: true,
50+
transportOptions: { headers: { 'x-custom': '1' } },
51+
});
52+
const nativeFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
53+
54+
const transport = makeFetchTransport(
55+
{ ...transportOptions, url: tunnel!, recordDroppedEvent: () => undefined },
56+
nativeFetch,
57+
);
58+
await transport.send(
59+
createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
60+
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
61+
]),
62+
);
63+
64+
expect(nativeFetch).toHaveBeenCalledWith(
65+
getTunnelPath(DSN),
66+
expect.objectContaining({
67+
headers: { 'Content-Type': 'application/x-sentry-envelope', 'x-custom': '1' },
68+
}),
69+
);
70+
});
71+
72+
it('lets a user-provided Content-Type header win', () => {
73+
const { transportOptions } = resolveBrowserTunnelOption({
74+
dsn: DSN,
75+
tunnel: true,
76+
transportOptions: { headers: { 'Content-Type': 'text/plain' } },
77+
});
78+
79+
expect(transportOptions?.headers).toEqual({ 'Content-Type': 'text/plain' });
80+
});
81+
});
82+
83+
describe('init with `tunnel: true`', () => {
84+
afterEach(() => {
85+
getCurrentScope().setClient(undefined);
86+
});
87+
88+
it('gives the client a resolved tunnel URL', () => {
89+
init({ dsn: DSN, tunnel: true, defaultIntegrations: false });
90+
91+
expect(getClient()?.getOptions().tunnel).toBe(getTunnelPath(DSN));
92+
});
93+
});

‎packages/cloudflare/src/baseSdk.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { Integration } from '@sentry/core';
2-
import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core';
2+
import {
3+
_INTERNAL_isClientClosed,
4+
debug,
5+
getCurrentScope,
6+
resolveServerTunnelOption,
7+
setCurrentClient,
8+
} from '@sentry/core';
39
import {
410
consoleIntegration,
511
conversationIdIntegration,
@@ -120,6 +126,7 @@ export function initWithDefaultIntegrations(
120126

121127
const clientOptions: CloudflareClientOptions = {
122128
...options,
129+
...resolveServerTunnelOption(options.tunnel),
123130
cacheClient: cacheEnabled,
124131
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
125132
integrations: getIntegrationsToSetup(options),

‎packages/cloudflare/test/sdk.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ describe('init', () => {
2323
expect(client).toBeInstanceOf(CloudflareClient);
2424
});
2525

26+
test('enables the managed tunnel for `tunnel: { allowedDsns }`', () => {
27+
const browserDsn = 'https://browser@o1.ingest.sentry.io/42';
28+
init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: { allowedDsns: [browserDsn] } });
29+
30+
const options = getClient()?.getOptions();
31+
expect(options?.tunnel).toBeUndefined();
32+
expect(options?._managedTunnel).toEqual({ allowedDsns: [browserDsn] });
33+
});
34+
2635
test('installs SpanStreaming integration by default', () => {
2736
init({
2837
dsn: 'https://public@dsn.ingest.sentry.io/1337',

‎packages/core/src/types/options.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
374374
tunnel?: string;
375375

376376
/**
377-
* Set by server SDKs when the managed tunnel is enabled. Server request handlers check it to decide whether to
377+
* Set by server SDKs when `init` was called with `tunnel: true` or {@link TunnelOptions}. Server request handlers check it to decide whether to
378378
* serve the managed tunnel.
379379
*
380380
* @internal This option is not part of the public API and is subject to change at any time.
@@ -674,8 +674,21 @@ export interface TunnelOptions {
674674
/** Base configuration options for every SDK. */
675675
export interface CoreOptions<TO extends BaseTransportOptions = BaseTransportOptions> extends Omit<
676676
Partial<ClientOptions<TO>>,
677-
'integrations' | 'transport' | 'stackParser'
677+
'integrations' | 'transport' | 'stackParser' | 'tunnel'
678678
> {
679+
/**
680+
* Where to send envelopes instead of the Sentry ingest URL.
681+
*
682+
* - A URL sends envelopes to your own tunnel endpoint. On the server, a relative path such as `/monitor` instead
683+
* serves the managed tunnel at that path, so `tunnel: '/monitor'` on both sides gives a fixed path.
684+
* - `true` (or {@link TunnelOptions}) enables the managed tunnel. Set it in both the browser and the server `init`:
685+
* the browser sends envelopes to a same-origin path derived from its DSN, and the server SDK serves that path and
686+
* forwards the envelopes to Sentry. This keeps ad-blockers from dropping events.
687+
*
688+
* @default undefined
689+
*/
690+
tunnel?: string | true | TunnelOptions;
691+
679692
/**
680693
* If this is set to false, default integrations will not be added, otherwise this will internally be set to the
681694
* recommended default integrations.

‎packages/deno/src/sdk.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
initAndBind,
1313
linkedErrorsIntegration,
1414
requestDataIntegration,
15+
resolveServerTunnelOption,
1516
stackParserFromStackParserOptions,
1617
} from '@sentry/core';
1718
import { getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils';
@@ -110,6 +111,7 @@ export function init(options: DenoOptions = {}): Client {
110111

111112
const clientOptions: ServerRuntimeClientOptions = {
112113
...options,
114+
...resolveServerTunnelOption(options.tunnel),
113115
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
114116
integrations: getIntegrationsToSetup({ integrations: options.integrations, defaultIntegrations }),
115117
transport: options.transport || makeFetchTransport,

‎packages/node/src/sdk/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Integration, Options } from '@sentry/core';
22
import {
3-
_INTERNAL_warnIfClientIsActive,
43
applySdkMetadata,
54
consoleSandbox,
65
conversationIdIntegration,
@@ -12,9 +11,11 @@ import {
1211
getCurrentScope,
1312
getIntegrationsToSetup,
1413
hasSpansEnabled,
14+
_INTERNAL_warnIfClientIsActive,
1515
linkedErrorsIntegration,
1616
propagationContextFromHeaders,
1717
requestDataIntegration,
18+
resolveServerTunnelOption,
1819
stackParserFromStackParserOptions,
1920
} from '@sentry/core';
2021
import { isMainThread, parentPort } from 'node:worker_threads';
@@ -249,6 +250,7 @@ function getClientOptions(
249250

250251
return {
251252
...mergedOptions,
253+
...resolveServerTunnelOption(mergedOptions.tunnel),
252254
integrations: resolvedIntegrations,
253255
};
254256
}

‎packages/node/test/sdk/init.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ describe('init()', () => {
4040
vi.clearAllMocks();
4141
});
4242

43+
describe('tunnel', () => {
44+
it('enables the managed tunnel for `tunnel: true` and sends its own envelopes directly', () => {
45+
init({ dsn: PUBLIC_DSN, tunnel: true });
46+
47+
const options = getClient<NodeClient>()?.getOptions();
48+
expect(options?.tunnel).toBeUndefined();
49+
expect(options?._managedTunnel).toEqual({});
50+
});
51+
52+
it('keeps a URL tunnel for its own envelopes', () => {
53+
init({ dsn: PUBLIC_DSN, tunnel: 'https://example.com/tunnel' });
54+
55+
const options = getClient<NodeClient>()?.getOptions();
56+
expect(options?.tunnel).toBe('https://example.com/tunnel');
57+
expect(options?._managedTunnel).toBeUndefined();
58+
});
59+
});
60+
4361
describe('metadata', () => {
4462
it('has the correct metadata', () => {
4563
init({ dsn: PUBLIC_DSN });

0 commit comments

Comments
 (0)