Skip to content

Commit a2af1e1

Browse files
committed
feat(core): Add managed tunnel helpers that derive the tunnel path from the DSN
Browser and server SDKs can now derive the same opaque tunnel path from the DSN with `getTunnelPath`, so they agree on it without a build step. `isTunnelRequest` and `handleTunnelRequestIfMatched` let a server wrapper serve the tunnel when its client has `_managedTunnel` set, which `resolveServerTunnelOption` derives from the `tunnel` option. `allowedDsns` defaults to the client's DSN and the forward to Sentry runs with tracing suppressed.
1 parent 1dffc6e commit a2af1e1

4 files changed

Lines changed: 320 additions & 16 deletions

File tree

‎packages/core/src/index.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,13 @@ export { handleCallbackErrors } from './utils/handleCallbackErrors';
102102
export { safeCallback } from './utils/safeCallback';
103103
export { parameterize, fmt } from './utils/parameterize';
104104
export type { HandleTunnelRequestOptions } from './utils/tunnel';
105-
export { handleTunnelRequest } from './utils/tunnel';
105+
export {
106+
getTunnelPath,
107+
handleTunnelRequest,
108+
handleTunnelRequestIfMatched,
109+
isTunnelRequest,
110+
resolveServerTunnelOption,
111+
} from './utils/tunnel';
106112
export { addAutoIpAddressToSession } from './utils/ipAddress';
107113
export {
108114
convertSpanLinksForEnvelope,
@@ -415,7 +421,7 @@ export type {
415421
HttpHeadersCollection,
416422
ResolvedDataCollection,
417423
} from './types/datacollection';
418-
export type { ClientOptions, CoreOptions as Options } from './types/options';
424+
export type { ClientOptions, CoreOptions as Options, TunnelOptions } from './types/options';
419425
export type { Package } from './types/package';
420426
export type { PolymorphicEvent, PolymorphicRequest } from './types/polymorphics';
421427
export type {

‎packages/core/src/types/options.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,14 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
373373
*/
374374
tunnel?: string;
375375

376+
/**
377+
* Set by server SDKs when the managed tunnel is enabled. Server request handlers check it to decide whether to
378+
* serve the managed tunnel.
379+
*
380+
* @internal This option is not part of the public API and is subject to change at any time.
381+
*/
382+
_managedTunnel?: TunnelOptions;
383+
376384
/**
377385
* Controls what data the SDK collects and sends to Sentry.
378386
* All fields are optional — omitted fields use the documented defaults.
@@ -645,6 +653,21 @@ export type BeforeSendStreamedSpanCallback = (span: StreamedSpanJSON) => Streame
645653
*/
646654
export type BeforeSendStaticSpanCallback = (span: SpanJSON) => SpanJSON;
647655

656+
/** Options for the managed tunnel, see `tunnel`. */
657+
export interface TunnelOptions {
658+
/**
659+
* Server only. DSNs whose envelopes the tunnel forwards. Defaults to the server's own DSN, so list the browser DSN
660+
* here when the browser uses a different one.
661+
*/
662+
allowedDsns?: string[];
663+
664+
/** Path prefix the app is served under, e.g. `/docs`. Set the same value in the browser and the server `init`. */
665+
basePath?: string;
666+
667+
/** Browser only. Append a trailing slash to the tunnel path, for servers that redirect paths without one. */
668+
trailingSlash?: boolean;
669+
}
670+
648671
/** Base configuration options for every SDK. */
649672
export interface CoreOptions<TO extends BaseTransportOptions = BaseTransportOptions> extends Omit<
650673
Partial<ClientOptions<TO>>,

‎packages/core/src/utils/tunnel.ts‎

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,76 @@
11
import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api';
2+
import { getClient } from '../currentScopes';
3+
import { suppressTracing } from '../tracing/trace';
4+
import type { DsnLike } from '../types/dsn';
5+
import type { TunnelOptions } from '../types/options';
26
import { debug } from './debug-logger';
3-
import { makeDsn } from './dsn';
7+
import { dsnToString, makeDsn } from './dsn';
48
import { parseEnvelope } from './envelope';
59

610
export interface HandleTunnelRequestOptions {
711
/** Incoming request containing the Sentry envelope as its body */
812
request: Request;
9-
/** Pre-parsed array of allowed DSN strings */
10-
allowedDsns: Array<string>;
13+
/** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */
14+
allowedDsns?: Array<string>;
15+
}
16+
17+
/**
18+
* Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own
19+
* envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead.
20+
*/
21+
export function resolveServerTunnelOption(tunnel: string | true | TunnelOptions | undefined): {
22+
tunnel: string | undefined;
23+
_managedTunnel?: TunnelOptions;
24+
} {
25+
if (typeof tunnel === 'string' || !tunnel) {
26+
return { tunnel };
27+
}
28+
29+
return { tunnel: undefined, _managedTunnel: tunnel === true ? {} : tunnel };
30+
}
31+
32+
/**
33+
* Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`.
34+
*
35+
* Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it
36+
* between them. The path is a hash so it carries nothing an ad-blocker filter list could match on.
37+
*/
38+
export function getTunnelPath(dsn: DsnLike): string | undefined {
39+
const components = makeDsn(dsn);
40+
return components ? `/${fnv1a(dsnToString(components)).toString(36)}` : undefined;
41+
}
42+
43+
/**
44+
* Returns true if the request targets the managed tunnel.
45+
*
46+
* Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its
47+
* `init` was called with `tunnel: true`.
48+
*/
49+
export function isTunnelRequest(method: string, pathname: string, options?: TunnelOptions): boolean {
50+
if (method !== 'POST') {
51+
return false;
52+
}
53+
54+
const tunnelOptions = options ?? getClient()?.getOptions()._managedTunnel;
55+
if (!tunnelOptions) {
56+
return false;
57+
}
58+
59+
const path = stripBasePath(pathname, tunnelOptions.basePath).replace(/\/$/, '');
60+
return resolveAllowedDsns(tunnelOptions.allowedDsns).some(dsn => getTunnelPath(dsn) === path);
61+
}
62+
63+
/**
64+
* Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller
65+
* can hand the request to the app.
66+
*/
67+
export function handleTunnelRequestIfMatched(request: Request, options?: TunnelOptions): Promise<Response> | undefined {
68+
if (!isTunnelRequest(request.method, new URL(request.url).pathname, options)) {
69+
return undefined;
70+
}
71+
72+
const allowedDsns = (options ?? getClient()?.getOptions()._managedTunnel)?.allowedDsns;
73+
return handleTunnelRequest({ request, allowedDsns });
1174
}
1275

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

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

64128
try {
65-
return await fetch(sentryIngestUrl, {
66-
method: 'POST',
67-
headers: {
68-
'Content-Type': 'application/x-sentry-envelope',
69-
},
70-
body,
71-
});
129+
// The forward is SDK plumbing, not app work, so it must not show up as a span.
130+
return await suppressTracing(() =>
131+
fetch(sentryIngestUrl, {
132+
method: 'POST',
133+
headers: {
134+
'Content-Type': 'application/x-sentry-envelope',
135+
},
136+
body,
137+
}),
138+
);
72139
} catch (error) {
73140
debug.error('Sentry tunnel: failed to forward envelope', error);
74141
return new Response('Failed to forward envelope to Sentry', { status: 500 });
75142
}
76143
}
144+
145+
function resolveAllowedDsns(allowedDsns: Array<string> | undefined): Array<string> {
146+
if (allowedDsns) {
147+
return allowedDsns;
148+
}
149+
150+
const dsn = getClient()?.getDsn();
151+
return dsn ? [dsnToString(dsn)] : [];
152+
}
153+
154+
function stripBasePath(pathname: string, basePath: string | undefined): string {
155+
const base = basePath?.replace(/\/$/, '');
156+
if (!base) {
157+
return pathname;
158+
}
159+
160+
return pathname === base || pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : pathname;
161+
}
162+
163+
/** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */
164+
function fnv1a(input: string): number {
165+
let hash = 0x811c9dc5;
166+
for (let i = 0; i < input.length; i++) {
167+
// eslint-disable-next-line no-bitwise
168+
hash ^= input.charCodeAt(i);
169+
hash = Math.imul(hash, 0x01000193);
170+
}
171+
// eslint-disable-next-line no-bitwise
172+
return hash >>> 0;
173+
}

0 commit comments

Comments
 (0)