diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 86fd2127c724..e1692463acd7 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -163,6 +163,7 @@ export { systemErrorIntegration, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, updateSpanName, vercelAIIntegration, withActiveSpan, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 994aabb838ce..f5877b600a13 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -151,6 +151,7 @@ export { spanToBaggageHeader, systemErrorIntegration, trpcMiddleware, + serveTunnelIntegration, updateSpanName, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index e29efcaeb599..130187badbb5 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,15 @@ 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 & { + /** + * Where to send envelopes instead of the Sentry ingest URL, see {@link TunnelInit}. + * + * @default undefined + */ + 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/utils/managedTunnel.ts b/packages/browser/src/utils/managedTunnel.ts new file mode 100644 index 000000000000..0a2a3644fd21 --- /dev/null +++ b/packages/browser/src/utils/managedTunnel.ts @@ -0,0 +1,43 @@ +import type { BrowserTunnelOptions } from '@sentry/core'; +import { + debug, + getTunnelPath, + makeDsn, + _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import type { BrowserOptions } from '../client'; + +/** + * Resolves `tunnel: true` or {@link BrowserTunnelOptions} to the managed tunnel's same-origin path. A string is left as + * written, so a tunnel the app serves itself gets the same requests as before. + */ +export function resolveBrowserTunnelOption( + options: BrowserOptions, +): Pick & { tunnel: string | undefined } { + const { tunnel, dsn, transportOptions } = options; + if (!tunnel || typeof tunnel === 'string') { + return { tunnel, transportOptions }; + } + + const tunnelOptions: BrowserTunnelOptions = tunnel === true ? {} : 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, + // CSRF protection (SvelteKit, Astro) rejects the default `text/plain` POST to the tunnel as a form submission. + headers: { 'Content-Type': 'application/x-sentry-envelope', ...transportOptions?.headers }, + }, + }; +} diff --git a/packages/browser/test/utils/managedTunnel.test.ts b/packages/browser/test/utils/managedTunnel.test.ts new file mode 100644 index 000000000000..c7c288276d4a --- /dev/null +++ b/packages/browser/test/utils/managedTunnel.test.ts @@ -0,0 +1,87 @@ +import { getTunnelPath } 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)); + }); + + it('sends to a managed tunnel as application/x-sentry-envelope so CSRF checks do not treat it as a form post', () => { + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel: true }).transportOptions).toEqual({ + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }); + expect( + resolveBrowserTunnelOption({ dsn: DSN, tunnel: true, transportOptions: { headers: { 'X-Custom': '1' } } }) + .transportOptions, + ).toEqual({ headers: { 'Content-Type': 'application/x-sentry-envelope', 'X-Custom': '1' } }); + }); + + 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.each(['https://proxy.example.com/tunnel', '/monitor'])( + 'keeps the string tunnel %s and its transport options untouched', + tunnel => { + const transportOptions = { headers: { 'x-custom': '1' } }; + + expect(resolveBrowserTunnelOption({ dsn: DSN, tunnel, transportOptions })).toEqual({ 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 sets the envelope content type 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), + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }), + ); + expect(transport).toHaveBeenLastCalledWith(expect.not.objectContaining({ headers: expect.anything() })); + }); +}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 3bb0bb8927d6..24ddbe3ec328 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -171,6 +171,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + serveTunnelIntegration, updateSpanName, supabaseIntegration, instrumentSupabaseClient, diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index ade64f83137f..ca760d2a6d0a 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -35,7 +35,13 @@ 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 { + /** + * A URL to send this server's own envelopes to instead of the Sentry ingest URL. To serve the managed tunnel for the + * browser, use `serveTunnelIntegration`. + */ + tunnel?: string; +} /** * Configuration options for the Sentry Bun SDK Client class diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index ee2171cd987c..ca9e75dbef64 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -540,6 +540,12 @@ interface BaseCloudflareOptions { */ export interface CloudflareOptions extends Options, BaseCloudflareOptions { ctx?: ExecutionContextCompat; + + /** + * A URL to send this server's own envelopes to instead of the Sentry ingest URL. To serve the managed tunnel for the + * browser, use `serveTunnelIntegration`. + */ + tunnel?: string; } /** diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index e1fb6e60cee3..dc7312cb3685 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -110,7 +110,7 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils'; export type { FlueOptions } from '@sentry/server-utils'; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index b30630a92843..29a7ea64b999 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -6,7 +6,11 @@ import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; -import { mysqlIntegration as exportedMysqlIntegration, spanStreamingIntegration } from '../src/'; +import { + mysqlIntegration as exportedMysqlIntegration, + spanStreamingIntegration, + serveTunnelIntegration, +} from '../src/'; describe('init', () => { beforeEach(() => { @@ -23,6 +27,18 @@ describe('init', () => { expect(client).toBeInstanceOf(CloudflareClient); }); + test('serves the managed tunnel with `serveTunnelIntegration`', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [serveTunnelIntegration({ 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/index.ts b/packages/core/src/index.ts index 1eb1d0ff4d72..ed705da266e7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,13 +101,12 @@ export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; -export type { HandleTunnelRequestOptions } from './utils/tunnel'; +export type { HandleTunnelRequestOptions } from './utils/tunnelForward'; +export { handleTunnelRequest } from './utils/tunnelForward'; export { applyTunnelDefaults as _INTERNAL_applyTunnelDefaults, getTunnelPath, - handleTunnelRequest, normalizeTunnelBasePath as _INTERNAL_normalizeTunnelBasePath, - TUNNEL_RESPONSE_HEADER, } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { diff --git a/packages/core/src/integrations/serveTunnel.ts b/packages/core/src/integrations/serveTunnel.ts new file mode 100644 index 000000000000..74b38499dff2 --- /dev/null +++ b/packages/core/src/integrations/serveTunnel.ts @@ -0,0 +1,33 @@ +import { defineIntegration } from '../integration'; +import type { IntegrationFn } from '../types/integration'; +import type { ServerTunnelOptions } from '../types/options'; +import { enableManagedTunnel, SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/managedTunnelDefaults'; +import { forwardTunnelRequest } from '../utils/tunnelForward'; + +const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { + return { + name: SERVE_TUNNEL_INTEGRATION_NAME, + forward: forwardTunnelRequest, + setup(client) { + enableManagedTunnel(client, options, forwardTunnelRequest); + }, + }; +}) satisfies IntegrationFn; + +/** + * Serves the managed tunnel, so a browser `init` with `tunnel: true` sends its envelopes through the app's own origin + * instead of the Sentry ingest URL, where ad-blockers can't drop them. The meta-framework SDKs answer the tunnel + * requests from their request hooks. + * + * By default the tunnel path is derived from the DSN, under the app's base path, and only envelopes for the server's + * own DSN are forwarded. A `path` is served as written. + * + * @example + * ```js + * Sentry.init({ + * dsn: '__DSN__', + * integrations: [Sentry.serveTunnelIntegration()], + * }); + * ``` + */ +export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 594318ca6e4e..6452fc7b81a9 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -11,8 +11,9 @@ export { getTunnelPaths as _INTERNAL_getTunnelPaths, handleTunnelRequestIfMatched, isTunnelRequest, - resolveServerTunnelOption as _INTERNAL_resolveServerTunnelOption, } from './utils/tunnel'; +export { forwardTunnelRequest as _INTERNAL_forwardTunnelRequest } from './utils/tunnelForward'; +export { serveTunnelIntegration } from './integrations/serveTunnel'; export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults as _INTERNAL_setManagedTunnelDefaults, diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 4f8bf39f117a..443a9f1e59d5 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -374,7 +374,7 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; -/** Managed tunnel options shared by the browser and the server `init`, see `tunnel`. */ +/** Managed tunnel options shared by the browser `tunnel` option and the server `serveTunnelIntegration`. */ export interface TunnelOptions { - /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + /** A fixed tunnel path instead of the one derived from the DSN, used as written without `basePath`. */ path?: string; - /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and on the server. */ basePath?: string; } -/** Managed tunnel options for the browser `init`. */ +/** Managed tunnel options for the browser `tunnel` option. */ export interface BrowserTunnelOptions extends TunnelOptions { /** Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ trailingSlash?: boolean; } -/** Managed tunnel options for a server `init`. */ +/** Options for the server `serveTunnelIntegration`. */ export interface ServerTunnelOptions extends TunnelOptions { /** DSNs to forward besides the server's own, e.g. a different browser DSN. */ allowedDsns?: string[]; } /** - * The `tunnel` option, where to send envelopes instead of the Sentry ingest URL: + * The browser `tunnel` option, where to send envelopes instead of the Sentry ingest URL: * - * - A URL, e.g. `https://example.com/tunnel`, sends envelopes to your own tunnel endpoint. - * - A relative path, e.g. `/monitor`, enables the managed tunnel at that fixed path. The browser sends envelopes to - * it and the server SDK serves it, so set the same path in both `init`s. - * - `true` enables the managed tunnel at a same-origin path derived from the DSN. Set it in both the browser and the - * server `init`: the browser sends envelopes to that path and the server SDK forwards them to Sentry. This keeps - * ad-blockers from dropping events. - * - Options, like `true`, with the browser or server options of the managed tunnel. + * - A URL or a relative path, e.g. `/monitor`, sends envelopes to it as written, to a tunnel the app serves itself. + * - `true` sends envelopes to the managed tunnel at a same-origin path derived from the DSN, which the server serves + * with `serveTunnelIntegration`. This keeps ad-blockers from dropping events. + * - {@link BrowserTunnelOptions}, like `true`, with options of the managed tunnel. */ export type TunnelInit = string | true | T; /** 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. The browser also takes `true` and options for the managed + * tunnel, see {@link TunnelInit}. Server SDKs only take a URL. + * + * @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/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts index 6b775cce247f..f9ee1c933169 100644 --- a/packages/core/src/utils/managedTunnelDefaults.ts +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -1,13 +1,14 @@ import type { Client } from '../client'; +import type { Integration } from '../types/integration'; import { getClient } from '../currentScopes'; import type { IgnoreSpanFilter, ServerTunnelOptions } from '../types/options'; +import type { TunnelForwarder } from './tunnel'; import { getTunnelIgnoreSpanFilters, normalizeTunnelBasePath, resolveAllowedDsns, resolveTunnelPaths, setServedTunnel, - stripBasePathFromTunnelPath, } from './tunnel'; /** @@ -19,12 +20,25 @@ interface ManagedTunnelDefaults { * path is `basePath`, so it has none of its own. */ tunnel?: Omit; - /** The path prefix the app is served under. Applies however the tunnel was enabled, unless `init` set its own. */ + /** Forwards the requests of the tunnel `tunnel` enables. */ + forward?: TunnelForwarder; + /** + * The path prefix the app is served under. Applies to the DSN-derived path however the tunnel was enabled, unless + * `init` set its own. A fixed `path` is served as written. + */ basePath?: string; } +/** + * The name of `serveTunnelIntegration`, which carries the forwarder. Kept here so this module doesn't import the + * integration, and with it the forwarder, into apps that don't use it. + */ +export const SERVE_TUNNEL_INTEGRATION_NAME = 'ServeTunnel'; + let managedTunnelDefaults: ManagedTunnelDefaults | undefined; +const tunnelForwarders = new WeakMap(); + // So re-applying defaults replaces a client's filters instead of adding more. const installedSpanFilters = new WeakMap(); @@ -35,31 +49,53 @@ const installedSpanFilters = new WeakMap(); export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { managedTunnelDefaults = { ...managedTunnelDefaults, - ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(defaults.tunnel && { tunnel: defaults.tunnel, forward: defaults.forward }), ...(defaults.basePath !== undefined && { basePath: defaults.basePath }), }; applyManagedTunnelDefaults(getClient()); } /** - * Resolves a server client's managed tunnel (from `init` or the defaults), computes its paths and allowed DSNs, and - * installs its span filters. Runs when a server client is created and from {@link setManagedTunnelDefaults}. + * Enables the managed tunnel on a server client, from `serveTunnelIntegration`. + */ +export function enableManagedTunnel( + client: Client, + tunnelOptions: ServerTunnelOptions, + forward: TunnelForwarder, +): void { + client.getOptions()._managedTunnel = { ...tunnelOptions }; + tunnelForwarders.set(client, forward); + applyManagedTunnelDefaults(client); +} + +/** + * Resolves a server client's managed tunnel (from `serveTunnelIntegration` or the defaults), computes its paths and + * allowed DSNs, and installs its span filters. Runs when a server client is created and from + * {@link setManagedTunnelDefaults}. */ export function applyManagedTunnelDefaults(client: Client | undefined): void { const options = client?.getOptions(); const tunnelOptions: ServerTunnelOptions | undefined = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; - if (!client || !options || !tunnelOptions) { + // A framework SDK can bundle its own copy of this module, which only reaches the forwarder through the client. + const forward = + client && + (tunnelForwarders.get(client) ?? + client.getIntegrationByName?.(SERVE_TUNNEL_INTEGRATION_NAME) + ?.forward ?? + managedTunnelDefaults?.forward); + if (!client || !options || !tunnelOptions || !forward) { return; } const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); - const resolved = stripBasePathFromTunnelPath( - tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, - ); + const resolved = + tunnelOptions.basePath === undefined && !tunnelOptions.path && basePath + ? { ...tunnelOptions, basePath } + : tunnelOptions; options._managedTunnel = resolved; const allowedDsns = resolveAllowedDsns(resolved.allowedDsns, client.getDsn()); const paths = resolveTunnelPaths(resolved, allowedDsns); - setServedTunnel(client, { paths, allowedDsns }); + setServedTunnel(client, { paths, allowedDsns, forward }); const previousFilters = installedSpanFilters.get(client) ?? []; const spanFilters = getTunnelIgnoreSpanFilters(resolved, paths); diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 36f78d91f291..5c0c805e531a 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,21 +1,24 @@ -import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; import type { Client } from '../client'; import { getClient } from '../currentScopes'; -import { suppressTracing } from '../tracing/trace'; import { URL_PATH } from '@sentry/conventions/attributes'; import type { DsnComponents, DsnLike } from '../types/dsn'; import type { IgnoreSpanFilter, ServerTunnelOptions, TunnelInit, TunnelOptions } from '../types/options'; import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; -import { debug } from './debug-logger'; import { dsnToString, makeDsn } from './dsn'; import { parseUrl } from './url'; /** - * A server client's tunnel paths and allowed DSNs, computed once per client. + * Forwards a tunnel request to Sentry. Only `serveTunnelIntegration` brings it in, so apps without a tunnel don't bundle it. + */ +export type TunnelForwarder = (request: Request, allowedDsns: Array) => Promise; + +/** + * A server client's tunnel paths, allowed DSNs and forwarder, computed once per client. */ interface ServedTunnel { paths: string[]; allowedDsns: string[]; + forward: TunnelForwarder; } const servedTunnels = new WeakMap(); @@ -33,52 +36,27 @@ function getServedTunnel(): ServedTunnel | undefined { return client && servedTunnels.get(client); } -export interface HandleTunnelRequestOptions { - /** Incoming request containing the Sentry envelope as its body */ - request: Request; - /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ - allowedDsns?: Array; -} - /** - * Splits a server `tunnel` option: a URL stays the transport tunnel, anything else enables the managed tunnel. - */ -export function resolveServerTunnelOption(tunnel: TunnelInit | undefined): { - tunnel: string | undefined; - _managedTunnel?: ServerTunnelOptions; -} { - if (!tunnel) { - return { tunnel }; - } - - if (typeof tunnel === 'string') { - // A relative path can't be a transport URL on the server, so it's the path to serve. - return tunnel.startsWith('/') ? { tunnel: undefined, _managedTunnel: { path: tunnel } } : { tunnel }; - } - - return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel }; -} - -/** - * Fills in framework defaults (like the base path) for a managed tunnel. User options win; URLs pass through. + * Fills in framework defaults (like the base path) for a managed tunnel on the DSN-derived path. User options win. + * A path the user wrote, as a string or `path`, is used as written, so a tunnel served outside the app keeps working. * The base path is normalized, and a root one is no default at all. */ export function applyTunnelDefaults( tunnel: TunnelInit | undefined, defaults: TunnelOptions | undefined, ): TunnelInit | undefined { + if (!tunnel || typeof tunnel === 'string' || (tunnel !== true && tunnel.path)) { + return tunnel; + } + const { basePath, ...rest } = defaults ?? {}; const normalizedBasePath = normalizeTunnelBasePath(basePath); const resolvedDefaults: TunnelOptions = normalizedBasePath ? { ...rest, basePath: normalizedBasePath } : rest; - if (!tunnel || !Object.keys(resolvedDefaults).length) { + if (!Object.keys(resolvedDefaults).length) { return tunnel; } - if (typeof tunnel === 'string') { - return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...resolvedDefaults, path: tunnel }) : tunnel; - } - - return stripBasePathFromTunnelPath({ ...resolvedDefaults, ...(tunnel === true ? {} : tunnel) }); + return { ...resolvedDefaults, ...(tunnel === true ? {} : tunnel) }; } /** @@ -176,224 +154,12 @@ export function isTunnelRequest(request: TunnelRequestLike): boolean { * Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle. */ export function handleTunnelRequestIfMatched(request: Request): Promise | undefined { - const allowedDsns = getServedTunnel()?.allowedDsns; - // Without a DSN to forward to, the app's 404 makes the browser send directly. - if (!allowedDsns?.length || !isTunnelRequest(request)) { + const servedTunnel = getServedTunnel(); + if (!servedTunnel?.allowedDsns.length || !isTunnelRequest(request)) { return undefined; } - return forwardTunnelRequest(request, allowedDsns); -} - -/** - * Core Sentry tunnel handler - framework agnostic. - * - * Validates the envelope DSN against allowed DSNs, then forwards the - * envelope to the Sentry ingest endpoint. - * - * @returns A `Response` — either the upstream Sentry response on success, or an error response. - */ -export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); -} - -/** - * Marks responses from the tunnel handler, so the browser can tell them from e.g. a static host's `index.html`. - */ -export const TUNNEL_RESPONSE_HEADER = 'X-Sentry-Tunnel'; - -function markTunnelResponse(response: Response): Response { - const headers = new Headers(response.headers); - // `fetch` already decoded the body, so its original encoding and length no longer describe it. - headers.delete('content-encoding'); - headers.delete('content-length'); - headers.set(TUNNEL_RESPONSE_HEADER, '1'); - - return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); -} - -async function forwardTunnelRequest(request: Request, allowedDsns: Array): Promise { - return markTunnelResponse(await forwardEnvelope(request, allowedDsns)); -} - -async function forwardEnvelope(request: Request, allowedDsns: Array): Promise { - if (allowedDsns.length === 0) { - return new Response('Tunnel not configured', { status: 500 }); - } - - // The HTTP server ends the body at `Content-Length`, so the header bounds what the forward streams. - const contentLength = Number(request.headers.get('content-length')); - if (!contentLength) { - return new Response('Length required', { status: 411 }); - } - - const reader = request.body?.getReader(); - // A client aborting mid-upload makes the read throw. - const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); - if (!reader || !head) { - return new Response('Invalid envelope', { status: 400 }); - } - - const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); - if (typeof sentryIngestUrl !== 'string') { - reader.cancel().catch(() => undefined); - - return sentryIngestUrl; - } - - try { - return await suppressTracing(() => - fetch(sentryIngestUrl, { - method: 'POST', - headers: forwardHeaders(request), - body: prependToStream(head.read, reader), - duplex: 'half', - } as RequestInit), - ); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); - } -} - -/** - * Returns the ingest URL for an envelope header line, or the rejecting response. - */ -function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { - let envelopeHeader: { dsn?: string } | null; - try { - envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); - } catch { - return new Response('Invalid envelope', { status: 400 }); - } - - if (!envelopeHeader) { - return new Response('Invalid envelope: missing header', { status: 400 }); - } - - const dsn = envelopeHeader.dsn; - if (!dsn) { - return new Response('Invalid envelope: missing DSN', { status: 400 }); - } - - // SECURITY: Validate that the envelope DSN matches one of the allowed DSNs - // This prevents SSRF attacks where attackers send crafted envelopes - // with malicious DSNs pointing to arbitrary hosts - const isAllowed = allowedDsns.some(allowed => allowed === dsn); - - if (!isAllowed) { - debug.warn(`Sentry tunnel: rejected request with unauthorized DSN (${dsn})`); - return new Response('DSN not allowed', { status: 403 }); - } - - const dsnComponents = makeDsn(dsn); - if (!dsnComponents) { - debug.warn(`Could not extract DSN Components from: ${dsn}`); - return new Response('Invalid DSN', { status: 403 }); - } - - return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); -} - -/** - * A header line longer than this is not an envelope. - */ -const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; - -/** - * Client headers Relay reads from a browser's own request: the user agent and its client hints to tell the browser, - * and `Origin` or `Referer` for the project's allowed domains check, which Relay skips without them. - */ -const FORWARDED_CLIENT_HEADERS = [ - 'user-agent', - 'sec-ch-ua', - 'sec-ch-ua-model', - 'sec-ch-ua-platform', - 'sec-ch-ua-platform-version', - 'origin', - 'referer', -]; - -/** - * Reads the body up to the envelope header line. Returns the line and every byte read, or `undefined`. - */ -async function readEnvelopeHeaderLine( - reader: ReadableStreamDefaultReader, -): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { - let read: Uint8Array = new Uint8Array(0); - - while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { - const { done, value } = await reader.read(); - if (done) { - return read.length ? { line: read, read } : undefined; - } - - const searchFrom = read.length; - read = concatBytes(read, value); - - const newline = read.indexOf(0x0a, searchFrom); - if (newline !== -1) { - return { line: read.subarray(0, newline), read }; - } - } - - await reader.cancel(); - - return undefined; -} - -function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { - const merged = new Uint8Array(a.length + b.length); - merged.set(a); - merged.set(b, a.length); - - return merged; -} - -function prependToStream( - prefix: Uint8Array, - reader: ReadableStreamDefaultReader, -): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(prefix); - }, - async pull(controller) { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - return; - } - - controller.enqueue(value); - }, - cancel(reason) { - return reader.cancel(reason); - }, - }); -} - -function forwardHeaders(request: Request): Record { - // No `Content-Length`: some runtimes reject it on a streamed body (Next.js edge in dev). - const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; - - // Relay would otherwise attribute the server's IP. Same precedence Relay uses. - const clientIp = - request.headers.get('cf-connecting-ip') || - request.headers.get('x-vercel-forwarded-for') || - request.headers.get('x-forwarded-for'); - if (clientIp) { - headers['X-Sentry-Forwarded-For'] = clientIp; - } - - for (const name of FORWARDED_CLIENT_HEADERS) { - const value = request.headers.get(name); - if (value) { - headers[name] = value; - } - } - - return headers; + return servedTunnel.forward(request, servedTunnel.allowedDsns); } /** @@ -414,15 +180,6 @@ export function resolveAllowedDsns( return [...dsns]; } -/** - * Makes a fixed `path` relative to `basePath`, so one written with the base path isn't served under it twice. - */ -export function stripBasePathFromTunnelPath(tunnelOptions: T): T { - const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); - - return path ? { ...tunnelOptions, path } : tunnelOptions; -} - /** * Returns the path below `basePath`, or `undefined` outside it. */ diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts new file mode 100644 index 000000000000..5334c53fa2ac --- /dev/null +++ b/packages/core/src/utils/tunnelForward.ts @@ -0,0 +1,221 @@ +import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { getClient } from '../currentScopes'; +import { suppressTracing } from '../tracing/trace'; +import { debug } from './debug-logger'; +import { makeDsn } from './dsn'; +import { resolveAllowedDsns } from './tunnel'; + +export interface HandleTunnelRequestOptions { + /** Incoming request containing the Sentry envelope as its body */ + request: Request; + /** DSNs whose envelopes may be forwarded, in addition to the DSN of the active client. */ + allowedDsns?: Array; +} + +/** + * Core Sentry tunnel handler - framework agnostic. + * + * Validates the envelope DSN against allowed DSNs, then forwards the + * envelope to the Sentry ingest endpoint. + * + * @returns A `Response` — either the upstream Sentry response on success, or an error response. + */ +export function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + return forwardTunnelRequest(options.request, resolveAllowedDsns(options.allowedDsns, getClient()?.getDsn())); +} + +function withDecodedBodyHeaders(response: Response): Response { + const headers = new Headers(response.headers); + // `fetch` already decoded the body, so its original encoding and length no longer describe it. + headers.delete('content-encoding'); + headers.delete('content-length'); + + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +/** + * Forwards an envelope to Sentry if its DSN is one of `allowedDsns`, otherwise answers with the rejecting response. + */ +export async function forwardTunnelRequest(request: Request, allowedDsns: Array): Promise { + return withDecodedBodyHeaders(await forwardEnvelope(request, allowedDsns)); +} + +async function forwardEnvelope(request: Request, allowedDsns: Array): Promise { + if (allowedDsns.length === 0) { + return new Response('Tunnel not configured', { status: 500 }); + } + + // The HTTP server ends the body at `Content-Length`, so the header bounds what the forward streams. + const contentLength = Number(request.headers.get('content-length')); + if (!contentLength) { + return new Response('Length required', { status: 411 }); + } + + const reader = request.body?.getReader(); + // A client aborting mid-upload makes the read throw. + const head = reader && (await readEnvelopeHeaderLine(reader).catch(() => undefined)); + if (!reader || !head) { + return new Response('Invalid envelope', { status: 400 }); + } + + const sentryIngestUrl = resolveIngestUrl(head.line, allowedDsns); + if (typeof sentryIngestUrl !== 'string') { + reader.cancel().catch(() => undefined); + + return sentryIngestUrl; + } + + try { + return await suppressTracing(() => + fetch(sentryIngestUrl, { + method: 'POST', + headers: forwardHeaders(request), + body: prependToStream(head.read, reader), + duplex: 'half', + } as RequestInit), + ); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} + +/** + * Returns the ingest URL for an envelope header line, or the rejecting response. + */ +function resolveIngestUrl(headerLine: Uint8Array, allowedDsns: Array): string | Response { + let envelopeHeader: { dsn?: string } | null; + try { + envelopeHeader = JSON.parse(new TextDecoder().decode(headerLine)); + } catch { + return new Response('Invalid envelope', { status: 400 }); + } + + if (!envelopeHeader) { + return new Response('Invalid envelope: missing header', { status: 400 }); + } + + const dsn = envelopeHeader.dsn; + if (!dsn) { + return new Response('Invalid envelope: missing DSN', { status: 400 }); + } + + // SECURITY: Validate that the envelope DSN matches one of the allowed DSNs + // This prevents SSRF attacks where attackers send crafted envelopes + // with malicious DSNs pointing to arbitrary hosts + const isAllowed = allowedDsns.some(allowed => allowed === dsn); + + if (!isAllowed) { + debug.warn(`Sentry tunnel: rejected request with unauthorized DSN (${dsn})`); + return new Response('DSN not allowed', { status: 403 }); + } + + const dsnComponents = makeDsn(dsn); + if (!dsnComponents) { + debug.warn(`Could not extract DSN Components from: ${dsn}`); + return new Response('Invalid DSN', { status: 403 }); + } + + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); +} + +/** + * A header line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * Client headers Relay reads from a browser's own request: the user agent and its client hints to tell the browser, + * and `Origin` or `Referer` for the project's allowed domains check, which Relay skips without them. + */ +const FORWARDED_CLIENT_HEADERS = [ + 'user-agent', + 'sec-ch-ua', + 'sec-ch-ua-model', + 'sec-ch-ua-platform', + 'sec-ch-ua-platform-version', + 'origin', + 'referer', +]; + +/** + * Reads the body up to the envelope header line. Returns the line and every byte read, or `undefined`. + */ +async function readEnvelopeHeaderLine( + reader: ReadableStreamDefaultReader, +): Promise<{ line: Uint8Array; read: Uint8Array } | undefined> { + let read: Uint8Array = new Uint8Array(0); + + while (read.length <= MAX_ENVELOPE_HEADER_BYTES) { + const { done, value } = await reader.read(); + if (done) { + return read.length ? { line: read, read } : undefined; + } + + const searchFrom = read.length; + read = concatBytes(read, value); + + const newline = read.indexOf(0x0a, searchFrom); + if (newline !== -1) { + return { line: read.subarray(0, newline), read }; + } + } + + await reader.cancel(); + + return undefined; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const merged = new Uint8Array(a.length + b.length); + merged.set(a); + merged.set(b, a.length); + + return merged; +} + +function prependToStream( + prefix: Uint8Array, + reader: ReadableStreamDefaultReader, +): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(prefix); + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + controller.enqueue(value); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); +} + +function forwardHeaders(request: Request): Record { + // No `Content-Length`: some runtimes reject it on a streamed body (Next.js edge in dev). + const headers: Record = { 'Content-Type': 'application/x-sentry-envelope' }; + + // Relay would otherwise attribute the server's IP. Same precedence Relay uses. + const clientIp = + request.headers.get('cf-connecting-ip') || + request.headers.get('x-vercel-forwarded-for') || + request.headers.get('x-forwarded-for'); + if (clientIp) { + headers['X-Sentry-Forwarded-For'] = clientIp; + } + + for (const name of FORWARDED_CLIENT_HEADERS) { + const value = request.headers.get(name); + if (value) { + headers[name] = value; + } + } + + return headers; +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 33ce33115a3a..05dfa054e91b 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -9,14 +9,14 @@ import { getTunnelPath, getTunnelIgnoreSpanFilters, getTunnelPaths, - handleTunnelRequest, handleTunnelRequestIfMatched, isTunnelRequest, normalizeTunnelBasePath, resolveAllowedDsns, - resolveServerTunnelOption, resolveTunnelPaths, } from '../../../src/utils/tunnel'; +import { forwardTunnelRequest, handleTunnelRequest } from '../../../src/utils/tunnelForward'; +import { serveTunnelIntegration } from '../../../src/integrations/serveTunnel'; import { _INTERNAL_resetManagedTunnelDefaults, applyManagedTunnelDefaults, @@ -24,10 +24,11 @@ import { } from '../../../src/utils/managedTunnelDefaults'; import type { DsnComponents } from '../../../src/types/dsn'; import type { IgnoreSpanFilter, ServerTunnelOptions } from '../../../src/types/options'; -import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { getDefaultTestClientOptions } from '../../mocks/client'; import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const SERVER_DSN = 'https://server@o7.ingest.sentry.io/7'; /** The `url.path` filter, the first of the tunnel span filters. */ function getTunnelIgnoreSpanFilter( @@ -129,7 +130,7 @@ describe('handleTunnelRequest', () => { expect(await result.text()).toBe('ok'); }); - it("marks every response it returns as the tunnel's, so the browser can tell it from a static host", async () => { + it('drops the encoding of the body `fetch` already decoded', async () => { fetchMock.mockResolvedValueOnce( new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), ); @@ -138,17 +139,10 @@ describe('handleTunnelRequest', () => { request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN], }); - const rejected = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: 'https://public@other.ingest.sentry.io/1' }), - allowedDsns: [TEST_DSN], - }); - expect(forwarded.headers.get('X-Sentry-Tunnel')).toBe('1'); expect(forwarded.headers.get('Content-Type')).toBe('application/json'); - // `fetch` already decoded the body, so the original encoding would make the browser decode it twice. + // The original encoding would make the browser decode it twice. expect(forwarded.headers.get('Content-Encoding')).toBeNull(); - expect(rejected.status).toBe(403); - expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); }); it.each([ @@ -680,11 +674,20 @@ describe('getTunnelIgnoreSpanFilter', () => { }); }); -describe('server client with the managed tunnel enabled', () => { +describe('serveTunnelIntegration', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + it('ignores the server spans of tunnel requests and keeps user filters', () => { const client = new ServerRuntimeClient( - getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + getDefaultTestClientOptions({ + dsn: TEST_DSN, + integrations: [serveTunnelIntegration()], + ignoreSpans: ['user-filter'], + }), ); + client.init(); const ignoreSpans = client.getOptions().ignoreSpans!; expect(ignoreSpans[0]).toBe('user-filter'); @@ -693,35 +696,49 @@ describe('server client with the managed tunnel enabled', () => { ).toBe(true); }); - it('leaves ignoreSpans alone without the managed tunnel, and in non-server clients', () => { - expect( - new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })).getOptions().ignoreSpans, - ).toBeUndefined(); - expect( - new TestClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })).getOptions().ignoreSpans, - ).toBeUndefined(); - }); -}); + it('leaves ignoreSpans alone without it', () => { + const client = new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN })); + client.init(); -describe('resolveServerTunnelOption', () => { - it('keeps a URL as the transport tunnel', () => { - expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + expect(client.getOptions().ignoreSpans).toBeUndefined(); }); - it('enables the managed tunnel for `true` and options, and leaves the transport untunneled', () => { - expect(resolveServerTunnelOption(true)).toEqual({ tunnel: undefined, _managedTunnel: {} }); - expect(resolveServerTunnelOption({ allowedDsns: [TEST_DSN] })).toEqual({ - tunnel: undefined, - _managedTunnel: { allowedDsns: [TEST_DSN] }, - }); - }); + it('serves the tunnel from a separately bundled copy of core, which reaches the forwarder through the client', async () => { + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }), + ); + setCurrentClient(client); + client.init(); + + vi.resetModules(); + const bundledDefaults = await import('../../../src/utils/managedTunnelDefaults'); + const bundledTunnel = await import('../../../src/utils/tunnel'); + bundledDefaults.setManagedTunnelDefaults({ basePath: '/app' }); + + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(new Response('forwarded'))), + ); + const response = await bundledTunnel.handleTunnelRequestIfMatched( + makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost/app${getTunnelPath(TEST_DSN)}`), + ); + vi.unstubAllGlobals(); - it('serves a relative path instead of using it as the transport tunnel', () => { - expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + expect(bundledTunnel.getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); + expect(await response?.text()).toBe('forwarded'); }); - it('leaves everything off when unset', () => { - expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + it('serves nothing for a `_managedTunnel` set without it, since only the integration brings the forwarder', () => { + const client = new ServerRuntimeClient(getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {} })); + setCurrentClient(client); + client.init(); + + expect(getTunnelPaths()).toEqual([]); + expect( + handleTunnelRequestIfMatched( + makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`), + ), + ).toBeUndefined(); }); }); @@ -737,20 +754,9 @@ describe('applyTunnelDefaults', () => { expect(applyTunnelDefaults({ basePath: '/custom' }, { basePath: '/app' })).toEqual({ basePath: '/custom' }); }); - it('treats a relative path as a managed tunnel with that path', () => { - expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); - }); - - it('does not add the base path again to a path that already starts with it', () => { - expect(applyTunnelDefaults('/app/monitor', { basePath: '/app' })).toEqual({ basePath: '/app', path: '/monitor' }); - expect(applyTunnelDefaults({ path: '/app/monitor' }, { basePath: '/app' })).toEqual({ - basePath: '/app', - path: '/monitor', - }); - expect(applyTunnelDefaults('/application/monitor', { basePath: '/app' })).toEqual({ - basePath: '/app', - path: '/application/monitor', - }); + it('leaves a path the user wrote as written', () => { + expect(applyTunnelDefaults('/monitor', { basePath: '/app' })).toBe('/monitor'); + expect(applyTunnelDefaults({ path: '/monitor' }, { basePath: '/app' })).toEqual({ path: '/monitor' }); }); it('leaves a URL, an unset tunnel and empty defaults alone', () => { @@ -802,15 +808,15 @@ describe('setManagedTunnelDefaults', () => { ); }); - it('does not add the base path again to a fixed path that already starts with it', () => { - setTestClient(TEST_DSN, { path: '/app/monitor' }); + it('serves a fixed path as written, outside the base path', () => { + setTestClient(TEST_DSN, { path: '/monitor' }); const client = getCurrentScope().getClient()!; setManagedTunnelDefaults({ basePath: '/app' }); - expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app', path: '/monitor' }); - expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(true); - expect(isTunnelRequest({ method: 'POST', url: '/app/app/monitor' })).toBe(false); + expect(client.getOptions()._managedTunnel).toEqual({ path: '/monitor' }); + expect(isTunnelRequest({ method: 'POST', url: '/monitor' })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: '/app/monitor' })).toBe(false); }); it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { @@ -833,33 +839,37 @@ describe('setManagedTunnelDefaults', () => { }); it('enables the tunnel on clients created later, unless their `init` set one', () => { - setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, forward: forwardTunnelRequest, basePath: '/app' }); setTestClient(TEST_DSN); const laterClient = getCurrentScope().getClient()!; applyManagedTunnelDefaults(laterClient); - expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor' }); expect(laterClient.getOptions().ignoreSpans).toContainEqual( - getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, makeDsn(TEST_DSN)), + getTunnelIgnoreSpanFilter({ path: '/monitor' }, makeDsn(TEST_DSN)), ); setTestClient(TEST_DSN, { path: '/from-init' }); const initClient = getCurrentScope().getClient()!; applyManagedTunnelDefaults(initClient); - expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init', basePath: '/app' }); + expect(initClient.getOptions()._managedTunnel).toEqual({ path: '/from-init' }); }); }); function setTestClient(dsn: string | undefined, managedTunnel?: ServerTunnelOptions): void { - setCurrentClient(new ServerRuntimeClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn, integrations: managedTunnel ? [serveTunnelIntegration(managedTunnel)] : [] }), + ); + setCurrentClient(client); + client.init(); } -/** `isTunnelRequest` for a server client with this managed tunnel and no DSN of its own. */ +/** `isTunnelRequest` for a server client with this managed tunnel, whose own DSN none of the tests request. */ function isTunnelRequestFor( request: Parameters[0], managedTunnel: ServerTunnelOptions, ): boolean { - setTestClient(undefined, managedTunnel); + setTestClient(SERVER_DSN, managedTunnel); return isTunnelRequest(request); } diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 59b68d31034c..f0efd1331582 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -111,7 +111,7 @@ export { consoleLoggingIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; export { instrumentPostgresJsSql, prismaIntegration, diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts index 9e5a8cf53cd6..9c0e9d66a25f 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -56,7 +56,13 @@ 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 { + /** + * A URL to send this server's own envelopes to instead of the Sentry ingest URL. To serve the managed tunnel for the + * browser, use `serveTunnelIntegration`. + */ + tunnel?: string; +} /** * Configuration options for the Sentry Deno SDK Client class diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 2c4cb5726769..3965a6b8113e 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -147,6 +147,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + serveTunnelIntegration, updateSpanName, supabaseIntegration, systemErrorIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 7cc711e110f0..37cd9f5365d2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -175,7 +175,7 @@ export { featureFlagsIntegration, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; export type { Breadcrumb, diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 220c194c6546..dd7ef72b3914 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -73,7 +73,13 @@ 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 { + /** + * A URL to send this server's own envelopes to instead of the Sentry ingest URL. To serve the managed tunnel for the + * browser, use `serveTunnelIntegration`. + */ + tunnel?: string; +} /** * 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..09b0e1983d89 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -3,7 +3,7 @@ import { debug, originalConsoleMethods, SDK_VERSION } from '@sentry/core'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; import * as SentryServerUtils from '@sentry/server-utils'; import { afterEach, beforeEach, describe, expect, it, type Mock, type MockInstance, vi } from 'vitest'; -import { close, getClient, NodeClient } from '../../src/'; +import { close, getClient, NodeClient, serveTunnelIntegration } from '../../src/'; import { init } from '../../src/sdk'; import { cleanupOtel } from '../helpers/mockSdkInit'; @@ -40,6 +40,24 @@ describe('init()', () => { vi.clearAllMocks(); }); + describe('tunnel', () => { + it('serves the managed tunnel with `serveTunnelIntegration` and sends its own envelopes directly', () => { + init({ dsn: PUBLIC_DSN, integrations: [serveTunnelIntegration()] }); + + const options = getClient()?.getOptions(); + expect(options?.tunnel).toBeUndefined(); + expect(options?._managedTunnel).toEqual({}); + }); + + it('keeps a `tunnel` string as the URL 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/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 7b8b29d46a02..582607b00d58 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -121,6 +121,7 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts index a51f4dd21aba..3c92bd0a05b3 100644 --- a/packages/remix/src/server/sdk.ts +++ b/packages/remix/src/server/sdk.ts @@ -34,7 +34,7 @@ export function init(options: RemixOptions): NodeClient | undefined { options.defaultIntegrations ??= getRemixDefaultIntegrations(options); - const client = nodeInit(options); + const client = nodeInit(options as NodeOptions); instrumentServer(); diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 55c0f48ba010..2b12f240379c 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -126,6 +126,7 @@ export { systemErrorIntegration, tediousIntegration, trpcMiddleware, + serveTunnelIntegration, updateSpanName, withActiveSpan, withIsolationScope, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index d1f92c146338..47abe7da7218 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,7 +105,7 @@ export { withStreamedSpan, spanStreamingIntegration, } from '@sentry/core'; -export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; +export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server'; export { openTelemetryIntegration, getOtlpTracesEndpoint, diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index af568bbfe4bf..f03f63260bc3 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -58,7 +58,13 @@ 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 { + /** + * A URL to send this server's own envelopes to instead of the Sentry ingest URL. To serve the managed tunnel for the + * browser, use `serveTunnelIntegration`. + */ + tunnel?: string; +} /** * 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..ac3c0d436a38 100644 --- a/packages/vercel-edge/test/sdk.test.ts +++ b/packages/vercel-edge/test/sdk.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { close, getClient, getCurrentScope, getDefaultIntegrations, init, spanStreamingIntegration } from '../src'; +import { + close, + getClient, + getCurrentScope, + getDefaultIntegrations, + init, + spanStreamingIntegration, + serveTunnelIntegration, +} from '../src'; import { type Event, type Integration, originalConsoleMethods } from '@sentry/core'; afterEach(() => { @@ -45,6 +53,18 @@ describe('getDefaultIntegrations', () => { }); describe('init', () => { + it('serves the managed tunnel with `serveTunnelIntegration`', () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + const client = init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [serveTunnelIntegration({ 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;