Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
getTunnelPath,
handleTunnelRequest,
handleTunnelRequestIfMatched,
isTunnelRequest,
resolveServerTunnelOption,
} from './utils/tunnel';
export { addAutoIpAddressToSession } from './utils/ipAddress';
export {
convertSpanLinksForEnvelope,
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
*/
tunnel?: string;

/**
* Set by server SDKs when the managed tunnel is enabled. Server request handlers check it to decide whether to
* serve the managed tunnel.
*
* @internal This option is not part of the public API and is subject to change at any time.
*/
_managedTunnel?: TunnelOptions;

/**
* Controls what data the SDK collects and sends to Sentry.
* All fields are optional — omitted fields use the documented defaults.
Expand Down Expand Up @@ -645,6 +653,21 @@ export type BeforeSendStreamedSpanCallback = (span: StreamedSpanJSON) => Streame
*/
export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON;

/** Options for the managed tunnel, see `tunnel`. */
export interface TunnelOptions {
/**
* Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN
* here when the browser uses a different one.
*/
allowedDsns?: 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<TO extends BaseTransportOptions = BaseTransportOptions> extends Omit<
Partial<ClientOptions<TO>>,
Expand Down
119 changes: 108 additions & 11 deletions packages/core/src/utils/tunnel.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,76 @@
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api';
import { getClient } from '../currentScopes';
import { suppressTracing } from '../tracing/trace';
import type { DsnLike } from '../types/dsn';
import type { TunnelOptions } from '../types/options';
import { debug } from './debug-logger';
import { makeDsn } from './dsn';
import { dsnToString, makeDsn } from './dsn';
import { parseEnvelope } from './envelope';

export interface HandleTunnelRequestOptions {
/** Incoming request containing the Sentry envelope as its body */
request: Request;
/** Pre-parsed array of allowed DSN strings */
allowedDsns: Array<string>;
/** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */
allowedDsns?: Array<string>;
}

/**
* Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own
* envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead.
*/
export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): {
tunnel: string | undefined;
_managedTunnel?: TunnelOptions;
} {
if (typeof tunnel === 'string' || !tunnel) {
return { tunnel };
}

return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel };
}

/**
* Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`.
*
* Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it
* between them. The path is a hash so it carries nothing an ad-blocker filter list could match on.
*/
export function getTunnelPath(dsn: DsnLike): string | undefined {
const components = makeDsn(dsn);
return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined;
}

/**
* Returns true if the request targets the managed tunnel.
*
* Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its
* `init` was called with `tunnel: true`.
*/
export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean {
if (method !== 'POST') {
return false;
}

const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel;
if (!tunnelOptions) {
return false;
}

const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, '');
return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path);
}

/**
* Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller
* can hand the request to the app.
*/
export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise<Response> | undefined {
if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) {
return undefined;
}

const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns;
return handleTunnelRequest({ request, allowedDsns });
}

/**
Expand All @@ -19,7 +82,8 @@ export interface HandleTunnelRequestOptions {
* @returns A `Response` — either the upstream Sentry response on success, or an error response.
*/
export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise<Response> {
const { request, allowedDsns } = options;
const { request } = options;
const allowedDsns = resolveAllowedDsns(options.allowedDsns);

if (allowedDsns.length === 0) {
return new Response('Tunnel not configured', { status: 500 });
Expand Down Expand Up @@ -62,15 +126,48 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions):
const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents);

try {
return await fetch(sentryIngestUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
body,
});
// The forward is SDK plumbing, not app work, so it must not show up as a span.
return await suppressTracing(() =>
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 });
}
}

function resolveAllowedDsns(allowedDsns: Array<string> | undefined): Array<string> {
if (allowedDsns) {
return allowedDsns;
}

const dsn = getClient()?.getDsn();
return dsn ? [dsnToString(dsn)] : [];
}

function stripBasePath(pathname: string, basePath: string | undefined): string {
const base = basePath?.replace(/\/$/, '');
if (!base) {
return pathname;
}

return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname;
}

/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */
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;
}
Loading
Loading