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
7 changes: 4 additions & 3 deletions packages/aws-serverless/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ 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;
const defaultUseLayerExtension =
sdkSource === 'aws-lambda-layer' && typeof options.tunnel !== 'string' && !proxyWouldInterfere;
const useLayerExtension = options.useLayerExtension ?? useLayerExtensionFromEnv ?? defaultUseLayerExtension;

const opts = {
Expand All @@ -88,8 +89,8 @@ export function init(options: AwsServerlessOptions = {}): NodeClient | undefined
if (opts.useLayerExtension) {
if (sdkSource !== 'aws-lambda-layer') {
DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.');
} else if (opts.tunnel || proxyWouldInterfere) {
if (opts.tunnel) {
} else if (typeof opts.tunnel === 'string' || proxyWouldInterfere) {
if (typeof opts.tunnel === 'string') {
DEBUG_BUILD &&
debug.warn(
`Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`,
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
40 changes: 40 additions & 0 deletions packages/browser/src/utils/managedTunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { TunnelOptions } from '@sentry/core';
import { debug, getTunnelPath } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import type { BrowserOptions } from '../client';

/**
* Resolves the `tunnel` option `init` received. `true` or {@link TunnelOptions} point the tunnel at the same-origin
* path the server SDK derives from the same DSN.
*/
export function resolveBrowserTunnelOption(
options: BrowserOptions,
): Pick<BrowserOptions, 'transportOptions' | '_managedTunnel'> & { tunnel: string | undefined } {
const { tunnel, dsn, transportOptions } = options;
if (typeof tunnel === 'string' || !tunnel) {
return { tunnel, transportOptions };
}

const tunnelOptions: TunnelOptions = tunnel === true ? {} : tunnel;
const tunnelPath = tunnelOptions.path ?? (dsn && getTunnelPath(dsn));
if (!tunnelPath) {
return { tunnel: undefined, transportOptions };
}

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

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

return {
tunnel: resolvedTunnel,
// Lets the client explain a tunnel request that nothing on the server answers.
_managedTunnel: tunnelOptions,
// The fetch transport sends `text/plain` by default to avoid a CORS preflight. A same-origin request has no
// preflight, and frameworks with CSRF protection (SvelteKit, Astro) reject a `text/plain` POST as a form submission.
transportOptions: {
...transportOptions,
headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers },
},
};
}
100 changes: 100 additions & 0 deletions packages/browser/test/utils/managedTunnel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { EventEnvelope, EventItem } from '@sentry/core';
import { createEnvelope, getTunnelPath, isTunnelRequest } from '@sentry/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getClient, getCurrentScope, init } from '../../src';
import { makeFetchTransport } from '../../src/transports/fetch';
import { resolveBrowserTunnelOption } from '../../src/utils/managedTunnel';

const DSN = 'https://public@o123.ingest.sentry.io/1337';

describe('resolveBrowserTunnelOption', () => {
it('resolves `tunnel: true` to the path the server derives from the same DSN', () => {
const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: true });

expect(tunnel).toBe(getTunnelPath(DSN));
expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN] })).toBe(true);
});

it('marks the tunnel as managed so the client can explain a server that does not answer it', () => {
expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } })._managedTunnel).toEqual({
basePath: '/docs',
});
expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel' })._managedTunnel).toBeUndefined();
});

it('prefixes the base path and appends a trailing slash when asked', () => {
const { tunnel } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs/', trailingSlash: true } });

expect(tunnel).toBe(`/docs${getTunnelPath(DSN)}/`);
expect(isTunnelRequest({ method: 'POST', url: tunnel! }, { allowedDsns: [DSN], basePath: '/docs' })).toBe(true);
});

it('uses a fixed path instead of the DSN-derived one', () => {
const { tunnel, transportOptions } = resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } });

expect(tunnel).toBe('/monitor');
expect(transportOptions?.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' });
});

it('keeps a URL tunnel and its transport options untouched', () => {
const transportOptions = { headers: { 'x-custom': '1' } };

expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/my-tunnel', transportOptions })).toEqual({
tunnel: '/my-tunnel',
transportOptions,
});
});

it('turns the tunnel off without a valid DSN', () => {
expect(resolveBrowserTunnelOption({ tunnel: true }).tunnel).toBeUndefined();
expect(resolveBrowserTunnelOption({ dsn: 'not-a-dsn', tunnel: true }).tunnel).toBeUndefined();
});

it('sends envelopes as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => {
const { tunnel, transportOptions } = resolveBrowserTunnelOption({
dsn: DSN,
tunnel: true,
transportOptions: { headers: { 'x-custom': '1' } },
});
const nativeFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));

const transport = makeFetchTransport(
{ ...transportOptions, url: tunnel!, recordDroppedEvent: () => undefined },
nativeFetch,
);
await transport.send(
createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
]),
);

expect(nativeFetch).toHaveBeenCalledWith(
getTunnelPath(DSN),
expect.objectContaining({
headers: { 'Content-Type': 'application/x-sentry-envelope', 'x-custom': '1' },
}),
);
});

it('lets a user-provided Content-Type header win', () => {
const { transportOptions } = resolveBrowserTunnelOption({
dsn: DSN,
tunnel: true,
transportOptions: { headers: { 'Content-Type': 'text/plain' } },
});

expect(transportOptions?.headers).toEqual({ 'Content-Type': 'text/plain' });
});
});

describe('init with `tunnel: true`', () => {
afterEach(() => {
getCurrentScope().setClient(undefined);
});

it('gives the client a resolved tunnel URL', () => {
init({ dsn: DSN, tunnel: true, defaultIntegrations: false });

expect(getClient()?.getOptions().tunnel).toBe(getTunnelPath(DSN));
});
});
3 changes: 2 additions & 1 deletion packages/bun/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ServerRuntimeClientOptions } from '@sentry/core/server';
import { ServerRuntimeClient } from '@sentry/core/server';
import { applySdkMetadata } from '@sentry/core';
import { applySdkMetadata, resolveServerTunnelOption } from '@sentry/core';
import * as os from 'os';
import type { BunClientOptions } from './types';

Expand All @@ -17,6 +17,7 @@ export class BunClient extends ServerRuntimeClient<BunClientOptions> {

const clientOptions: ServerRuntimeClientOptions = {
...options,
...resolveServerTunnelOption(options.tunnel),
platform: 'javascript',
runtime: { name: 'bun', version: Bun.version },
serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(),
Expand Down
9 changes: 8 additions & 1 deletion packages/cloudflare/src/baseSdk.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Integration } from '@sentry/core';
import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core';
import {
_INTERNAL_isClientClosed,
debug,
getCurrentScope,
resolveServerTunnelOption,
setCurrentClient,
} from '@sentry/core';
import {
consoleIntegration,
conversationIdIntegration,
Expand Down Expand Up @@ -120,6 +126,7 @@ export function initWithDefaultIntegrations(

const clientOptions: CloudflareClientOptions = {
...options,
...resolveServerTunnelOption(options.tunnel),
cacheClient: cacheEnabled,
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations: getIntegrationsToSetup(options),
Expand Down
9 changes: 9 additions & 0 deletions packages/cloudflare/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ describe('init', () => {
expect(client).toBeInstanceOf(CloudflareClient);
});

test('enables the managed tunnel for `tunnel: { allowedDsns }`', () => {
const browserDsn = 'https://browser@o1.ingest.sentry.io/42';
init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: { allowedDsns: [browserDsn] } });

const options = getClient()?.getOptions();
expect(options?.tunnel).toBeUndefined();
expect(options?._managedTunnel).toEqual({ allowedDsns: [browserDsn] });
});

test('installs SpanStreaming integration by default', () => {
init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
protected _unhandledSessionStatus: SessionStatus;

/** Whether we already explained a managed tunnel request the server rejected. */
private _hasWarnedAboutManagedTunnel?: boolean;

/**
* Initializes this client instance.
*
Expand Down Expand Up @@ -1323,6 +1326,8 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
try {
const result = await this._transport.send(envelope);
this.emit('afterEnvelope', envelope);
DEBUG_BUILD && this._warnIfManagedTunnelIsNotServed(result.statusCode);

return result;
} catch (reason) {
DEBUG_BUILD && debug.error('Error while sending envelope:', reason);
Expand All @@ -1334,6 +1339,22 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
return {};
}

/**
* `tunnel: true` only works when the server serves the tunnel. These statuses mean nothing answers the path (404, 405)
* or something rejected the request (403), as opposed to Sentry rate limiting or failing.
*/
private _warnIfManagedTunnelIsNotServed(statusCode: number | undefined): void {
const { tunnel, _managedTunnel } = this._options;
if (!tunnel || !_managedTunnel || this._hasWarnedAboutManagedTunnel || ![403, 404, 405].includes(statusCode ?? 0)) {
return;
}

this._hasWarnedAboutManagedTunnel = true;
debug.warn(
`The managed tunnel at "${tunnel}" responded with ${statusCode}, so events sent through it are lost. \`tunnel: true\` needs a server that answers it: a Sentry meta-framework SDK (SvelteKit, Nuxt, Astro, ...) or your own endpoint calling \`handleTunnelRequest\`. A 403 can also mean the server's \`allowedDsns\` doesn't include this DSN.`,
);
}

/**
* Register a cleanup function to be called when the client is disposed.
* This is useful for integrations that need to clean up global state.
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
tunnel?: string;

/**
* Set by server SDKs when the managed tunnel is enabled. Server request handlers check it to decide whether to
* Set by server SDKs when `init` was called with `tunnel: true` or {@link TunnelOptions}. Server request handlers check it to decide whether to
* serve the managed tunnel.
*
* @internal This option is not part of the public API and is subject to change at any time.
Expand Down Expand Up @@ -674,8 +674,21 @@ export interface TunnelOptions {
/** Base configuration options for every SDK. */
export interface CoreOptions<TO extends BaseTransportOptions = BaseTransportOptions> extends Omit<
Partial<ClientOptions<TO>>,
'integrations' | 'transport' | 'stackParser'
'integrations' | 'transport' | 'stackParser' | 'tunnel'
> {
/**
* Where to send envelopes instead of the Sentry ingest URL.
*
* - A URL sends envelopes to your own tunnel endpoint. On the server, a relative path such as `/monitor` instead
* serves the managed tunnel at that path, so `tunnel: '/monitor'` on both sides gives a fixed path.
* - `true` (or {@link TunnelOptions}) enables the managed tunnel. Set it in both the browser and the server `init`:
* the browser sends envelopes to a same-origin path derived from its DSN, and the server SDK serves that path and
* forwards the envelopes to Sentry. This keeps ad-blockers from dropping events.
*
* @default undefined
*/
tunnel?: string | true | TunnelOptions;

/**
* If this is set to false, default integrations will not be added, otherwise this will internally be set to the
* recommended default integrations.
Expand Down
39 changes: 38 additions & 1 deletion packages/core/test/lib/utils/tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api';
import { getCurrentScope, setCurrentClient } from '../../../src';
import { createTransport, debug, getCurrentScope, resolvedSyncPromise, setCurrentClient } from '../../../src';
import { isTracingSuppressed } from '../../../src/tracing/trace';
import { makeDsn } from '../../../src/utils/dsn';
import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope';
Expand Down Expand Up @@ -530,6 +530,43 @@ describe('client with the managed tunnel enabled', () => {
});
});

describe('client warning about a managed tunnel nothing serves', () => {
/** The tunnel warnings among the debug warnings, as the transport also warns about every failed response. */
async function tunnelWarningsFor(statusCode: number, managedTunnel?: TunnelOptions): Promise<unknown[]> {
const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {});
const client = new TestClient(
getDefaultTestClientOptions({
dsn: TEST_DSN,
tunnel: '/abc123',
_managedTunnel: managedTunnel,
transport: () =>
createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({ statusCode })),
}),
);

await client.sendEnvelope(createEnvelope({}, [[{ type: 'event' }, {}]] as never));
await client.sendEnvelope(createEnvelope({}, [[{ type: 'event' }, {}]] as never));

return warnSpy.mock.calls.flat().filter(message => String(message).includes('managed tunnel'));
}

afterEach(() => {
vi.restoreAllMocks();
});

it('warns once when the server does not answer the managed tunnel', async () => {
const warnings = await tunnelWarningsFor(404, {});

expect(warnings).toEqual([expect.stringContaining('The managed tunnel at "/abc123" responded with 404')]);
});

it('stays quiet for rate limits and for tunnels the user set up themselves', async () => {
expect(await tunnelWarningsFor(429, {})).toEqual([]);
vi.restoreAllMocks();
expect(await tunnelWarningsFor(404)).toEqual([]);
});
});

describe('resolveServerTunnelOption', () => {
it('keeps a URL as the transport tunnel', () => {
expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' });
Expand Down
2 changes: 2 additions & 0 deletions packages/deno/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
initAndBind,
linkedErrorsIntegration,
requestDataIntegration,
resolveServerTunnelOption,
stackParserFromStackParserOptions,
} from '@sentry/core';
import { getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils';
Expand Down Expand Up @@ -110,6 +111,7 @@ export function init(options: DenoOptions = {}): Client {

const clientOptions: ServerRuntimeClientOptions = {
...options,
...resolveServerTunnelOption(options.tunnel),
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations: getIntegrationsToSetup({ integrations: options.integrations, defaultIntegrations }),
transport: options.transport || makeFetchTransport,
Expand Down
Loading
Loading