diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 8c7c238265fa..19d56203f8cb 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,5 +1,6 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, debug, envToBool, getSDKSource } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; import { awsIntegration } from '@sentry/server-utils'; @@ -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) { diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts index 500338dc7144..7d210c12ee4c 100644 --- a/packages/aws-serverless/test/init.test.ts +++ b/packages/aws-serverless/test/init.test.ts @@ -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 = { diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index c058144432e1..8c0046d69e1b 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -24,6 +24,7 @@ import { defaultStackParser } from './stack-parsers'; import { makeFetchTransport } from './transports/fetch'; import { normalizeStringifyValue } from './normalizeStringifyValue'; import { checkAndWarnIfIsEmbeddedBrowserExtension } from './utils/detectBrowserExtension'; +import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from './utils/managedTunnel'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -115,12 +116,18 @@ export function init(options: BrowserOptions = {}): Client | undefined { defaultIntegrations, }); + const tunnelOptions = resolveBrowserTunnelOption(options); + const transport = options.transport || makeFetchTransport; + const clientOptions: BrowserClientOptions = { ...options, + ...tunnelOptions, enabled: shouldDisableBecauseIsBrowserExtenstion ? false : options.enabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations, - transport: options.transport || makeFetchTransport, + transport: tunnelOptions._managedTunnel + ? makeManagedTunnelTransport(transport, options.transportOptions?.headers) + : transport, }; setNormalizeStringifier(normalizeStringifyValue); diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 83e3905de35c..15b324027427 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -50,6 +50,7 @@ export function makeFetchTransport( headers: { 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), 'retry-after': response.headers.get('Retry-After'), + 'x-sentry-tunnel': response.headers.get('X-Sentry-Tunnel'), }, }; } catch (e) { diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts new file mode 100644 index 000000000000..de1261e15f02 --- /dev/null +++ b/packages/browser/src/utils/managedTunnel.ts @@ -0,0 +1,105 @@ +import type { Envelope, Transport, TransportMakeRequestResponse, TunnelOptions } from '@sentry/core'; +import { + debug, + getEnvelopeEndpointWithUrlEncodedAuth, + getTunnelPath, + makeDsn, + normalizeTunnelBasePath, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { BrowserOptions } from '../client'; +import type { BrowserTransportOptions } from '../transports/types'; + +/** + * Resolves `tunnel: true` or {@link TunnelOptions} to the same-origin path the server derives from the DSN. + */ +export function resolveBrowserTunnelOption( + options: BrowserOptions, +): Pick & { 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 = normalizeTunnelBasePath(tunnelOptions.basePath); + const resolvedTunnel = `${basePath}${tunnelPath}${tunnelOptions.trailingSlash ? '/' : ''}`; + + DEBUG_BUILD && debug.log(`Tunneling events to "${resolvedTunnel}"`); + + return { + tunnel: resolvedTunnel, + _managedTunnel: tunnelOptions, + // CSRF protection (SvelteKit, Astro) rejects the default `text/plain` POST as a form submission. + transportOptions: { + ...transportOptions, + headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers }, + }, + }; +} + +let hasWarnedAboutRejectedDsn = false; + +/** + * Falls back to sending directly when nothing on this origin serves the managed tunnel. An envelope is resent only + * when the response proves it never reached Sentry. + */ +export function makeManagedTunnelTransport( + makeTransport: (options: BrowserTransportOptions) => Transport, + directHeaders: BrowserTransportOptions['headers'], +): (options: BrowserTransportOptions) => Transport { + return options => { + const tunnelTransport = makeTransport(options); + let directTransport: Transport | undefined; + + return { + async send(envelope: Envelope): Promise { + if (directTransport) { + return directTransport.send(envelope); + } + + const response = await tunnelTransport.send(envelope); + const statusCode = response.statusCode || 0; + + if (response.headers?.['x-sentry-tunnel']) { + if (DEBUG_BUILD && statusCode === 403 && !hasWarnedAboutRejectedDsn) { + hasWarnedAboutRejectedDsn = true; + debug.warn( + `The managed tunnel at "${options.url}" rejected this DSN, so events sent through it are lost. Add it to the server's \`allowedDsns\`.`, + ); + } + + return response; + } + + // Not the tunnel: a 4xx never reached Sentry so it's resent, a 2xx may have, so only later envelopes go direct. + const rejected = statusCode >= 400 && statusCode < 500; + const { dsn, sdk } = envelope[0]; + const components = (rejected || (statusCode >= 200 && statusCode < 300)) && dsn && makeDsn(dsn); + if (!components) { + return response; + } + + DEBUG_BUILD && + debug.warn(`Sending to Sentry directly: "${options.url}" answered ${statusCode} instead of the tunnel.`); + directTransport ??= makeTransport({ + ...options, + url: getEnvelopeEndpointWithUrlEncodedAuth(components, undefined, sdk), + headers: directHeaders, + }); + + return rejected ? directTransport.send(envelope) : response; + }, + async flush(timeout?: number): Promise { + const results = await Promise.all([tunnelTransport.flush(timeout), directTransport?.flush(timeout) ?? true]); + + return results.every(Boolean); + }, + }; + }; +} diff --git a/packages/browser/test/transports/fetch.test.ts b/packages/browser/test/transports/fetch.test.ts index 1c1dee2c7f90..52098701347d 100644 --- a/packages/browser/test/transports/fetch.test.ts +++ b/packages/browser/test/transports/fetch.test.ts @@ -73,9 +73,10 @@ describe('fetchTransport', () => { expect(headers.get).toHaveBeenCalledTimes(0); await transport.send(ERROR_ENVELOPE); - expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledTimes(3); expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); expect(headers.get).toHaveBeenCalledWith('Retry-After'); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Tunnel'); }); it('allows for custom options to be passed in', async () => { diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts new file mode 100644 index 000000000000..edb2457a2a6e --- /dev/null +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -0,0 +1,236 @@ +import type { EventEnvelope, EventItem, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import { createEnvelope, getEnvelopeEndpointWithUrlEncodedAuth, getTunnelPath, makeDsn } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getClient, getCurrentScope, init } from '../../src'; +import { makeFetchTransport } from '../../src/transports/fetch'; +import { makeManagedTunnelTransport, resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; +import type { BrowserTransportOptions } from '../../src/transports/types'; + +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)); + }); + + 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)}/`); + }); + + it('normalizes the base path like the server does, so the tunnel URL is never page-relative', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: 'docs' } }).tunnel).toBe( + `/docs${getTunnelPath(DSN)}`, + ); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: './docs/' } }).tunnel).toBe( + `/docs${getTunnelPath(DSN)}`, + ); + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: './' } }).tunnel).toBe(getTunnelPath(DSN)); + }); + + 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({ 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)); + }); +}); + +describe('makeManagedTunnelTransport', () => { + const TUNNEL_URL = `https://example.com${getTunnelPath(DSN)}`; + const DIRECT_URL = getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(DSN)!); + + function makeEnvelope(id: string): EventEnvelope { + return createEnvelope({ dsn: DSN, event_id: id }, [ + [{ type: 'event' }, { event_id: id }] as EventItem, + ]); + } + + // Each transport answers with the next response queued for its URL and records what it sent. + function setup(responses: Record): { + transport: Transport; + sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }>; + } { + const sent: Array<{ url: string; id: string; headers: BrowserTransportOptions['headers'] }> = []; + const makeTransport = (options: BrowserTransportOptions): Transport => ({ + send: envelope => { + sent.push({ url: options.url, id: String(envelope[0].event_id), headers: options.headers }); + + return Promise.resolve(responses[options.url]?.shift() ?? { statusCode: 200 }); + }, + flush: () => Promise.resolve(true), + }); + const transport = makeManagedTunnelTransport(makeTransport, { 'X-Custom': '1' })({ + url: TUNNEL_URL, + headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' }, + recordDroppedEvent: () => undefined, + }); + + return { transport, sent }; + } + + const tunnelResponse = { statusCode: 200, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + + it('keeps sending through the tunnel while its handler answers', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [tunnelResponse, tunnelResponse] }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it.each([400, 401, 403, 404, 405, 413, 429])( + 'sends an envelope directly once, and everything after it, when something other than the tunnel answers %s', + async statusCode => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual({ statusCode: 200 }); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [DIRECT_URL, 'a'], + [DIRECT_URL, 'b'], + ]); + // Sentry takes the default `text/plain` without a CORS preflight, so the tunnel's content type is dropped. + expect(sent[1]?.headers).toEqual({ 'X-Custom': '1' }); + }, + ); + + it.each([403, 404, 405, 429])( + 'keeps using the tunnel when Sentry answers %s through its handler', + async statusCode => { + const sentryResponse = { statusCode, headers: { 'x-sentry-tunnel': '1' } } as TransportMakeRequestResponse; + const { transport, sent } = setup({ [TUNNEL_URL]: [sentryResponse, tunnelResponse] }); + + expect(await transport.send(makeEnvelope('a'))).toBe(sentryResponse); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }, + ); + + it('never sends an envelope again after a 2xx the tunnel handler did not produce, only the ones after it', async () => { + const { transport, sent } = setup({ + [TUNNEL_URL]: [{ statusCode: 200, headers: {} } as TransportMakeRequestResponse], + }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [DIRECT_URL, 'b'], + ]); + }); + + it.each([500, 503])('never sends an envelope again after a %s', async statusCode => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode }] }); + + await transport.send(makeEnvelope('a')); + await transport.send(makeEnvelope('b')); + + expect(sent.map(({ url, id }) => [url, id])).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it('only sends each envelope directly once when several were already on their way to the tunnel', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ statusCode: 404 }, { statusCode: 404 }] }); + + await Promise.all([transport.send(makeEnvelope('a')), transport.send(makeEnvelope('b'))]); + + expect(sent.filter(({ url }) => url === DIRECT_URL).map(({ id }) => id)).toEqual(['a', 'b']); + }); + + it('is used by `init` for a managed tunnel only', () => { + const transport = vi.fn(makeFetchTransport); + + init({ dsn: DSN, tunnel: true, transport }); + const managedTransportOptions = transport.mock.calls[0]?.[0]; + init({ dsn: DSN, tunnel: 'https://proxy.example.com', transport }); + + expect(managedTransportOptions?.url).toBe(getTunnelPath(DSN)); + expect(transport).toHaveBeenLastCalledWith(expect.objectContaining({ url: 'https://proxy.example.com' })); + }); +}); diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index 03d301f05f95..b4670612e1e0 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -1,5 +1,5 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { ServerRuntimeClient } from '@sentry/core/server'; +import { resolveServerTunnelOption, ServerRuntimeClient } from '@sentry/core/server'; import { applySdkMetadata } from '@sentry/core'; import * as os from 'os'; import type { BunClientOptions } from './types'; @@ -17,6 +17,7 @@ export class BunClient extends ServerRuntimeClient { 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(), diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..fdd0f3c89a81 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import { consoleIntegration, conversationIdIntegration, @@ -120,6 +121,7 @@ export function initWithDefaultIntegrations( const clientOptions: CloudflareClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), cacheClient: cacheEnabled, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup(options), diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index dcbbb7089da4..dee64f789523 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -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', diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c61b3975d41b..a07e32769da6 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1317,6 +1317,7 @@ export abstract class Client { try { const result = await this._transport.send(envelope); this.emit('afterEnvelope', envelope); + return result; } catch (reason) { DEBUG_BUILD && debug.error('Error while sending envelope:', reason); diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 9cff5450e374..e756ce4c35f3 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,7 +374,7 @@ export interface ClientOptions extends Omit< Partial>, - '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. diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 8b277fb32453..566713397895 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -14,9 +14,9 @@ import { requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; +import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; import { getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; import { DenoClient } from './client'; -import { nodeStackLineParser } from '@sentry/core/server'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; import { denoContextIntegration } from './integrations/context'; import { contextLinesIntegration } from './integrations/contextlines'; @@ -125,6 +125,7 @@ function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Optio const clientOptions: ServerRuntimeClientOptions = { ...options, + ...resolveServerTunnelOption(options.tunnel), stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup({ integrations: options.integrations, defaultIntegrations }), transport: options.transport || makeFetchTransport, diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 57cf3871256a..3367653fb076 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -1,6 +1,5 @@ import type { Integration, Options } from '@sentry/core'; import { - _INTERNAL_warnIfClientIsActive, applySdkMetadata, consoleSandbox, conversationIdIntegration, @@ -12,11 +11,13 @@ import { getCurrentScope, getIntegrationsToSetup, hasSpansEnabled, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, propagationContextFromHeaders, requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; +import { resolveServerTunnelOption } from '@sentry/core/server'; import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -249,6 +250,7 @@ function getClientOptions( return { ...mergedOptions, + ...resolveServerTunnelOption(mergedOptions.tunnel), integrations: resolvedIntegrations, }; } diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 9ebfd0d05e31..7b92ef2d00af 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -40,6 +40,24 @@ describe('init()', () => { vi.clearAllMocks(); }); + describe('tunnel', () => { + it('enables the managed tunnel for `tunnel: true` and sends its own envelopes directly', () => { + init({ dsn: PUBLIC_DSN, tunnel: true }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({}); + }); + + it('keeps a URL tunnel for its own envelopes', () => { + init({ dsn: PUBLIC_DSN, tunnel: 'https://example.com/tunnel' }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBe('https://example.com/tunnel'); + expect(options?._managedTunnel).toBeUndefined(); + }); + }); + describe('metadata', () => { it('has the correct metadata', () => { init({ dsn: PUBLIC_DSN }); diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index bc2d74822d65..e909ea255640 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,7 +1,6 @@ import { diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import type { Client, Integration } from '@sentry/core'; import { - _INTERNAL_warnIfClientIsActive, consoleIntegration, consoleSandbox, conversationIdIntegration, @@ -14,11 +13,12 @@ import { getIntegrationsToSetup, getVercelEnv, GLOBAL_OBJ, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; -import { nodeStackLineParser } from '@sentry/core/server'; +import { nodeStackLineParser, resolveServerTunnelOption } from '@sentry/core/server'; import { SentryPropagator, SentryTracerProvider, @@ -86,6 +86,7 @@ export function init(options: VercelEdgeOptions = {}): Client { const client = new VercelEdgeClient({ ...options, + ...resolveServerTunnelOption(options.tunnel), stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), integrations: getIntegrationsToSetup(options), transport: options.transport || makeEdgeTransport,