Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ae3cbec
feat(core): Add the primitives to answer managed tunnel requests in t…
logaretm Nov 2, 2026
bc33cba
ref(core): Keep the managed tunnel state behind a registry module
logaretm Nov 3, 2026
6435b16
ref(core): Rename the tunnel registry's client accessors
logaretm Nov 3, 2026
3fd8179
ref(core): Read the tunnel forwarder from the shared registry only
logaretm Nov 3, 2026
0fdfaf1
ref(core): Name the tunnel registry's carrier key after it
logaretm Nov 3, 2026
c92ecd2
ref(core): Rename the tunnel registry to the tunnel store
logaretm Nov 3, 2026
a672b62
ref: Rename the managed tunnel defaults to tunnel defaults
logaretm Nov 3, 2026
8180949
ref(core): Name the client tunnel resolver applyTunnelDefaults
logaretm Nov 3, 2026
a63290a
ref: Name the browser and server tunnel defaults functions after wher…
logaretm Nov 3, 2026
cc48a54
fix(core): Isolate tunnel requests the app answers itself
logaretm Nov 5, 2026
2d0875b
fix(core): Set the client on the isolation scope of tunnel requests t…
logaretm Nov 5, 2026
46d84af
fix(server-utils): Capture errors and request data of tunnel requests…
logaretm Nov 5, 2026
9c67136
fix(core): Deliver tunnel envelopes after the browser leaves
logaretm Nov 5, 2026
f92c9ac
fix(core): Claim tunnel requests before forwarding them
logaretm Nov 5, 2026
3d4645d
test(node): Leave the tunnel only after it started the upload to Sentry
logaretm Nov 5, 2026
bf844da
ref(core): Drop the unused close listener from the tunnel's Node resp…
logaretm Nov 6, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getTunnelPath } from '@sentry/core';
import { serveTunnelIntegration } from '@sentry/core/server';
import { wrapRequestHandler } from '@sentry/cloudflare/request';

interface Env {
SENTRY_DSN: string;
}

export default {
async fetch(request, env, ctx) {
return wrapRequestHandler(
{
options: {
dsn: env.SENTRY_DSN,
// The app answers the tunnel requests itself.
integrations: [serveTunnelIntegration({ intercept: false })],
},
request,
context: ctx,
},
() => {
if (new URL(request.url).pathname === '/tunnel-path') {
return new Response(getTunnelPath(env.SENTRY_DSN));
}

throw new Error('The app failed to answer a tunnel request');
},
);
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Event } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

it('captures errors the app throws while it answers tunnel requests, with their own request data', async ({
signal,
}) => {
let tunnelPath: string | undefined;
const runner = createRunner(__dirname)
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.exception?.values?.[0]?.value).toBe('The app failed to answer a tunnel request');
// Without `AsyncLocalStorage`, the previous request's data would still be on the shared isolation scope.
expect(event.request?.method).toBe('POST');
expect(new URL(event.request!.url!).pathname).toBe(tunnelPath);
})
.start(signal);

tunnelPath = await runner.makeRequest<string>('get', '/tunnel-path');
await runner.makeRequest('post', tunnelPath!, { data: 'envelope', expectError: true });

await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "managed-tunnel-app-errors",
"compatibility_date": "2025-06-17",
"main": "index.ts",
// No `nodejs_compat`, so no `AsyncLocalStorage`: all requests share one isolation scope.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
// The test's fake ingest, so the tunnel forwards the browser's envelopes to it.
dsn: `http://public@localhost:${process.env.INGEST_PORT}/1337`,
release: '1.0',
integrations: [Sentry.serveTunnelIntegration({ intercept: false })],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [Sentry.serveTunnelIntegration({ intercept: false })],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/node';
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
import http from 'http';

const server = http.createServer((request, response) => {
if (!Sentry.handleTunnelRequestIfMatched(request, response)) {
response.end('app');
}
});

server.listen(0, () => {
sendPortToRunner(server.address().port);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Sentry from '@sentry/node';
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
import http from 'http';

const server = http.createServer((request, response) => {
// Middleware that tags the request's user, run here on the tunnel request the app answers itself.
if (request.method === 'POST') {
Sentry.setUser({ id: 'tunnel-user' });
response.end('app');
return;
}

Sentry.captureException(new Error('after tunnel'));
response.end('app');
});

server.listen(0, () => {
sendPortToRunner(server.address().port);
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,93 @@
import { URL_PATH } from '@sentry/conventions/attributes';
import { getTunnelPath } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import * as http from 'http';
import type { AddressInfo } from 'net';
import * as net from 'net';
import { afterAll, describe, expect, vi } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

interface FakeIngest {
port: number;
/** Uploads the tunnel started to Sentry. */
uploads: number;
/** Envelopes Sentry received in full and answered. */
delivered: string[];
/** Uploads that broke off before the whole envelope arrived. */
brokenUploads: number;
close(): Promise<void>;
}

/**
* A Sentry ingest that answers late, so the browser can leave while the tunnel waits on it.
*/
async function startFakeIngest(): Promise<FakeIngest> {
const delivered: string[] = [];
let uploads = 0;
let brokenUploads = 0;
const server = http.createServer((request, response) => {
uploads++;
const chunks: Buffer[] = [];
let ended = false;
request.on('data', chunk => chunks.push(chunk));
request.on('close', () => {
if (!ended) {
brokenUploads++;
}
});
request.on('end', () => {
ended = true;
setTimeout(() => {
// The tunnel gave up on the forward.
if (request.socket.destroyed) {
return;
}
response.end('{}');
delivered.push(Buffer.concat(chunks).toString());
}, 300);
});
});
await new Promise<void>(resolve => server.listen(0, resolve));

return {
port: (server.address() as AddressInfo).port,
delivered,
get uploads() {
return uploads;
},
get brokenUploads() {
return brokenUploads;
},
close: () => new Promise(resolve => server.close(() => resolve())),
};
}

/**
* Posts the first `sentLength` characters of `body` to the tunnel like a browser would, then leaves once the tunnel
* started the upload to Sentry, before it answers.
*/
async function postAndLeave(
port: number,
path: string,
body: string,
ingest: FakeIngest,
sentLength = body.length,
): Promise<void> {
const socket = await new Promise<net.Socket>(resolve => {
const connection = net.connect(port, 'localhost', () => resolve(connection));
});
socket.write(
`POST ${path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-sentry-envelope\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n`,
);
socket.write(body.slice(0, sentLength));
// Leaving earlier would only test the tunnel rejecting a body that broke off before it contacted Sentry.
await vi.waitUntil(() => ingest.uploads > 0, { timeout: 5_000 });
socket.destroy();
}

function makeEnvelope(dsn: string, message: string): string {
return [JSON.stringify({ dsn }), JSON.stringify({ type: 'event' }), JSON.stringify({ message })].join('\n');
}

describe('managed tunnel', () => {
afterAll(() => {
cleanupChildProcesses();
Expand All @@ -26,4 +111,77 @@ describe('managed tunnel', () => {
await runner.completed();
});
});

createEsmAndCjsTests(__dirname, 'server-forward.mjs', 'instrument-forward.mjs', (createRunner, test) => {
test('delivers an envelope it received in full after the browser leaves', async () => {
const ingest = await startFakeIngest();
const dsn = `http://public@localhost:${ingest.port}/1337`;
const runner = createRunner()
.withEnv({ INGEST_PORT: String(ingest.port) })
.start();

try {
expect(await runner.makeRequest<string>('get', '/')).toBe('app');
await postAndLeave(runner.getPort()!, getTunnelPath(dsn)!, makeEnvelope(dsn, 'sent in full'), ingest);

await vi.waitUntil(() => ingest.delivered.length > 0, { timeout: 5_000 });
expect(ingest.delivered[0]).toContain('sent in full');
} finally {
runner.sendSignal('SIGTERM');
await ingest.close();
}
});

test('breaks off the forward when the browser leaves mid-upload', async () => {
const ingest = await startFakeIngest();
const dsn = `http://public@localhost:${ingest.port}/1337`;
const runner = createRunner()
.withEnv({ INGEST_PORT: String(ingest.port) })
.start();
const envelope = makeEnvelope(dsn, 'a'.repeat(64 * 1024));

try {
expect(await runner.makeRequest<string>('get', '/')).toBe('app');
await postAndLeave(runner.getPort()!, getTunnelPath(dsn)!, envelope, ingest, 1024);

await vi.waitUntil(() => ingest.brokenUploads > 0, { timeout: 5_000 });
expect(ingest.delivered).toEqual([]);
} finally {
runner.sendSignal('SIGTERM');
await ingest.close();
}
});
});

createEsmAndCjsTests(
__dirname,
'server-intercept-false.mjs',
'instrument-intercept-false.mjs',
(createRunner, test) => {
test('records nothing for tunnel requests the app answers, and keeps their scope data isolated', async () => {
const runner = createRunner()
.unordered()
.expect({
span: container => {
const segmentPaths = container.items
.filter(item => item.is_segment)
.map(item => item.attributes[URL_PATH]);
expect(segmentPaths).toEqual([{ type: 'string', value: '/after-tunnel' }]);
},
})
.expect({
event: event => {
expect(event.exception?.values?.[0]?.value).toBe('after tunnel');
expect(event.user?.id).toBeUndefined();
},
})
.start();

await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!);
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
});
},
);
});
2 changes: 1 addition & 1 deletion packages/astro/src/integration/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { handleRequest } from '../../server/middleware';

// The one `@sentry/astro` entry that resolves to server code on every runtime, so the integration's virtual module
// imports it from here.
export { _INTERNAL_setManagedTunnelDefaults } from '@sentry/core/server';
export { _INTERNAL_setTunnelDefaults } from '@sentry/core/server';

type MiddlewareNext = () => Promise<Response>;
type MiddlewareHandler = (ctx: unknown, next: MiddlewareNext) => Promise<Response> | Response | Promise<void> | void;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/integration/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function makeTunnelOptionsVitePlugin(tunnelOptions: BrowserTunnelOptions)
}

return options?.ssr
? `import { _INTERNAL_setManagedTunnelDefaults } from '@sentry/astro/middleware';\n_INTERNAL_setManagedTunnelDefaults(${JSON.stringify({ basePath: tunnelOptions.basePath })});`
? `import { _INTERNAL_setTunnelDefaults } from '@sentry/astro/middleware';\n_INTERNAL_setTunnelDefaults(${JSON.stringify({ basePath: tunnelOptions.basePath })});`
: `globalThis[${JSON.stringify(TUNNEL_OPTIONS_GLOBAL)}] = ${JSON.stringify(tunnelOptions)};`;
},
transform: (code, id) =>
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/tunnel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BrowserTunnelOptions, Options } from '@sentry/core';
import { _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, GLOBAL_OBJ } from '@sentry/core';
import { _INTERNAL_applyBrowserTunnelDefaults as applyBrowserTunnelDefaults, GLOBAL_OBJ } from '@sentry/core';

/** Set by the Sentry Astro integration from the `base` and `trailingSlash` config. */
export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options';
Expand All @@ -10,5 +10,5 @@ export const TUNNEL_OPTIONS_GLOBAL = '__sentry_astro_tunnel_options';
*/
export function applyAstroTunnelOptions(options: Pick<Options, 'tunnel'>): void {
const astroTunnelOptions = (GLOBAL_OBJ as { [TUNNEL_OPTIONS_GLOBAL]?: BrowserTunnelOptions })[TUNNEL_OPTIONS_GLOBAL];
options.tunnel = applyTunnelDefaults(options.tunnel, astroTunnelOptions);
options.tunnel = applyBrowserTunnelDefaults(options.tunnel, astroTunnelOptions);
}
4 changes: 4 additions & 0 deletions packages/core/src/carrier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Scope } from './scope';
import type { SegmentSpanCaptureStrategy } from './tracing/segmentSpanCaptureStrategy';
import type { SerializedLog } from './types/log';
import type { SerializedMetric } from './types/metric';
import type { TunnelStore } from './utils/tunnelStore';
import { SDK_VERSION } from './utils/version';
import { GLOBAL_OBJ } from './utils/worldwide';

Expand Down Expand Up @@ -50,6 +51,9 @@ export interface SentryCarrier {
encodePolyfill?: (input: string) => Uint8Array;
/** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */
decodePolyfill?: (input: Uint8Array) => string;

/** Only `utils/tunnelStore.ts` reads or writes it. */
tunnelStore?: TunnelStore;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export { parameterize, fmt } from './utils/parameterize';
export type { HandleTunnelRequestOptions } from './utils/tunnelForward';
export { handleTunnelRequest } from './utils/tunnelForward';
export {
applyTunnelDefaults as _INTERNAL_applyTunnelDefaults,
applyBrowserTunnelDefaults as _INTERNAL_applyBrowserTunnelDefaults,
getTunnelPath,
normalizeTunnelBasePath as _INTERNAL_normalizeTunnelBasePath,
} from './utils/tunnel';
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/integrations/http/server-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { HttpIncomingMessage, HttpInstrumentationOptions, HttpServer, HttpS
import { DEBUG_BUILD } from '../../debug-build';
import { debug } from '../../utils/debug-logger';
import { safeCallback } from '../../utils/safeCallback';
import { isServedTunnelRequest } from '../../utils/tunnel';
import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from '../../currentScopes';
import { hasSpansEnabled } from '../../utils/hasSpansEnabled';
import {
Expand Down Expand Up @@ -132,6 +133,15 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht
return target.apply(thisArg, args);
}

// Tunnel requests record no session or span. The app may still answer them (`intercept: false`), so they keep
// their own isolation scope.
if (isServedTunnelRequest(request)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tunnel match ignores known client

Low Severity

This is more an "is this necessary" check than a hard violation: isServedTunnelRequest(request) calls getClient() internally even though client is already in hand. wrapServerRequestHandler in this same PR passes that client through.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit bf844da. Configure here.

const tunnelIsolationScope = getIsolationScope().clone();
tunnelIsolationScope.setClient(client);

return withIsolationScope(tunnelIsolationScope, () => target.apply(thisArg, args));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tunnel errors omit request data

Medium Severity

The Node isServedTunnelRequest path clones an isolation scope but never sets normalizedRequest. Errors thrown while the app answers a tunnel request with intercept: false are captured without method or URL, unlike the WinterCG handler which attaches request data (without the body).

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bf844da. Configure here.


DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Handling incoming request');
const isolationScope = getIsolationScope().clone();
isolationScope.setClient(client);
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/integrations/serveTunnel.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { defineIntegration } from '../integration';
import type { IntegrationFn } from '../types/integration';
import type { ServerTunnelOptions } from '../types/options';
import { enableManagedTunnel } from '../utils/managedTunnelDefaults';
import { SERVE_TUNNEL_INTEGRATION_NAME } from '../utils/tunnel';
import { enableManagedTunnel } from '../utils/tunnelDefaults';
import { forwardTunnelRequest } from '../utils/tunnelForward';

const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => {
return {
name: SERVE_TUNNEL_INTEGRATION_NAME,
forward: forwardTunnelRequest,
name: 'ServeTunnel',
setup(client) {
enableManagedTunnel(client, options, forwardTunnelRequest);
},
Expand Down
Loading
Loading