Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3f952f2
feat: Export the managed tunnel API from every server SDK
logaretm Oct 27, 2026
a5fc7f8
ref(core): Drop the unread `headers` from `TunnelRequestLike`
logaretm Oct 27, 2026
bdf9e35
feat(core): Take the request first in `handleTunnelRequest`, and acce…
logaretm Oct 27, 2026
7ba896a
feat(core): Default `handleTunnelRequest` to the DSNs `serveTunnelInt…
logaretm Oct 27, 2026
39e6f33
feat(core): Let the tunnel helpers take options, then fall back to `s…
logaretm Oct 27, 2026
139d00e
fix(core): Deprecate the `request` option instead of the whole `handl…
logaretm Oct 27, 2026
f057ec8
feat(solidstart, elysia): Export the managed tunnel API from their ex…
logaretm Oct 27, 2026
b2dcf4c
fix(core): Serve the tunnel over HTTP/2, and from copies of core that…
logaretm Oct 27, 2026
f0f95f3
fix(core): Drop connection headers when a Node server answers a tunne…
logaretm Oct 27, 2026
6a2c974
ref(core): Trim the tunnel helpers
logaretm Oct 27, 2026
e32379d
fix(core): Stream the Node tunnel body without ReadableStream.from, w…
logaretm Oct 27, 2026
32fdf4f
ref(core): Resolve the served tunnel and allowed DSNs once per request
logaretm Oct 27, 2026
5e0a515
fix(core): Build the Node tunnel request on a fixed URL, not the clie…
logaretm Oct 27, 2026
d3a9544
fix(core): Answer a Node tunnel request with a 500 when Sentry's answ…
logaretm Oct 27, 2026
e80e999
fix(solidstart): Serve the tunnel only with serveTunnelIntegration, l…
logaretm Oct 27, 2026
c4850c7
fix(core): Reject a non-string envelope DSN, keep the released handle…
logaretm Oct 27, 2026
1c77ce4
fix(core): Drain the rest of a rejected Node tunnel request
logaretm Oct 28, 2026
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
4 changes: 4 additions & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ export {
tediousIntegration,
trpcMiddleware,
serveTunnelIntegration,
getTunnelPath,
handleTunnelRequest,
handleTunnelRequestIfMatched,
isTunnelRequest,
Comment thread
logaretm marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: what's the use case for exporting isTunnelRequest publicly? Do users need it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They can use it to match a request manually if they don't use handleTunnelRequestIfMatched so I would keep it.

updateSpanName,
vercelAIIntegration,
withActiveSpan,
Expand Down
7 changes: 5 additions & 2 deletions packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
filterCollectedUrl,
filterCollectedUrlQuery,
} from '@sentry/core';
import { flushIfServerless, handleTunnelRequestIfMatched } from '@sentry/core/server';
import {
flushIfServerless,
_INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest,
} from '@sentry/core/server';
import {
captureException,
continueTrace,
Expand Down Expand Up @@ -87,7 +90,7 @@ export const handleRequest: (options?: MiddlewareOptions) => MiddlewareHandler =
return next();
}

const tunnelResponse = handleTunnelRequestIfMatched(ctx.request);
const tunnelResponse = handleServedTunnelRequest(ctx.request);
if (tunnelResponse) {
return tunnelResponse;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/test/server/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('sentryMiddleware with the managed tunnel', () => {
const getClientSpy = vi.spyOn(SentryNode, 'getClient').mockReturnValue({} as Client);
const tunnelResponse = new Response('forwarded');
const matcherSpy = vi
.spyOn(SentryCoreServer, 'handleTunnelRequestIfMatched')
.spyOn(SentryCoreServer, '_INTERNAL_handleServedTunnelRequest')
.mockReturnValue(Promise.resolve(tunnelResponse));
const next = vi.fn();
const request = new Request('https://myDomain.io/abc123', { method: 'POST' });
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ export {
systemErrorIntegration,
trpcMiddleware,
serveTunnelIntegration,
getTunnelPath,
handleTunnelRequest,
handleTunnelRequestIfMatched,
isTunnelRequest,
updateSpanName,
supabaseIntegration,
instrumentSupabaseClient,
Expand Down Expand Up @@ -193,6 +197,7 @@ export {
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/node';
export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node';

export {
captureConsoleIntegration,
Expand Down
5 changes: 5 additions & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ export {
spanToBaggageHeader,
trpcMiddleware,
serveTunnelIntegration,
getTunnelPath,
handleTunnelRequest,
handleTunnelRequestIfMatched,
isTunnelRequest,
updateSpanName,
supabaseIntegration,
instrumentSupabaseClient,
Expand Down Expand Up @@ -214,6 +218,7 @@ export {
eveIntegration,
getInstrumentedModuleNames,
} from '@sentry/node';
export type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '@sentry/node';

export {
captureConsoleIntegration,
Expand Down
11 changes: 10 additions & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,16 @@ export {
withStreamedSpan,
spanStreamingIntegration,
} from '@sentry/core';
export { trpcMiddleware, serveTunnelIntegration, wrapMcpServerWithSentry } from '@sentry/core/server';
export {
handleTunnelRequestIfMatched,
isTunnelRequest,
serveTunnelIntegration,
trpcMiddleware,
wrapMcpServerWithSentry,
} from '@sentry/core/server';
export type { TunnelRequestLike } from '@sentry/core/server';
export { getTunnelPath, handleTunnelRequest } from '@sentry/core';
export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core';
export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils';
export type { FlueOptions } from '@sentry/server-utils';

Expand Down
23 changes: 23 additions & 0 deletions packages/cloudflare/test/tunnelExports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import * as Sentry from '../src';
import type { HandleTunnelRequestOptions, ServerTunnelOptions, TunnelRequestLike } from '../src';

describe('managed tunnel exports', () => {
it.each([
'serveTunnelIntegration',
'handleTunnelRequestIfMatched',
'isTunnelRequest',
'getTunnelPath',
'handleTunnelRequest',
])('exports %s for wiring the tunnel by hand', name => {
expect(typeof (Sentry as Record<string, unknown>)[name]).toBe('function');
});

it('exports the tunnel types', () => {
const tunnelOptions: ServerTunnelOptions = { allowedDsns: [] };
const request: TunnelRequestLike = { method: 'POST', url: '/tunnel' };
const handleOptions: HandleTunnelRequestOptions = { request: new Request('http://localhost/tunnel') };

expect([tunnelOptions, request, handleOptions]).toHaveLength(3);
});
});
3 changes: 2 additions & 1 deletion packages/core/src/integrations/serveTunnel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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 { enableManagedTunnel } from '../utils/managedTunnelDefaults';
import { SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/tunnel';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L and out of scope of the changes of his PR: The JSDoc of serveTunnelIntegration doesn't read very user-facing, making it hard to understand when users should add it and how. I'd reword it a bit (can't make a direct suggestion since the change isn't part of this PR).

Here's a rough suggestion:

/**
 * Configures your Sentry server SDK to handle and forward requests from a Sentry browser SDK configured with `tunnel: true`.
 *
 * With this integration on the server and `tunnel: true` in the browser `Sentry.init`, the browser SDK sends its events to  * your own server instead, and your server sends them on to Sentry.
 *
 * In meta frameworks instrumented with Sentry (e.g. NextJS, Nuxt or SvelteKit), this integration is already enabled. For  standalone server applications (e.g. Express or Fastify), add this integration and call `handleTunnelRequestIfMatched` in your request handler.
 * 
 * @example
 * ```js
 * // Server
 * Sentry.init({
 *   dsn: '__DSN__',
 *   integrations: [Sentry.serveTunnelIntegration()],
 * });
 *
 * // Browser
 * Sentry.init({
 *   dsn: '__DSN__',
 *   tunnel: true,
 * });
 * ```
 */

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do, will do a follow up PR for this.

import { forwardTunnelRequest } from '../utils/tunnelForward';

const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => {
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ export { ServerRuntimeClient } from './server-runtime-client';
export type { TunnelRequestLike } from './utils/tunnel';
export {
getTunnelPaths as _INTERNAL_getTunnelPaths,
handleTunnelRequestIfMatched,
handleServedTunnelRequest as _INTERNAL_handleServedTunnelRequest,
isServedTunnelRequest as _INTERNAL_isServedTunnelRequest,
isTunnelRequest,
} from './utils/tunnel';
export { forwardTunnelRequest as _INTERNAL_forwardTunnelRequest } from './utils/tunnelForward';
export {
forwardTunnelRequest as _INTERNAL_forwardTunnelRequest,
handleTunnelRequestIfMatched,
} from './utils/tunnelForward';
export { serveTunnelIntegration } from './integrations/serveTunnel';
export {
_INTERNAL_resetManagedTunnelDefaults,
Expand Down
20 changes: 3 additions & 17 deletions packages/core/src/utils/managedTunnelDefaults.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
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 {
getServeTunnelForwarder,
getTunnelIgnoreSpanFilters,
normalizeTunnelBasePath,
resolveAllowedDsns,
resolveTunnelPaths,
setServedTunnel,
} from './tunnel';

Expand All @@ -29,12 +27,6 @@ interface ManagedTunnelDefaults {
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<Client, TunnelForwarder>();
Expand Down Expand Up @@ -78,11 +70,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void {
const tunnelOptions: ServerTunnelOptions | undefined = options?._managedTunnel ?? managedTunnelDefaults?.tunnel;
// 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?.<Integration & { forward?: TunnelForwarder }>(SERVE_TUNNEL_INTEGRATION_NAME)
?.forward ??
managedTunnelDefaults?.forward);
client && (tunnelForwarders.get(client) ?? getServeTunnelForwarder(client) ?? managedTunnelDefaults?.forward);
if (!client || !options || !tunnelOptions || !forward) {
return;
}
Expand All @@ -93,9 +81,7 @@ export function applyManagedTunnelDefaults(client: Client | undefined): void {
? { ...tunnelOptions, basePath }
: tunnelOptions;
options._managedTunnel = resolved;
const allowedDsns = resolveAllowedDsns(resolved.allowedDsns, client.getDsn());
const paths = resolveTunnelPaths(resolved, allowedDsns);
setServedTunnel(client, { paths, allowedDsns, forward });
const { paths } = setServedTunnel(client, resolved, forward);

const previousFilters = installedSpanFilters.get(client) ?? [];
const spanFilters = getTunnelIgnoreSpanFilters(resolved, paths);
Expand Down
125 changes: 102 additions & 23 deletions packages/core/src/utils/tunnel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Client } from '../client';
import type { Integration } from '../types/integration';
import { getClient } from '../currentScopes';
import { URL_PATH } from '@sentry/conventions/attributes';
import type { DsnComponents, DsnLike } from '../types/dsn';
Expand All @@ -13,27 +14,68 @@ import { parseUrl } from './url';
export type TunnelForwarder = (request: Request, allowedDsns: Array<string>) => Promise<Response>;

/**
* A server client's tunnel paths, allowed DSNs and forwarder, computed once per client.
* A server client's tunnel paths, base path, allowed DSNs and forwarder, computed once per client.
*/
interface ServedTunnel {
paths: string[];
allowedDsns: string[];
interface ServedTunnel extends TunnelMatch {
forward: TunnelForwarder;
}

const servedTunnels = new WeakMap<Client, ServedTunnel>();

/**
* Stores a server client's computed tunnel paths and allowed DSNs.
* 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';

/**
* Computes a server client's served tunnel from its resolved options, and stores it.
*/
export function setServedTunnel(client: Client, servedTunnel: ServedTunnel): void {
export function setServedTunnel(
client: Client,
tunnelOptions: ServerTunnelOptions,
forward: TunnelForwarder,
): ServedTunnel {
const allowedDsns = resolveAllowedDsns(tunnelOptions.allowedDsns, client.getDsn());
const servedTunnel = {
paths: resolveTunnelPaths(tunnelOptions, allowedDsns),
basePath: tunnelOptions.basePath,
allowedDsns,
forward,
};
servedTunnels.set(client, servedTunnel);

return servedTunnel;
}

/**
* The forwarder `serveTunnelIntegration` carries. Any copy of this module can reach it through the client.
*/
export function getServeTunnelForwarder(client: Client): TunnelForwarder | undefined {
return client.getIntegrationByName?.<Integration & { forward?: TunnelForwarder }>(SERVE_TUNNEL_INTEGRATION_NAME)
?.forward;
Comment on lines +55 to +56

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: I realize this isn't technically part of the PR but I was wondering why we put the forwarding function onto the integration? Would it be easier to store the forwarder directly on the client?

Kinda have the same question for the servedTunnels weakmap

@logaretm logaretm Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly bundle size, it's a server only feature so I didn't want it pulled into the client, which browser bundles share.

Happy to change them if u think the placement is worth the bundle size.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ok, I thought this would only affect ServerRuntimeClient. If we'd need to put it into the core client, I agree, then we should keep it out.

}

/**
* The active client's served tunnel. A copy of this module that didn't set it up rebuilds it once from what the
* client carries. Bundlers can give a framework's request hook its own copy, like Next.js's middleware next to its
* `instrumentation`.
*/
function getServedTunnel(): ServedTunnel | undefined {
const client = getClient();
if (!client) {
return undefined;
}

const servedTunnel = servedTunnels.get(client);
if (servedTunnel) {
return servedTunnel;
}

return client && servedTunnels.get(client);
const tunnelOptions = client.getOptions()._managedTunnel;
const forward = getServeTunnelForwarder(client);

return tunnelOptions && forward ? setServedTunnel(client, tunnelOptions, forward) : undefined;
}

/**
Expand All @@ -60,11 +102,11 @@ export function applyTunnelDefaults(
}

/**
* 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.
* Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`, by default the active client's. 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);
export function getTunnelPath(dsn?: DsnLike): string | undefined {
const components = dsn ? makeDsn(dsn) : getClient()?.getDsn();

return components ? `/${fnv1a32(dsnToString(components)).toString(36)}` : undefined;
}
Expand All @@ -76,7 +118,6 @@ export interface TunnelRequestLike {
method?: string;
/** Absolute URL or path with query, e.g. Node's `IncomingMessage.url`. */
url?: string;
headers?: Headers | Record<string, string | string[] | undefined>;
}

/**
Expand Down Expand Up @@ -133,29 +174,67 @@ export function normalizeTunnelBasePath(basePath: string | undefined): string {
}

/**
* Returns true if the request targets the active server client's managed tunnel.
* The paths, base path and allowed DSNs of a tunnel to match requests against.
*/
export function isTunnelRequest(request: TunnelRequestLike): boolean {
if (request.method !== 'POST' || !request.url) {
return false;
interface TunnelMatch {
paths: string[];
basePath?: string;
allowedDsns: string[];
}

/**
* Resolves the tunnel the public helpers match: `options`, else the one `serveTunnelIntegration` serves, else the
* path of the active client's own DSN.
*/
export function resolveTunnelMatch(options?: ServerTunnelOptions): TunnelMatch {
const servedTunnel = !options && getServedTunnel();
if (servedTunnel) {
return servedTunnel;
}

const tunnelOptions = getClient()?.getOptions()._managedTunnel;
if (!tunnelOptions) {
const allowedDsns = resolveAllowedDsns(options?.allowedDsns, getClient()?.getDsn());

return { paths: resolveTunnelPaths(options ?? {}, allowedDsns), basePath: options?.basePath, allowedDsns };
}

/**
* Returns true if the request is a POST to one of the tunnel's paths.
*/
export function matchesTunnel(request: TunnelRequestLike, tunnel: Omit<TunnelMatch, 'allowedDsns'>): boolean {
if (request.method !== 'POST' || !request.url) {
return false;
}

const path = stripBasePath(parseUrl(request.url).path || '/', tunnelOptions.basePath);
const path = stripBasePath(parseUrl(request.url).path || '/', tunnel.basePath);

return path !== undefined && tunnel.paths.includes(path.replace(/\/$/, ''));
}

/**
* Returns true if the request is a POST to the managed tunnel. The tunnel comes from `options`, else from
* `serveTunnelIntegration`, else it's the path of the active client's own DSN.
*/
export function isTunnelRequest(request: TunnelRequestLike, options?: ServerTunnelOptions): boolean {
return matchesTunnel(request, resolveTunnelMatch(options));
}

/**
* Returns true if the request targets the tunnel `serveTunnelIntegration` serves. Unlike {@link isTunnelRequest},
* there's no fallback, so the framework SDKs serve nothing without the integration.
*/
export function isServedTunnelRequest(request: TunnelRequestLike): boolean {
const servedTunnel = getServedTunnel();

return path !== undefined && getTunnelPaths().includes(path.replace(/\/$/, ''));
return !!servedTunnel && matchesTunnel(request, servedTunnel);
}

/**
* Forwards the request if it targets the managed tunnel, otherwise returns `undefined` for the app to handle.
* Forwards the request if it targets the tunnel `serveTunnelIntegration` serves, otherwise returns `undefined`. The
* framework SDKs answer tunnel requests with it.
*/
export function handleTunnelRequestIfMatched(request: Request): Promise<Response> | undefined {
export function handleServedTunnelRequest(request: Request): Promise<Response> | undefined {
const servedTunnel = getServedTunnel();
if (!servedTunnel?.allowedDsns.length || !isTunnelRequest(request)) {
if (!servedTunnel?.allowedDsns.length || !matchesTunnel(request, servedTunnel)) {
return undefined;
}

Expand Down
Loading
Loading