diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 8c7c238265fa..6d8bfadbf9f9 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 { _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'; @@ -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/client.ts b/packages/browser/src/client.ts index e29efcaeb599..6dc6d2402d86 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -1,4 +1,5 @@ import type { + BrowserTunnelOptions, ClientOptions, Event, EventHint, @@ -6,6 +7,7 @@ import type { ParameterizedString, Scope, SeverityLevel, + TunnelInit, } from '@sentry/core'; import type { BrowserClientReplayOptions } from '@sentry/core/browser'; import type { RouteProvider } from '@sentry/browser-utils'; @@ -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 & BrowserSpecificOptions; +export type BrowserOptions = CoreOptions & + BrowserSpecificOptions & { + /** + * See {@link CoreOptions.tunnel}. The browser `init` also takes the browser-only {@link BrowserTunnelOptions}. + */ + tunnel?: TunnelInit; + }; /** * Configuration options for the Sentry Browser SDK Client class diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index c058144432e1..1425fe5c6829 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 { resolveBrowserTunnelOption } from './utils/managedTunnel'; /** Get the default integrations for the browser SDK. */ export function getDefaultIntegrations(_options: Options): Integration[] { @@ -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, diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 83e3905de35c..e8f3f0e51e76 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -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'; @@ -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 { + const requestUrl = url; + const isTunnel = !!fallbackUrl && requestUrl !== fallbackUrl; const requestSize = request.body.length; pendingBodySize += requestSize; pendingCount++; @@ -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 @@ -43,15 +49,23 @@ 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; @@ -59,6 +73,9 @@ export function makeFetchTransport( pendingBodySize -= requestSize; pendingCount--; } + + // Resent once the tunnel request no longer counts as in flight, so the direct send keeps `keepalive`. + return makeRequest(request); } return createTransport( diff --git a/packages/browser/src/transports/types.ts b/packages/browser/src/transports/types.ts index a304e9f93d66..6e6c384929f2 100644 --- a/packages/browser/src/transports/types.ts +++ b/packages/browser/src/transports/types.ts @@ -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; } diff --git a/packages/browser/src/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts new file mode 100644 index 000000000000..c562bf2afb6e --- /dev/null +++ b/packages/browser/src/utils/managedTunnel.ts @@ -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 & { 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, + }, + }; +} diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts new file mode 100644 index 000000000000..19b4fbd97be9 --- /dev/null +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -0,0 +1,215 @@ +import type { EventEnvelope, EventItem } 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 { resolveBrowserTunnelOption } from '../../src/utils/managedTunnel'; + +const DSN = 'https://public@o123.ingest.sentry.io/1337'; +const DIRECT_URL = getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(DSN)!); + +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('gives a managed tunnel the direct ingest URL to fall back to', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { basePath: '/docs' } }).transportOptions).toEqual({ + _tunnelFallbackUrl: DIRECT_URL, + }); + }); + + 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', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }).tunnel).toBe('/monitor'); + }); + + it('treats a relative path as a shortcut for a fixed path', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: '/monitor' })).toEqual( + resolveBrowserTunnelOption({ dsn: DSN, tunnel: { path: '/monitor' } }), + ); + }); + + it('keeps a URL tunnel and its transport options untouched', () => { + const transportOptions = { headers: { 'x-custom': '1' } }; + + expect( + resolveBrowserTunnelOption({ dsn: DSN, tunnel: 'https://proxy.example.com/tunnel', transportOptions }), + ).toEqual({ + tunnel: 'https://proxy.example.com/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(); + }); +}); + +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)); + }); + + it('only gives the transport a fallback URL for a managed tunnel', () => { + 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).toEqual( + expect.objectContaining({ url: getTunnelPath(DSN), _tunnelFallbackUrl: DIRECT_URL }), + ); + expect(transport).toHaveBeenLastCalledWith(expect.not.objectContaining({ _tunnelFallbackUrl: expect.anything() })); + }); +}); + +describe('makeFetchTransport with a managed tunnel', () => { + const TUNNEL_URL = `https://example.com${getTunnelPath(DSN)}`; + + function makeEnvelope(id: string): EventEnvelope { + return createEnvelope({ dsn: DSN, event_id: id }, [ + [{ type: 'event' }, { event_id: id }] as EventItem, + ]); + } + + // Each URL answers with the next response queued for it, 200 once the queue is empty. + function setup(responses: Record>) { + const nativeFetch = vi.fn((url: string) => { + const { status, tunnel } = responses[url]?.shift() ?? { status: 200 }; + + return Promise.resolve(new Response(null, { status, headers: tunnel ? { 'X-Sentry-Tunnel': '1' } : {} })); + }); + const transport = makeFetchTransport( + { + url: TUNNEL_URL, + headers: { 'X-Custom': '1' }, + _tunnelFallbackUrl: DIRECT_URL, + recordDroppedEvent: () => undefined, + }, + nativeFetch as unknown as typeof fetch, + ); + const sent = (): string[][] => + nativeFetch.mock.calls.map(([url], i) => [ + url, + JSON.parse(String(nativeFetch.mock.calls[i]?.[1]?.body).split('\n')[0]!).event_id, + ]); + + return { transport, nativeFetch, sent }; + } + + it('sends envelopes to the tunnel as application/x-sentry-envelope so CSRF checks do not treat them as form posts', async () => { + const { transport, nativeFetch } = setup({}); + + await transport.send(makeEnvelope('a')); + + expect(nativeFetch).toHaveBeenCalledWith( + TUNNEL_URL, + expect.objectContaining({ headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' } }), + ); + }); + + it.each([404, 405])( + 'sends an envelope directly once, and everything after it, when nothing serves the tunnel and it answers %s', + async status => { + const { transport, nativeFetch, sent } = setup({ [TUNNEL_URL]: [{ status }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: 200 })); + await transport.send(makeEnvelope('b')); + + expect(sent()).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(nativeFetch).toHaveBeenLastCalledWith( + DIRECT_URL, + expect.objectContaining({ headers: { 'X-Custom': '1' } }), + ); + }, + ); + + it.each([403, 404, 405])('keeps using the tunnel when its handler answers %s', async status => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status, tunnel: true }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: status })); + await transport.send(makeEnvelope('b')); + + expect(sent()).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }); + + it.each([200, 400, 401, 403, 413, 500, 503])( + 'keeps using a tunnel the app serves itself when it answers %s', + async status => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: status })); + await transport.send(makeEnvelope('b')); + + expect(sent()).toEqual([ + [TUNNEL_URL, 'a'], + [TUNNEL_URL, 'b'], + ]); + }, + ); + + it.each([true, false])('never resends a 429 directly (from the tunnel handler: %s)', async tunnel => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status: 429, tunnel }] }); + + expect(await transport.send(makeEnvelope('a'))).toEqual(expect.objectContaining({ statusCode: 429 })); + + expect(sent()).toEqual([[TUNNEL_URL, 'a']]); + }); + + it('keeps `keepalive` on the direct resend of an envelope the tunnel did not take', async () => { + const { transport, nativeFetch } = setup({ [TUNNEL_URL]: [{ status: 404 }] }); + const envelope = createEnvelope({ dsn: DSN, event_id: 'a' }, [ + [{ type: 'event' }, { event_id: 'a', message: 'x'.repeat(40_000) }] as EventItem, + ]); + + await transport.send(envelope); + + expect(nativeFetch).toHaveBeenLastCalledWith(DIRECT_URL, expect.objectContaining({ keepalive: true })); + }); + + it('sends each envelope directly once when several were already on their way to the tunnel', async () => { + const { transport, sent } = setup({ [TUNNEL_URL]: [{ status: 404 }, { status: 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']); + }); +}); diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index 03d301f05f95..3b1e9a15e6f4 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -1,5 +1,8 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; -import { ServerRuntimeClient } from '@sentry/core/server'; +import { + _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption, + ServerRuntimeClient, +} from '@sentry/core/server'; import { applySdkMetadata } from '@sentry/core'; import * as os from 'os'; import type { BunClientOptions } from './types'; @@ -17,6 +20,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/bun/src/types.ts b/packages/bun/src/types.ts index ade64f83137f..fe6f975a9978 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -1,4 +1,4 @@ -import type { BaseTransportOptions, ClientOptions, Options } from '@sentry/core'; +import type { BaseTransportOptions, ClientOptions, Options, ServerTunnelOptions, TunnelInit } from '@sentry/core'; import type { ServerRuntimeOptions } from '@sentry/core/server'; /** @@ -35,7 +35,12 @@ export interface BaseBunOptions extends ServerRuntimeOptions { * Configuration options for the Sentry Bun SDK * @see @sentry/core Options for more information. */ -export interface BunOptions extends Options, BaseBunOptions {} +export interface BunOptions extends Options, BaseBunOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry Bun SDK Client class diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..80f3eee99055 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 { _INTERNAL_resolveServerTunnelOption as 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/src/client.ts b/packages/cloudflare/src/client.ts index 051ffd8627d2..49fd35bc16ea 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,4 +1,13 @@ -import type { CheckIn, ClientOptions, MonitorConfig, Options, Scope, TracePropagationTargets } from '@sentry/core'; +import type { + CheckIn, + ClientOptions, + MonitorConfig, + Options, + Scope, + ServerTunnelOptions, + TunnelInit, + TracePropagationTargets, +} from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -530,6 +539,11 @@ interface BaseCloudflareOptions { */ export interface CloudflareOptions extends Options, BaseCloudflareOptions { ctx?: ExecutionContextCompat; + + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; } /** 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 4f8bf39f117a..0e1e65264184 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,7 +374,7 @@ export interface ClientOptions = string | true /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, - 'integrations' | 'transport' | 'stackParser' + 'integrations' | 'transport' | 'stackParser' | 'tunnel' > { + /** + * Where to send envelopes instead of the Sentry ingest URL, see {@link TunnelInit}. + * + * @default undefined + */ + tunnel?: TunnelInit; + /** * 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 96af20a57b87..18369fb82fc4 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -14,9 +14,12 @@ import { requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; +import { + nodeStackLineParser, + _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption, +} from '@sentry/core/server'; import { detectOrchestrionSetup, 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 +128,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/deno/src/types.ts b/packages/deno/src/types.ts index 9e5a8cf53cd6..ffe09fc3e31e 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -1,4 +1,11 @@ -import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; +import type { + BaseTransportOptions, + ClientOptions, + Options, + ServerTunnelOptions, + TunnelInit, + TracePropagationTargets, +} from '@sentry/core'; export interface BaseDenoOptions { /** @@ -56,7 +63,12 @@ export interface BaseDenoOptions { * Configuration options for the Sentry Deno SDK * @see @sentry/core Options for more information. */ -export interface DenoOptions extends Options, BaseDenoOptions {} +export interface DenoOptions extends Options, BaseDenoOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry Deno SDK Client class diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 57cf3871256a..c96048d57d2c 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 { _INTERNAL_resolveServerTunnelOption as 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/src/types.ts b/packages/node/src/types.ts index 220c194c6546..97c4dec0e0f2 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, Scope } from '@sentry/core'; +import type { ClientOptions, Options, Scope, ServerTunnelOptions, TunnelInit } from '@sentry/core'; import type { ServerRuntimeOptions } from '@sentry/core/server'; import type { NodeTransportOptions } from './transports'; @@ -73,7 +73,12 @@ export interface BaseNodeOptions extends ServerRuntimeOptions { * Configuration options for the Sentry Node SDK * @see @sentry/core Options for more information. */ -export interface NodeOptions extends Options, BaseNodeOptions {} +export interface NodeOptions extends Options, BaseNodeOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry Node SDK Client class 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 47522657a510..d25cbbed55bc 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, conversationIdIntegration, createStackParser, @@ -13,11 +12,16 @@ import { getIntegrationsToSetup, getVercelEnv, GLOBAL_OBJ, + _INTERNAL_warnIfClientIsActive, linkedErrorsIntegration, requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; -import { _INTERNAL_getReleaseFromCommitEnv as getReleaseFromCommitEnv, nodeStackLineParser } from '@sentry/core/server'; +import { + _INTERNAL_getReleaseFromCommitEnv as getReleaseFromCommitEnv, + nodeStackLineParser, + _INTERNAL_resolveServerTunnelOption as resolveServerTunnelOption, +} from '@sentry/core/server'; import { SentryPropagator, SentryTracerProvider, @@ -85,6 +89,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, diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index af568bbfe4bf..ff9427d18d57 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; +import type { ClientOptions, Options, ServerTunnelOptions, TracePropagationTargets, TunnelInit } from '@sentry/core'; import type { VercelEdgeClient } from './client'; import type { VercelEdgeTransportOptions } from './transports'; @@ -58,7 +58,12 @@ export interface BaseVercelEdgeOptions { * Configuration options for the Sentry VercelEdge SDK * @see @sentry/core Options for more information. */ -export interface VercelEdgeOptions extends Options, BaseVercelEdgeOptions {} +export interface VercelEdgeOptions extends Options, BaseVercelEdgeOptions { + /** + * See {@link Options.tunnel}. A server `init` also takes the server-only {@link ServerTunnelOptions}. + */ + tunnel?: TunnelInit; +} /** * Configuration options for the Sentry VercelEdge SDK Client class diff --git a/packages/vercel-edge/test/sdk.test.ts b/packages/vercel-edge/test/sdk.test.ts index f9c69cdac777..70397cac31f4 100644 --- a/packages/vercel-edge/test/sdk.test.ts +++ b/packages/vercel-edge/test/sdk.test.ts @@ -45,6 +45,15 @@ describe('getDefaultIntegrations', () => { }); describe('init', () => { + it('enables the managed tunnel for `tunnel: { allowedDsns }`', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: { allowedDsns: [browserDsn] } }); + + const options = client?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({ allowedDsns: [browserDsn] }); + }); + it('adds spanStreamingIntegration by default', () => { const client = init({}); const integrations = client?.getOptions().integrations;