diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aae98061c25d..b7172593df2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { safeCallback } from './utils/safeCallback'; export { parameterize, fmt } from './utils/parameterize'; export type { HandleTunnelRequestOptions } from './utils/tunnel'; -export { handleTunnelRequest } from './utils/tunnel'; +export { + applyTunnelDefaults, + getTunnelPath, + handleTunnelRequest, + normalizeTunnelBasePath, + TUNNEL_RESPONSE_HEADER, +} from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; export { convertSpanLinksForEnvelope, @@ -415,7 +421,7 @@ export type { HttpHeadersCollection, ResolvedDataCollection, } from './types/datacollection'; -export type { ClientOptions, CoreOptions as Options } from './types/options'; +export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options'; export type { Package } from './types/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics'; export type { diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts index 5cf7a0489719..cc57d5edca45 100644 --- a/packages/core/src/integrations/fetch.ts +++ b/packages/core/src/integrations/fetch.ts @@ -5,6 +5,7 @@ import { getClient } from '../currentScopes'; import { instrumentFetchRequest } from '../fetch'; import { defineIntegration } from '../integration'; import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import { isTracingSuppressed } from '../tracing/trace'; import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; import type { HandlerDataFetch } from '../types/instrument'; import type { Integration, IntegrationFn } from '../types/integration'; @@ -92,7 +93,8 @@ export function createFetchIntegration({ return; } - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + // Like Node's http and undici instrumentation: SDK-internal requests leave no breadcrumb. + if (isTracingSuppressed() || isSentryRequestUrl(handlerData.fetchData.url, client)) { return; } diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 159c4aeb3b93..9dcc4512bea4 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -21,6 +21,7 @@ import { uuid4 } from './utils/misc'; import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; +import { applyManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -58,6 +59,7 @@ export class ServerRuntimeClient< super(options); + applyManagedTunnelDefaults(this); this._setUpMetricsProcessing(); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e44bb9c2df8b..c1fe19f4ed7b 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -6,6 +6,14 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export { ServerRuntimeClient } from './server-runtime-client'; +export type { TunnelRequestLike } from './utils/tunnel'; +export { + getTunnelPaths, + handleTunnelRequestIfMatched, + isTunnelRequest, + resolveServerTunnelOption, +} from './utils/tunnel'; +export { _INTERNAL_resetManagedTunnelDefaults, setManagedTunnelDefaults } from './utils/managedTunnelDefaults'; export type { ServerRuntimeOptions } from './types/options'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 8ea3944f1613..9cff5450e374 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -96,7 +96,7 @@ export type IgnoreSpanAttributeValue = string | boolean | number | string[] | bo * At least one of the properties (`name`, `op`, or `attributes`) must be set. * If multiple are set, all must match for the span to be ignored. */ -type IgnoreSpanFilter = +export type IgnoreSpanFilter = | { /** * Spans with a name matching this pattern will be ignored. @@ -373,6 +373,13 @@ export interface ClientOptions Streame */ export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON; +/** Options for the managed tunnel, see `tunnel`. */ +export interface TunnelOptions { + /** + * Server only. DSNs to forward besides the server's own, e.g. a different browser DSN. + */ + allowedDsns?: string[]; + + /** A fixed tunnel path instead of the one derived from the DSN. A relative `tunnel` string sets this. */ + path?: string; + + /** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */ + basePath?: string; + + /** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */ + trailingSlash?: boolean; +} + /** Base configuration options for every SDK. */ export interface CoreOptions extends Omit< Partial>, diff --git a/packages/core/src/utils/managedTunnelDefaults.ts b/packages/core/src/utils/managedTunnelDefaults.ts new file mode 100644 index 000000000000..19daffef2444 --- /dev/null +++ b/packages/core/src/utils/managedTunnelDefaults.ts @@ -0,0 +1,77 @@ +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import type { IgnoreSpanFilter, TunnelOptions } from '../types/options'; +import { + getTunnelIgnoreSpanFilters, + normalizeTunnelBasePath, + resolveAllowedDsns, + resolveTunnelPaths, + setServedTunnel, + stripBasePathFromTunnelPath, +} from './tunnel'; + +/** + * Managed tunnel settings a framework SDK knows outside `init`. + */ +interface ManagedTunnelDefaults { + /** Enables the managed tunnel, e.g. from TanStack Start's `tunnelRoute` build option. */ + tunnel?: TunnelOptions; + /** The path prefix the app is served under. */ + basePath?: string; +} + +let managedTunnelDefaults: ManagedTunnelDefaults | undefined; + +// So re-applying defaults replaces a client's filters instead of adding more. +const installedSpanFilters = new WeakMap(); + +/** + * Sets framework-known tunnel settings for the current and every later server client, so per-request clients + * (Cloudflare) start with them. Settings from `init` win. Framework SDKs call this once at load. + */ +export function setManagedTunnelDefaults(defaults: ManagedTunnelDefaults): void { + managedTunnelDefaults = { + ...managedTunnelDefaults, + ...(defaults.tunnel && { tunnel: defaults.tunnel }), + ...(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}. + */ +export function applyManagedTunnelDefaults(client: Client | undefined): void { + const options = client?.getOptions(); + const tunnelOptions = options?._managedTunnel ?? managedTunnelDefaults?.tunnel; + if (!client || !options || !tunnelOptions) { + return; + } + + const basePath = normalizeTunnelBasePath(managedTunnelDefaults?.basePath); + const resolved = stripBasePathFromTunnelPath( + tunnelOptions.basePath === undefined && basePath ? { ...tunnelOptions, basePath } : tunnelOptions, + ); + options._managedTunnel = resolved; + const allowedDsns = resolveAllowedDsns(resolved.allowedDsns, client.getDsn()); + const paths = resolveTunnelPaths(resolved, allowedDsns); + setServedTunnel(client, { paths, allowedDsns }); + + const previousFilters = installedSpanFilters.get(client) ?? []; + const spanFilters = getTunnelIgnoreSpanFilters(resolved, paths); + options.ignoreSpans = [ + ...(options.ignoreSpans ?? []).filter(filter => !(previousFilters as unknown[]).includes(filter)), + ...spanFilters, + ]; + installedSpanFilters.set(client, spanFilters); +} + +/** + * Resets {@link setManagedTunnelDefaults}, for tests. + * + * @internal + */ +export function _INTERNAL_resetManagedTunnelDefaults(): void { + managedTunnelDefaults = undefined; +} diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts index 4831d6f48ea8..0d3df1d4d8b5 100644 --- a/packages/core/src/utils/tunnel.ts +++ b/packages/core/src/utils/tunnel.ts @@ -1,16 +1,191 @@ 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, TunnelOptions } from '../types/options'; +import { escapeStringForRegex } from '../vendor/escapeStringForRegex'; import { debug } from './debug-logger'; -import { makeDsn } from './dsn'; -import { parseEnvelope } from './envelope'; +import { dsnToString, makeDsn } from './dsn'; +import { parseUrl } from './url'; -// Bounds the memory of each request. SDK envelopes stay far below it. +/** + * A server client's tunnel paths and allowed DSNs, computed once per client. + */ +interface ServedTunnel { + paths: string[]; + allowedDsns: string[]; +} + +const servedTunnels = new WeakMap(); + +/** + * Stores a server client's computed tunnel paths and allowed DSNs. + */ +export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void { + servedTunnels.set(client, servedTunnel); +} + +function getServedTunnel(): ServedTunnel | undefined { + const client = getClient(); + + return client && servedTunnels.get(client); +} + +/** + * Bounds what one tunnel request may send. SDK envelopes stay far below it. + */ export const MAX_TUNNEL_BODY_SIZE = 20 * 1024 * 1024; export interface HandleTunnelRequestOptions { /** Incoming request containing the Sentry envelope as its body */ request: Request; - /** Pre-parsed array of allowed DSN strings */ - allowedDsns: Array; + /** 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: string | true | TunnelOptions | undefined): { + tunnel: string | undefined; + _managedTunnel?: TunnelOptions; +} { + 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. + * The base path is normalized, and a root one is no default at all. + */ +export function applyTunnelDefaults( + tunnel: string | true | TunnelOptions | undefined, + defaults: TunnelOptions | undefined, +): string | true | TunnelOptions | undefined { + const { basePath, ...rest } = defaults ?? {}; + const normalizedBasePath = normalizeTunnelBasePath(basePath); + const resolvedDefaults: TunnelOptions = normalizedBasePath ? { ...rest, basePath: normalizedBasePath } : rest; + if (!tunnel || !Object.keys(resolvedDefaults).length) { + return tunnel; + } + + if (typeof tunnel === 'string') { + return tunnel.startsWith('/') ? stripBasePathFromTunnelPath({ ...resolvedDefaults, path: tunnel }) : tunnel; + } + + return stripBasePathFromTunnelPath({ ...resolvedDefaults, ...(tunnel === true ? {} : tunnel) }); +} + +/** + * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`. Browser and server derive it independently, + * and as a hash it gives ad-blocker filter lists nothing to match. + */ +export function getTunnelPath(dsn: DsnLike): string | undefined { + const components = makeDsn(dsn); + + return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined; +} + +/** + * The parts of a request the tunnel match reads. Satisfied by a fetch `Request` and Node's `IncomingMessage`. + */ +export interface TunnelRequestLike { + method?: string; + /** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */ + url?: string; + headers?: Headers | Record; +} + +/** + * Returns the active server client's tunnel paths, relative to its base path. For frameworks that can only register + * routes; everything else uses {@link handleTunnelRequestIfMatched}. + */ +export function getTunnelPaths(): string[] { + return getServedTunnel()?.paths ?? []; +} + +/** + * The paths a managed tunnel is served at: the fixed `path`, or else one per allowed DSN. + */ +export function resolveTunnelPaths(tunnelOptions: TunnelOptions, allowedDsns: Array): string[] { + if (tunnelOptions.path) { + return [tunnelOptions.path.replace(/\/$/, '')]; + } + + // Differently written DSNs can hash to the same path. + const paths = allowedDsns.map(dsn => getTunnelPath(dsn)); + + return [...new Set(paths.filter((path): path is string => !!path))]; +} + +/** + * `ignoreSpans` filters for the server spans of tunnel requests. They match `url.path` and the legacy `http.url`, + * which some framework spans start with (SvelteKit's `sveltekit.handle.root`). + */ +export function getTunnelIgnoreSpanFilters(tunnelOptions: TunnelOptions, paths: string[]): IgnoreSpanFilter[] { + if (!paths.length) { + return []; + } + + const basePath = escapeStringForRegex(normalizeTunnelBasePath(tunnelOptions.basePath)); + const pathPattern = `${basePath}(?:${paths.map(escapeStringForRegex).join('|')})/?`; + + return [ + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { [URL_PATH]: new RegExp(`^${pathPattern}$`) } }, + // oxlint-disable-next-line sdk/no-regexp-constructor -- built from escaped config values, not end user input + { attributes: { 'http.url': new RegExp(`^[a-z][a-z\\d+.-]*://[^/]+${pathPattern}(?:[?#].*)?$`) } }, + ]; +} + +/** + * Normalizes a base path to `/app`, or `''` for the root. + */ +export function normalizeTunnelBasePath(basePath: string | undefined): string { + const path = (basePath?.startsWith('./') ? basePath.slice(1) : (basePath ?? '')).replace(/\/$/, ''); + + return !path || path.startsWith('/') ? path : `/${path}`; +} + +/** + * Returns true if the request targets the active server client's managed tunnel. + */ +export function isTunnelRequest(request: TunnelRequestLike): boolean { + if (request.method !== 'POST' || !request.url) { + return false; + } + + const tunnelOptions = getClient()?.getOptions()._managedTunnel; + if (!tunnelOptions) { + return false; + } + + const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath); + + return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, '')); +} + +/** + * 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)) { + return undefined; + } + + return forwardTunnelRequest(request, allowedDsns); } /** @@ -21,14 +196,35 @@ export interface HandleTunnelRequestOptions { * * @returns A `Response` — either the upstream Sentry response on success, or an error response. */ -export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { - const { request, allowedDsns } = options; +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 `arrayBuffer()` reads. + // 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 }); @@ -37,11 +233,42 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Envelope too large', { status: 413 }); } - const body = new Uint8Array(await request.arrayBuffer()); + 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; + } - let envelopeHeader; try { - [envelopeHeader] = parseEnvelope(body); + 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 }); } @@ -71,18 +298,140 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions): return new Response('Invalid DSN', { status: 403 }); } - const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); +} - try { - return await fetch(sentryIngestUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-sentry-envelope', - }, - body, - }); - } catch (error) { - debug.error('Sentry tunnel: failed to forward envelope', error); - return new Response('Failed to forward envelope to Sentry', { status: 500 }); +/** + * A header line longer than this is not an envelope. + */ +const MAX_ENVELOPE_HEADER_BYTES = 64 * 1024; + +/** + * 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; + } + + return headers; +} + +/** + * `allowedDsns` plus the server's own DSN, which is always allowed. + */ +export function resolveAllowedDsns( + allowedDsns: Array | undefined, + ownDsn: DsnComponents | undefined, +): Array { + // Normalized like the envelope header's DSN, which has no secret key. + const dsns = new Set(); + for (const components of [...(allowedDsns ?? []).map(dsn => makeDsn(dsn)), ownDsn]) { + if (components) { + dsns.add(dsnToString(components)); + } } + + 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: TunnelOptions): TunnelOptions { + const path = tunnelOptions.path && stripBasePath(tunnelOptions.path, tunnelOptions.basePath); + + return path ? { ...tunnelOptions, path } : tunnelOptions; +} + +/** + * Returns the path below `basePath`, or `undefined` outside it. + */ +function stripBasePath(pathname: string, basePath: string | undefined): string | undefined { + const base = normalizeTunnelBasePath(basePath); + if (!base) { + return pathname; + } + + return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : undefined; +} + +/** + * 32-bit FNV-1a: synchronous (the browser needs it in `init`) and dependency free. Changing it breaks mixed SDK + * versions, since browser and server compute the path separately. + */ +function fnv1a(input: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + // eslint-disable-next-line no-bitwise + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + // eslint-disable-next-line no-bitwise + return hash >>> 0; } diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts index 4c332b479fa0..4362c15dd2fd 100644 --- a/packages/core/test/lib/integrations/fetch.test.ts +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -4,6 +4,7 @@ import * as currentScopesModule from '../../../src/currentScopes'; import * as fetchModule from '../../../src/fetch'; import { createFetchIntegration } from '../../../src/integrations/fetch'; import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import { suppressTracing } from '../../../src/tracing/trace'; import type { HandlerDataFetch } from '../../../src/types/instrument'; import type { Integration } from '../../../src/types/integration'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -136,6 +137,23 @@ describe('createFetchIntegration', () => { expect(addBreadcrumbSpy).not.toHaveBeenCalled(); }); + it('does not instrument requests made while tracing is suppressed', () => { + const handler = setupIntegration(fetchIntegration(), client); + + suppressTracing(() => + handler({ + fetchData: { url: 'https://o1.ingest.us.sentry.io/api/2/envelope/', method: 'POST' }, + args: ['https://o1.ingest.us.sentry.io/api/2/envelope/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }), + ); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + it('applies the `shouldCreateSpanForRequest` option', () => { const handler = setupIntegration( fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts index 7e56a5badfca..5723bf29e528 100644 --- a/packages/core/test/lib/utils/tunnel.test.ts +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -1,20 +1,75 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { getCurrentScope, setCurrentClient } from '../../../src'; +import { isTracingSuppressed } from '../../../src/tracing/trace'; import { makeDsn } from '../../../src/utils/dsn'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import { handleTunnelRequest, MAX_TUNNEL_BODY_SIZE } from '../../../src/utils/tunnel'; +import { + applyTunnelDefaults, + getTunnelPath, + getTunnelIgnoreSpanFilters, + getTunnelPaths, + handleTunnelRequest, + handleTunnelRequestIfMatched, + isTunnelRequest, + normalizeTunnelBasePath, + resolveAllowedDsns, + resolveServerTunnelOption, + resolveTunnelPaths, + MAX_TUNNEL_BODY_SIZE, +} from '../../../src/utils/tunnel'; +import { + _INTERNAL_resetManagedTunnelDefaults, + applyManagedTunnelDefaults, + setManagedTunnelDefaults, +} from '../../../src/utils/managedTunnelDefaults'; +import type { DsnComponents } from '../../../src/types/dsn'; +import type { IgnoreSpanFilter, TunnelOptions } from '../../../src/types/options'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +import { ServerRuntimeClient } from '../../../src/server-runtime-client'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; -function makeEnvelopeRequest(envelopeHeader: Record, contentLength?: number): Request { - const envelope = createEnvelope(envelopeHeader, []); - const body = serializeEnvelope(envelope); - const byteLength = typeof body === 'string' ? new TextEncoder().encode(body).byteLength : body.byteLength; +/** The `url.path` filter, the first of the tunnel span filters. */ +function getTunnelIgnoreSpanFilter( + tunnelOptions: TunnelOptions, + dsn: DsnComponents | undefined, +): IgnoreSpanFilter | undefined { + const paths = resolveTunnelPaths(tunnelOptions, resolveAllowedDsns(tunnelOptions.allowedDsns, dsn)); + + return getTunnelIgnoreSpanFilters(tunnelOptions, paths)[0]; +} + +function makeEnvelopeRequest( + envelopeHeader: Record, + url = 'http://localhost/tunnel', + contentLength?: number, +): Request { + const body = serializeEnvelope(createEnvelope(envelopeHeader, [])); + const length = contentLength ?? new TextEncoder().encode(body as string).length; + + return new Request(url, { method: 'POST', body, headers: { 'content-length': String(length) } }); +} + +/** + * A request whose body starts with `head` and then never ends, so buffering it would hang the test. + */ +function makeEndlessRequest(head: string, onCancel?: () => void): Request { + const filler = new Uint8Array(16 * 1024).fill(0x61); + let sentHead = false; + return new Request('http://localhost/tunnel', { method: 'POST', - body, - headers: { 'content-length': String(contentLength ?? byteLength) }, - }); + duplex: 'half', + headers: { 'content-length': String(MAX_TUNNEL_BODY_SIZE) }, + body: new ReadableStream({ + pull(controller) { + controller.enqueue(sentHead ? filler : new TextEncoder().encode(head)); + sentHead = true; + }, + cancel: onCancel, + }), + } as RequestInit); } describe('handleTunnelRequest', () => { @@ -25,8 +80,43 @@ describe('handleTunnelRequest', () => { vi.stubGlobal('fetch', fetchMock); }); + it('forwards the envelope when Content-Length equals the limit', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(200); + }); + + it('returns 413 when Content-Length is above the limit, without reading the body', async () => { + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, undefined, MAX_TUNNEL_BODY_SIZE + 1); + + const result = await handleTunnelRequest({ request, allowedDsns: [TEST_DSN] }); + + expect(result.status).toBe(413); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 411 when the request has no Content-Length', async () => { + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + }), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(411); + expect(fetchMock).not.toHaveBeenCalled(); + }); + afterEach(() => { vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); }); it('forwards the envelope to Sentry and returns the upstream response', async () => { @@ -43,9 +133,99 @@ describe('handleTunnelRequest', () => { expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); expect(init.method).toBe('POST'); expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); - expect(init.body).toBeInstanceOf(Uint8Array); + expect(await new Response(init.body).text()).toBe(serializeEnvelope(createEnvelope({ dsn: TEST_DSN }, []))); + + expect(result.status).toBe(200); + 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 () => { + fetchMock.mockResolvedValueOnce( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } }), + ); + + const forwarded = await 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. + expect(forwarded.headers.get('Content-Encoding')).toBeNull(); + expect(rejected.status).toBe(403); + expect(rejected.headers.get('X-Sentry-Tunnel')).toBe('1'); + }); + + it.each([ + ['cf-connecting-ip', { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, '1.1.1.1'], + ['x-vercel-forwarded-for', { 'x-vercel-forwarded-for': '3.3.3.3', 'x-forwarded-for': '2.2.2.2' }, '3.3.3.3'], + ['x-forwarded-for', { 'x-forwarded-for': '2.2.2.2, 10.0.0.1' }, '2.2.2.2, 10.0.0.1'], + ])('forwards the client IP from %s', async (_, incomingHeaders, forwardedFor) => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + + await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { + method: 'POST', + body: `${JSON.stringify({ dsn: TEST_DSN })}\n`, + headers: { ...incomingHeaders, 'content-length': '1' }, + }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Sentry-Forwarded-For': forwardedFor }), + }), + ); + }); + + it('rejects a disallowed DSN after reading only the envelope header', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('cancels the body of a rejected request', async () => { + const onCancel = vi.fn(); + + await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: 'https://other@example.com/9999' })}\n`, onCancel), + allowedDsns: [TEST_DSN], + }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); - expect(result).toBe(upstreamResponse); + it('streams an allowed envelope to Sentry without reading the rest of the body', async () => { + const upstreamResponse = new Response('ok'); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEndlessRequest(`${JSON.stringify({ dsn: TEST_DSN })}\n`), + allowedDsns: [TEST_DSN], + }); + + expect(await result.text()).toBe('ok'); + }); + + it('returns 400 when the envelope header line exceeds the limit', async () => { + const result = await handleTunnelRequest({ + request: makeEndlessRequest(''), + allowedDsns: [TEST_DSN], + }); + + expect(result.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); }); it('returns 500 when allowedDsns is empty', async () => { @@ -100,7 +280,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('returns 403 when the DSN string cannot be parsed into components', async () => { + it('ignores an allowed DSN that cannot be parsed', async () => { const malformedDsn = 'not-a-valid-dsn'; const result = await handleTunnelRequest({ @@ -108,12 +288,22 @@ describe('handleTunnelRequest', () => { allowedDsns: [malformedDsn], }); - expect(result).toBeInstanceOf(Response); - expect(result.status).toBe(403); - expect(await result.text()).toBe('Invalid DSN'); + expect(result.status).toBe(500); expect(fetchMock).not.toHaveBeenCalled(); }); + it('matches an allowed DSN written with a secret key, which the envelope header leaves out', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN.replace('@', ':secret@')], + }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('forwards the envelope when multiple DSNs are configured', async () => { const otherDsn = 'https://other@example.com/9999'; const upstreamResponse = new Response('ok', { status: 200 }); @@ -127,7 +317,7 @@ describe('handleTunnelRequest', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url] = fetchMock.mock.calls[0]!; expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); - expect(result).toBe(upstreamResponse); + expect(await result.text()).toBe('ok'); }); it('returns 500 when fetch throws a network error', async () => { @@ -143,47 +333,504 @@ describe('handleTunnelRequest', () => { expect(await result.text()).toBe('Failed to forward envelope to Sentry'); }); - describe('body size limit', () => { - it('forwards the envelope when Content-Length equals the limit', async () => { - fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + it('forwards with tracing suppressed so the forward does not create a span', async () => { + let suppressed: boolean | undefined; + fetchMock.mockImplementationOnce(() => { + suppressed = isTracingSuppressed(); - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE), - allowedDsns: [TEST_DSN], - }); + return Promise.resolve(new Response('ok')); + }); + + await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }), allowedDsns: [TEST_DSN] }); + + expect(suppressed).toBe(true); + }); - expect(result.status).toBe(200); + it('accepts the DSN of the active client on top of allowedDsns', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValue(new Response('ok')); + + const ownDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], }); + const unlistedDsnResult = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@o3.ingest.sentry.io/7' }), + allowedDsns: ['https://browser@o2.ingest.sentry.io/42'], + }); + + expect(ownDsnResult.status).toBe(200); + expect(unlistedDsnResult.status).toBe(403); + }); + + it('defaults allowedDsns to the DSN of the active client', async () => { + setTestClient(TEST_DSN); + fetchMock.mockResolvedValueOnce(new Response('ok')); + + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('returns 500 when allowedDsns is omitted and there is no client', async () => { + const result = await handleTunnelRequest({ request: makeEnvelopeRequest({ dsn: TEST_DSN }) }); + + expect(result.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelPath', () => { + it('returns a short opaque path that does not leak the DSN', () => { + const path = getTunnelPath(TEST_DSN); + + expect(path).toMatch(/^\/[0-9a-z]{1,7}$/); + expect(path).not.toContain('sentry'); + }); + + // Browser and server SDKs of different versions must derive the same path, so the hash must never change. + it('derives a fixed path for a known DSN', () => { + expect(getTunnelPath(TEST_DSN)).toBe('/fdv680'); + }); + + it('is stable for the same DSN and differs between DSNs', () => { + expect(getTunnelPath(TEST_DSN)).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(TEST_DSN)).not.toBe(getTunnelPath('https://public@dsn.ingest.sentry.io/1338')); + }); + + it('ignores DSN formatting differences so browser and server derive the same path', () => { + expect(getTunnelPath('https://public:secret@dsn.ingest.sentry.io/1337')).toBe(getTunnelPath(TEST_DSN)); + expect(getTunnelPath(makeDsn(TEST_DSN)!)).toBe(getTunnelPath(TEST_DSN)); + }); + + it('returns undefined for an invalid DSN', () => { + expect(getTunnelPath('not-a-dsn')).toBeUndefined(); + }); +}); + +describe('isTunnelRequest', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('matches a POST to the tunnel path of an allowed DSN', () => { + expect(isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}/` }, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match other methods, other paths, or path prefixes', () => { + expect(isTunnelRequestFor({ method: 'GET', url: tunnelPath }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/somewhere' }, { allowedDsns: [TEST_DSN] })).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: `${tunnelPath}x` }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); + + it('does not match the tunnel path of a DSN that is not allowed', () => { + const otherDsn = 'https://other@example.com/9999'; + + expect(isTunnelRequestFor({ method: 'POST', url: getTunnelPath(otherDsn)! }, { allowedDsns: [TEST_DSN] })).toBe( + false, + ); + }); + + it('uses the managed tunnel config of the active client when no options are passed', () => { + setTestClient(TEST_DSN); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + + setTestClient(TEST_DSN, {}); + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(true); + }); + + it('matches nothing without a client', () => { + expect(isTunnelRequest({ method: 'POST', url: tunnelPath })).toBe(false); + }); + + it('matches under a base path', () => { + expect( + isTunnelRequestFor( + { method: 'POST', url: `/docs${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs/' }, + ), + ).toBe(true); + expect( + isTunnelRequestFor( + { method: 'POST', url: `/docsx${tunnelPath}` }, + { allowedDsns: [TEST_DSN], basePath: '/docs' }, + ), + ).toBe(false); + }); + + it('matches nothing outside the base path', () => { + expect( + isTunnelRequestFor({ method: 'POST', url: tunnelPath }, { allowedDsns: [TEST_DSN], basePath: '/docs' }), + ).toBe(false); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', basePath: '/docs' })).toBe( + false, + ); + }); +}); + +describe('isTunnelRequest request shapes', () => { + const tunnelPath = getTunnelPath(TEST_DSN)!; + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('accepts a fetch Request with an absolute URL and query', () => { + const request = new Request(`https://example.com${tunnelPath}?o=1`, { method: 'POST' }); + + expect(isTunnelRequestFor(request, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('accepts a Node IncomingMessage-like object', () => { + const incomingMessage = { method: 'POST', url: `${tunnelPath}?o=1`, headers: { host: 'example.com' } }; + + expect(isTunnelRequestFor(incomingMessage, { allowedDsns: [TEST_DSN] })).toBe(true); + }); + + it('does not match without a URL', () => { + expect(isTunnelRequestFor({ method: 'POST' }, { allowedDsns: [TEST_DSN] })).toBe(false); + }); +}); + +describe('getTunnelPaths', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('returns one path per allowed DSN and skips invalid ones', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect(resolveTunnelPaths({}, [TEST_DSN, otherDsn, 'not-a-dsn'])).toEqual([ + getTunnelPath(TEST_DSN), + getTunnelPath(otherDsn), + ]); + }); + + it('returns a fixed path instead of the DSN-derived ones', () => { + expect(resolveTunnelPaths({ path: '/monitor' }, [TEST_DSN])).toEqual(['/monitor']); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor', allowedDsns: [TEST_DSN] })).toBe( + true, + ); + }); + + it('matches a fixed path that ends with a slash', () => { + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor' }, { path: '/monitor/' })).toBe(true); + expect(isTunnelRequestFor({ method: 'POST', url: '/monitor/' }, { path: '/monitor/' })).toBe(true); + }); + + it('serves the path of the active client DSN on top of allowedDsns, once', () => { + const browserDsn = 'https://browser@o2.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn, TEST_DSN] }); + + expect(getTunnelPaths()).toEqual([getTunnelPath(browserDsn), getTunnelPath(TEST_DSN)]); + }); + + it('uses the managed tunnel config of the active client', () => { + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN); + expect(getTunnelPaths()).toEqual([]); + + setTestClient(TEST_DSN, {}); + expect(getTunnelPaths()).toEqual([getTunnelPath(TEST_DSN)]); + }); + + it('derives the paths once per client, so an invalid DSN in allowedDsns logs once', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setTestClient(TEST_DSN, { allowedDsns: ['also-not-a-dsn'] }); + + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) }); + + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + consoleErrorSpy.mockRestore(); + }); +}); + +describe('handleTunnelRequestIfMatched', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + getCurrentScope().setClient(undefined); + }); + + it('forwards a matching request', async () => { + fetchMock.mockResolvedValueOnce(new Response('ok')); + setTestClient(TEST_DSN, {}); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, `http://localhost${getTunnelPath(TEST_DSN)}`); + + const result = await handleTunnelRequestIfMatched(request); + + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('forwards using the allowedDsns of the active client managed tunnel config', async () => { + const browserDsn = 'https://browser@o1.ingest.sentry.io/42'; + setTestClient(TEST_DSN, { allowedDsns: [browserDsn] }); + fetchMock.mockResolvedValueOnce(new Response('ok')); + const request = makeEnvelopeRequest({ dsn: browserDsn }, `http://localhost${getTunnelPath(browserDsn)}`); - it('returns 413 when Content-Length is above the limit', async () => { - const result = await handleTunnelRequest({ - request: makeEnvelopeRequest({ dsn: TEST_DSN }, MAX_TUNNEL_BODY_SIZE + 1), - allowedDsns: [TEST_DSN], - }); + const result = await handleTunnelRequestIfMatched(request); - expect(result.status).toBe(413); - expect(await result.text()).toBe('Envelope too large'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(result?.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(browserDsn)!), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('leaves a fixed path to the app when there is no DSN to forward to', () => { + setTestClient(undefined, { path: '/monitor' }); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/monitor'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + + it('returns undefined without reading the body when the request does not match', () => { + setTestClient(TEST_DSN, {}); + const request = makeEnvelopeRequest({ dsn: TEST_DSN }, 'http://localhost/api/users'); + + expect(handleTunnelRequestIfMatched(request)).toBeUndefined(); + expect(request.bodyUsed).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('getTunnelIgnoreSpanFilter', () => { + function urlPathMatcher(filter: ReturnType): RegExp { + return (filter as { attributes: { 'url.path': RegExp } }).attributes['url.path']; + } + + it("matches the tunnel path of the client's DSN, with or without a trailing slash", () => { + const matcher = urlPathMatcher(getTunnelIgnoreSpanFilter({}, makeDsn(TEST_DSN))); + const tunnelPath = getTunnelPath(TEST_DSN)!; + + expect(matcher.test(tunnelPath)).toBe(true); + expect(matcher.test(`${tunnelPath}/`)).toBe(true); + expect(matcher.test(`${tunnelPath}x`)).toBe(false); + expect(matcher.test('/api/users')).toBe(false); + }); + + it('follows allowedDsns, a fixed path and the base path', () => { + const otherDsn = 'https://other@o2.ingest.sentry.io/42'; + + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(otherDsn)!, + ), + ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ allowedDsns: [otherDsn] }, makeDsn(TEST_DSN))).test( + getTunnelPath(TEST_DSN)!, + ), + ).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor' }, undefined)).test('/monitor')).toBe(true); + expect(urlPathMatcher(getTunnelIgnoreSpanFilter({ path: '/monitor/' }, undefined)).test('/monitor')).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app/' }, makeDsn(TEST_DSN))).test( + `/app${getTunnelPath(TEST_DSN)}`, + ), + ).toBe(true); + expect( + urlPathMatcher(getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN))).test(getTunnelPath(TEST_DSN)!), + ).toBe(false); + }); + + it('returns nothing when there is no path to match', () => { + expect(getTunnelIgnoreSpanFilter({}, undefined)).toBeUndefined(); + }); +}); + +describe('server client with the managed tunnel enabled', () => { + it('ignores the server spans of tunnel requests and keeps user filters', () => { + const client = new ServerRuntimeClient( + getDefaultTestClientOptions({ dsn: TEST_DSN, _managedTunnel: {}, ignoreSpans: ['user-filter'] }), + ); + const ignoreSpans = client.getOptions().ignoreSpans!; + + expect(ignoreSpans[0]).toBe('user-filter'); + expect( + (ignoreSpans[1] as { attributes: { 'url.path': RegExp } }).attributes['url.path'].test(getTunnelPath(TEST_DSN)!), + ).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(); + }); +}); + +describe('resolveServerTunnelOption', () => { + it('keeps a URL as the transport tunnel', () => { + expect(resolveServerTunnelOption('https://example.com/tunnel')).toEqual({ tunnel: 'https://example.com/tunnel' }); + }); + + 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('returns 411 when the request has no Content-Length', async () => { - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ dsn: TEST_DSN })}\n`)); - controller.close(); - }, - }); - // Node.js requires `duplex` for a stream body, but the DOM types do not have it yet. - const init: RequestInit & { duplex: 'half' } = { method: 'POST', body, duplex: 'half' }; + it('serves a relative path instead of using it as the transport tunnel', () => { + expect(resolveServerTunnelOption('/monitor')).toEqual({ tunnel: undefined, _managedTunnel: { path: '/monitor' } }); + }); - const result = await handleTunnelRequest({ - request: new Request('http://localhost/tunnel', init), - allowedDsns: [TEST_DSN], - }); + it('leaves everything off when unset', () => { + expect(resolveServerTunnelOption(undefined)).toEqual({ tunnel: undefined }); + }); +}); - expect(result.status).toBe(411); - expect(await result.text()).toBe('Length required'); - expect(fetchMock).not.toHaveBeenCalled(); +describe('applyTunnelDefaults', () => { + it('fills in the defaults for `true` and options, and lets options the user set win', () => { + expect(applyTunnelDefaults(true, { basePath: '/app' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults({ allowedDsns: [TEST_DSN] }, { basePath: '/app' })).toEqual({ + basePath: '/app', + allowedDsns: [TEST_DSN], }); + 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 URL, an unset tunnel and empty defaults alone', () => { + expect(applyTunnelDefaults('https://example.com/tunnel', { basePath: '/app' })).toBe('https://example.com/tunnel'); + expect(applyTunnelDefaults(undefined, { basePath: '/app' })).toBeUndefined(); + expect(applyTunnelDefaults('/monitor', {})).toBe('/monitor'); + expect(applyTunnelDefaults(true, undefined)).toBe(true); + }); + + it('normalizes the default base path and ignores a root one', () => { + expect(applyTunnelDefaults(true, { basePath: 'app/' })).toEqual({ basePath: '/app' }); + expect(applyTunnelDefaults(true, { basePath: '/' })).toBe(true); + expect(applyTunnelDefaults(true, { basePath: undefined })).toBe(true); + }); +}); + +describe('normalizeTunnelBasePath', () => { + it.each([ + ['/app', '/app'], + ['/app/', '/app'], + ['app', '/app'], + ['./app/', '/app'], + ['./', ''], + ['/', ''], + ['', ''], + [undefined, ''], + ])('normalizes %j to %j', (input, expected) => { + expect(normalizeTunnelBasePath(input)).toBe(expected); + }); +}); + +describe('setManagedTunnelDefaults', () => { + afterEach(() => { + getCurrentScope().setClient(undefined); + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it('serves the tunnel under the base path and drops its spans there', () => { + setTestClient(TEST_DSN, {}); + const client = getCurrentScope().getClient()!; + + setManagedTunnelDefaults({ basePath: '/app/' }); + + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/app' }); + expect(isTunnelRequest({ method: 'POST', url: `/app${getTunnelPath(TEST_DSN)}` })).toBe(true); + expect(isTunnelRequest({ method: 'POST', url: getTunnelPath(TEST_DSN) })).toBe(false); + expect(client.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ basePath: '/app' }, makeDsn(TEST_DSN)), + ); + }); + + it('does not add the base path again to a fixed path that already starts with it', () => { + setTestClient(TEST_DSN, { path: '/app/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); + }); + + it('keeps a base path the user set, and does nothing without a managed tunnel or a base path', () => { + setTestClient(TEST_DSN, { basePath: '/custom' }); + const client = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/app' }); + expect(client.getOptions()._managedTunnel).toEqual({ basePath: '/custom' }); + _INTERNAL_resetManagedTunnelDefaults(); + + setTestClient(TEST_DSN, {}); + const rootClient = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/' }); + expect(rootClient.getOptions()._managedTunnel).toEqual({}); + _INTERNAL_resetManagedTunnelDefaults(); + + setTestClient(TEST_DSN); + const offClient = getCurrentScope().getClient()!; + setManagedTunnelDefaults({ basePath: '/app' }); + expect(offClient.getOptions()._managedTunnel).toBeUndefined(); + }); + + it('enables the tunnel on clients created later, unless their `init` set one', () => { + setManagedTunnelDefaults({ tunnel: { path: '/monitor' }, basePath: '/app' }); + + setTestClient(TEST_DSN); + const laterClient = getCurrentScope().getClient()!; + applyManagedTunnelDefaults(laterClient); + expect(laterClient.getOptions()._managedTunnel).toEqual({ path: '/monitor', basePath: '/app' }); + expect(laterClient.getOptions().ignoreSpans).toContainEqual( + getTunnelIgnoreSpanFilter({ path: '/monitor', basePath: '/app' }, 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' }); }); }); + +function setTestClient(dsn: string | undefined, managedTunnel?: TunnelOptions): void { + setCurrentClient(new ServerRuntimeClient(getDefaultTestClientOptions({ dsn, _managedTunnel: managedTunnel }))); +} + +/** `isTunnelRequest` for a server client with this managed tunnel and no DSN of its own. */ +function isTunnelRequestFor(request: Parameters[0], managedTunnel: TunnelOptions): boolean { + setTestClient(undefined, managedTunnel); + + return isTunnelRequest(request); +} diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 0df557baa736..516543a2b5a9 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -59,12 +59,14 @@ describe('POST', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( 'https://o1.ingest.sentry.io/api/1/envelope/?sentry_version=7&sentry_key=public', - { + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/x-sentry-envelope' }, - body, - }, + }), ); + // The body streams through, so it is read back to compare it. + const [, init] = fetchSpy.mock.calls[0]!; + expect(new Uint8Array(await new Response((init as RequestInit).body).arrayBuffer())).toEqual(body); }); it('responds with 500 when the server SDK has no DSN', async () => {