Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7cc3b00
feat: Accept `tunnel: true` in `init` to enable the managed tunnel
logaretm Oct 8, 2026
4911924
feat: Explain a managed tunnel that nothing on the server answers
logaretm Oct 15, 2026
d138861
fix(core): Keep the managed tunnel warning out of production bundles
logaretm Oct 16, 2026
f651a97
fix(aws-serverless): Keep the managed tunnel when the Lambda extensio…
logaretm Oct 16, 2026
36c4a1a
fix(core): Point Next.js users to `tunnelRoute` when the managed tunn…
logaretm Oct 16, 2026
5d4afe9
feat(browser): Send directly to Sentry when nothing answers the manag…
logaretm Oct 16, 2026
5ebb8e9
fix(browser): Normalize the managed tunnel's base path like the serve…
logaretm Oct 19, 2026
1f23d5a
fix(browser): Keep the managed tunnel when Sentry answers 404 or 405 …
logaretm Oct 19, 2026
36dfaab
fix(browser): Send directly when anything but the tunnel answers a ma…
logaretm Oct 19, 2026
f0b532c
ref(browser): Warn about a rejected DSN from the managed tunnel trans…
logaretm Oct 19, 2026
6f1d9c9
ref(browser): Switch the managed tunnel to direct sends in one place
logaretm Oct 19, 2026
e6ff11a
test(browser): Drop the server-side tunnel match from the browser tun…
logaretm Oct 19, 2026
695feb9
ref(browser): Trim the managed tunnel comments
logaretm Oct 19, 2026
a90bd51
fix(aws-serverless): Keep the Lambda extension for a relative tunnel …
logaretm Oct 19, 2026
8d266b5
ref: Import the internal tunnel helpers under their `_INTERNAL_` names
logaretm Oct 22, 2026
a80ac61
ref: Narrow `tunnel` to the browser or server tunnel options per SDK
logaretm Oct 22, 2026
ba73fb1
feat(browser): Treat a relative tunnel path as a managed one and only…
logaretm Oct 23, 2026
ee14703
ref(browser): Fold the managed tunnel fallback into the fetch transport
logaretm Oct 23, 2026
9f5229d
fix(browser): Resend to Sentry after the tunnel request leaves the ke…
logaretm Oct 23, 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
7 changes: 6 additions & 1 deletion packages/aws-serverless/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Integration, Options } from '@sentry/core';
import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core';
import { _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption } from '@sentry/core/server';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node';
import { awsIntegration } from '@sentry/server-utils';
Expand Down Expand Up @@ -76,13 +77,17 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
// 2. Environment variable SENTRY_LAYER_EXTENSION (if set)
// 3. Default logic based on sdkSource, tunnel, and proxy settings
const useLayerExtensionFromEnv = envToBool(process.env.SENTRY_LAYER_EXTENSION, { strict: true });
const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !options.tunnel && !proxyWouldInterfere;
// A relative path is the managed tunnel's path, not a transport URL.
const { tunnel, _managedTunnel } = resolveServerTunnelOption(options.tunnel);
const defaultUseLayerExtension = sdkSource === 'aws-lambda-layer' && !tunnel && !proxyWouldInterfere;
const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension;

const opts = {
defaultIntegrations: getDefaultIntegrations(options),
useLayerExtension,
...options,
tunnel,
_managedTunnel,
};

if (opts.useLayerExtension) {
Expand Down
27 changes: 27 additions & 0 deletions packages/aws-serverless/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,33 @@ describe('init', () => {
);
});

test('keeps the managed tunnel when the Lambda extension takes over the transport', () => {
mockGetSDKSource.mockReturnValue('aws-lambda-layer');

init({ tunnel: true, useLayerExtension: true });

expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
expect.objectContaining({
tunnel: 'http://localhost:9000/envelope',
_managedTunnel: {},
}),
);
});

test('keeps the Lambda extension for a relative tunnel path, which is the managed tunnel to serve', () => {
mockGetSDKSource.mockReturnValue('aws-lambda-layer');

init({ tunnel: '/monitor' });

expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith(
expect.objectContaining({
useLayerExtension: true,
tunnel: 'http://localhost:9000/envelope',
_managedTunnel: { path: '/monitor' },
}),
);
});

test('should not set tunnel when Lambda extension is disabled', () => {
mockGetSDKSource.mockReturnValue('aws-lambda-layer');
const options: AwsServerlessOptions = {
Expand Down
10 changes: 9 additions & 1 deletion packages/browser/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type {
BrowserTunnelOptions,
ClientOptions,
Event,
EventHint,
Options as CoreOptions,
ParameterizedString,
Scope,
SeverityLevel,
TunnelInit,
} from '@sentry/core';
import type { BrowserClientReplayOptions } from '@sentry/core/browser';
import type { RouteProvider } from '@sentry/browser-utils';
Expand Down Expand Up @@ -91,7 +93,13 @@ type BrowserSpecificOptions = BrowserClientReplayOptions &
* Configuration options for the Sentry Browser SDK.
* @see @sentry/core Options for more information.
*/
export type BrowserOptions = CoreOptions<BrowserTransportOptions> & BrowserSpecificOptions;
export type BrowserOptions = CoreOptions<BrowserTransportOptions> &
BrowserSpecificOptions & {
/**
* See {@link CoreOptions.tunnel}. The browser `init` also takes the browser-only {@link BrowserTunnelOptions}.
*/
tunnel?: TunnelInit<BrowserTunnelOptions>;
};

/**
* Configuration options for the Sentry Browser SDK Client class
Expand Down
2 changes: 2 additions & 0 deletions packages/browser/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { defaultStackParser } from './stack-parsers';
import { makeFetchTransport } from './transports/fetch';
import { normalizeStringifyValue } from './normalizeStringifyValue';
import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension';
import { resolveBrowserTunnelOption } from './utils/managedTunnel';

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

const clientOptions: BrowserClientOptions = {
...options,
...resolveBrowserTunnelOption(options),
enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled,
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations,
Expand Down
37 changes: 27 additions & 10 deletions packages/browser/src/transports/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core';
import { createTransport, makePromiseBuffer } from '@sentry/core';
import { createTransport, debug, makePromiseBuffer, TUNNEL_RESPONSE_HEADER } from '@sentry/core';
import { clearCachedImplementation, getNativeImplementation } from '@sentry/browser-utils';
import { DEBUG_BUILD } from '../debug-build';
import type { WINDOW } from '../helpers';
import type { BrowserTransportOptions } from './types';

Expand All @@ -13,10 +14,14 @@ export function makeFetchTransport(
options: BrowserTransportOptions,
nativeFetch: typeof WINDOW.fetch = getNativeImplementation('fetch'),
): Transport {
const fallbackUrl = options._tunnelFallbackUrl;
let url = options.url;
let pendingBodySize = 0;
let pendingCount = 0;

async function makeRequest(request: TransportRequest): Promise<TransportMakeRequestResponse> {
const requestUrl = url;
const isTunnel = !!fallbackUrl && requestUrl !== fallbackUrl;
const requestSize = request.body.length;
pendingBodySize += requestSize;
pendingCount++;
Expand All @@ -25,7 +30,8 @@ export function makeFetchTransport(
body: request.body as BodyInit,
method: 'POST',
referrerPolicy: 'strict-origin',
headers: options.headers,
// CSRF protection (SvelteKit, Astro) rejects the default `text/plain` POST to the tunnel as a form submission.
headers: isTunnel ? { 'Content-Type': 'application/x-sentry-envelope', ...options.headers } : options.headers,
// Outgoing requests are usually cancelled when navigating to a different page, causing a "TypeError: Failed to
// fetch" error and sending a "network_error" client-outcome - in Chrome, the request status shows "(cancelled)".
// The `keepalive` flag keeps outgoing requests alive, even when switching pages. We want this since we're
Expand All @@ -43,22 +49,33 @@ export function makeFetchTransport(

try {
// Note: We do not need to suppress tracing here, because we are using the native fetch, instead of our wrapped one.
const response = await nativeFetch(options.url, requestOptions);
const response = await nativeFetch(requestUrl, requestOptions);
const status = response.status;

return {
statusCode: response.status,
headers: {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
},
};
// Only a 404 or 405 says nothing serves the tunnel. Any other answer may come from a tunnel the app runs itself.
if (!isTunnel || (status !== 404 && status !== 405) || response.headers.get(TUNNEL_RESPONSE_HEADER)) {
return {
statusCode: status,
headers: {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
},
};
}

DEBUG_BUILD &&
debug.warn(`Sending to Sentry directly: "${requestUrl}" answered ${status} instead of the tunnel.`);
url = fallbackUrl;
} catch (e) {
clearCachedImplementation('fetch');
throw e;
} finally {
pendingBodySize -= requestSize;
pendingCount--;
}

// Resent once the tunnel request no longer counts as in flight, so the direct send keeps `keepalive`.
return makeRequest(request);
Comment thread
logaretm marked this conversation as resolved.
}

return createTransport(
Expand Down
6 changes: 6 additions & 0 deletions packages/browser/src/transports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ import type { BaseTransportOptions } from '@sentry/core';
export interface BrowserTransportOptions extends BaseTransportOptions {
/** Fetch API init parameters. Used by the FetchTransport */
fetchOptions?: RequestInit;
/**
* The direct ingest URL `init` sets for a managed tunnel. Envelopes go there when nothing serves the tunnel.
*
* @hidden
*/
_tunnelFallbackUrl?: string;
}
45 changes: 45 additions & 0 deletions packages/browser/src/utils/managedTunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { BrowserTunnelOptions } from '@sentry/core';
import {
debug,
getEnvelopeEndpointWithUrlEncodedAuth,
getTunnelPath,
makeDsn,
_INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath,
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import type { BrowserOptions } from '../client';

/**
* Resolves `tunnel: true`, a relative path or {@link BrowserTunnelOptions} to the managed tunnel's same-origin path.
*/
export function resolveBrowserTunnelOption(
options: BrowserOptions,
): Pick<BrowserOptions, 'transportOptions'> & { tunnel: string | undefined } {
const { tunnel, dsn, transportOptions } = options;
if (!tunnel || (typeof tunnel === 'string' && !tunnel.startsWith('/'))) {
return { tunnel, transportOptions };
}

const tunnelOptions: BrowserTunnelOptions =
tunnel === true ? {} : typeof tunnel === 'string' ? { path: tunnel } : tunnel;
const dsnComponents = dsn && makeDsn(dsn);
const tunnelPath = tunnelOptions.path ?? (dsnComponents && getTunnelPath(dsnComponents));
if (!tunnelPath) {
return { tunnel: undefined, transportOptions };
}

const basePath = normalizeTunnelBasePath(tunnelOptions.basePath);
const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`;

DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`);

return {
tunnel: resolvedTunnel,
transportOptions: {
...transportOptions,
_tunnelFallbackUrl: dsnComponents
? getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents, undefined, options._metadata?.sdk)
: undefined,
},
};
}
Comment thread
logaretm marked this conversation as resolved.
Loading
Loading