From fe6c48c49f0b25017774f72c493e734bd55f6c16 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:33:20 -0400 Subject: [PATCH 1/5] test: Check that runtimes drop the server spans of managed tunnel requests Meta-framework SDKs answer the tunnel requests. Cloudflare, Node, Bun and Deno only translate `tunnel: true` so the core client drops the server span they create for them, which these tests pin down. --- .../tracing/managed-tunnel/instrument.mjs | 11 +++++ .../suites/tracing/managed-tunnel/server.mjs | 10 +++++ .../suites/tracing/managed-tunnel/test.ts | 28 ++++++++++++ packages/bun/test/managedTunnel.test.ts | 44 +++++++++++++++++++ packages/cloudflare/test/request.test.ts | 40 +++++++++++++++++ packages/deno/test/deno-serve-tunnel.test.ts | 42 ++++++++++++++++++ 6 files changed, 175 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts create mode 100644 packages/bun/test/managedTunnel.test.ts create mode 100644 packages/deno/test/deno-serve-tunnel.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs new file mode 100644 index 000000000000..df798c3e0249 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/instrument.mjs @@ -0,0 +1,11 @@ +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, + // A meta-framework answers the tunnel requests, Node only drops their server spans. + tunnel: true, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs new file mode 100644 index 000000000000..ee4a7683d045 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/server.mjs @@ -0,0 +1,10 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +const server = http.createServer((_request, response) => { + response.end('app'); +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts new file mode 100644 index 000000000000..ea8a1db7d0c4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,28 @@ +import { getTunnelPath } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('managed tunnel', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('drops the server span of tunnel requests', async () => { + const runner = createRunner() + .expect({ + span: container => { + // Spans flush in order, so a span for the tunnel request would arrive before this one. + const serverSpan = container.items.find(item => item.is_segment); + expect(serverSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/after-tunnel' }); + }, + }) + .start(); + + await runner.makeRequest('post', getTunnelPath('https://public@dsn.ingest.sentry.io/1337')!); + await runner.makeRequest('get', '/after-tunnel'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/bun/test/managedTunnel.test.ts b/packages/bun/test/managedTunnel.test.ts new file mode 100644 index 000000000000..7523cf0a711c --- /dev/null +++ b/packages/bun/test/managedTunnel.test.ts @@ -0,0 +1,44 @@ +import type { TransactionEvent } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { init } from '../src'; + +const DSN = 'https://username@domain/123'; + +describe('managed tunnel', () => { + afterEach(() => { + SentryCore.getCurrentScope().setClient(undefined); + }); + + test('Bun.serve drops the server span of tunnel requests', async () => { + const transactions: string[] = []; + init({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + // A meta-framework answers the tunnel requests, Bun only drops their server spans. + tunnel: true, + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event.request?.url ?? ''); + return null; + }, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + }); + + const server = Bun.serve({ port: 0, fetch: () => new Response('app') }); + const baseUrl = `http://localhost:${server.port}`; + + const tunnelResponse = await fetch(`${baseUrl}${SentryCore.getTunnelPath(DSN)}`, { + method: 'POST', + body: 'envelope', + }); + expect(await tunnelResponse.text()).toBe('app'); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + await SentryCore.flush(); + server.stop(); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.endsWith('/after-tunnel')).toBe(true); + }); +}); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a8c9a3f735f0..48d194545e52 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -63,6 +63,46 @@ describe('withSentry', () => { }); }); + describe('managed tunnel', () => { + async function transactionsFor(url: string, tunnel?: true): Promise { + const transactions: string[] = []; + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tunnel, + tracesSampleRate: 1, + beforeSendTransaction(event) { + transactions.push(event.transaction ?? ''); + return null; + }, + }, + request: new Request(url, { method: 'POST' }), + context: createMockExecutionContext(), + }, + () => new Response('app'), + ).then(response => response.text()); + + // Wait for async span end and transaction capture + await new Promise(resolve => setTimeout(resolve, 50)); + + return transactions; + } + + test('leaves tunnel requests to the app and drops their server span', async () => { + const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; + + expect(await transactionsFor(tunnelUrl, true)).toEqual([]); + expect(await transactionsFor('https://example.com/api', true)).toEqual(['POST /api']); + }); + + test('keeps the span of the tunnel path when the managed tunnel is off', async () => { + const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; + + expect(await transactionsFor(tunnelUrl)).toHaveLength(1); + }); + }); + test('passes through the response from the handler', async () => { const response = new Response('test'); const result = await wrapRequestHandler( diff --git a/packages/deno/test/deno-serve-tunnel.test.ts b/packages/deno/test/deno-serve-tunnel.test.ts new file mode 100644 index 000000000000..67cd1e61c989 --- /dev/null +++ b/packages/deno/test/deno-serve-tunnel.test.ts @@ -0,0 +1,42 @@ +// + +import type { TransactionEvent } from '@sentry/core'; +import { getMainCarrier, getTunnelPath } from '@sentry/core'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { init } from '../build/esm/index.js'; + +const DSN = 'https://username@domain/123'; + +Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { + getMainCarrier().__SENTRY__ = undefined; + const transactions: string[] = []; + + init({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + // A meta-framework answers the tunnel requests, Deno only drops their server spans. + tunnel: true, + beforeSendTransaction: (event: TransactionEvent) => { + transactions.push(event.request?.url ?? ''); + return null; + }, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => new Response('app')); + await listening; + + const baseUrl = `http://localhost:${server.addr.port}`; + const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' }); + assertEquals(await tunnelResponse.text(), 'app'); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + + abortController.abort(); + await server.finished; + + assertEquals(transactions.length, 1); + assertEquals(transactions[0]?.endsWith('/after-tunnel'), true); +}); From bd5d7ab88f1d48d2eb8703883f7330052b23245d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:14:04 -0400 Subject: [PATCH 2/5] test(bun): Run the managed tunnel suite only with the Bun SDK --- dev-packages/bun-integration-tests/node-suites/excludes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 65edcd311712..5ca029909329 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -25,6 +25,7 @@ const NO_HTTP_SERVER_SPANS = [ 'suites/tracing/httpIntegration/test.ts', 'suites/tracing/httpServerSpans-unrouted/test.ts', 'suites/tracing/ignoreSpans/**', + 'suites/tracing/managed-tunnel/test.ts', 'suites/tracing/meta-tags-twp-errors/test.ts', 'suites/tracing/meta-tags/test.ts', 'suites/tracing/requestData/test.ts', From 1884646262c3f7a85e8d729079a2cfc6ddc75f1c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:40:36 -0400 Subject: [PATCH 3/5] test(node): Assert the tunnel request sends no server span at all --- .../suites/tracing/managed-tunnel/test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts index ea8a1db7d0c4..1504ba345677 100644 --- a/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -1,3 +1,4 @@ +import { URL_PATH } from '@sentry/conventions/attributes'; import { getTunnelPath } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -12,9 +13,9 @@ describe('managed tunnel', () => { const runner = createRunner() .expect({ span: container => { - // Spans flush in order, so a span for the tunnel request would arrive before this one. - const serverSpan = container.items.find(item => item.is_segment); - expect(serverSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/after-tunnel' }); + // Spans flush in order, so a span for the tunnel request would arrive in this container or before it. + const segmentPaths = container.items.filter(item => item.is_segment).map(item => item.attributes[URL_PATH]); + expect(segmentPaths).toEqual([{ type: 'string', value: '/after-tunnel' }]); }, }) .start(); From e81da2540864dc656cdfb6340683010c235e4257 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 20 Oct 2026 10:15:51 -0400 Subject: [PATCH 4/5] test(deno): Move the Deno.serve managed tunnel test to the Deno integration tests --- .../suites/deno-serve-managed-tunnel/test.ts | 49 +++++++++++++++++++ packages/deno/test/deno-serve-tunnel.test.ts | 42 ---------------- 2 files changed, 49 insertions(+), 42 deletions(-) create mode 100644 dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts delete mode 100644 packages/deno/test/deno-serve-tunnel.test.ts diff --git a/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts b/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts new file mode 100644 index 000000000000..2625f9ce5add --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/deno-serve-managed-tunnel/test.ts @@ -0,0 +1,49 @@ +// + +import { getTunnelPath } from '@sentry/core'; +import { flush, init } from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; + +const DSN = 'https://username@domain/123'; + +Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { + resetGlobals(); + const sink = spanSink(); + + init({ + dsn: DSN, + tracesSampleRate: 1, + // A meta-framework answers the tunnel requests, Deno only drops their server spans. + tunnel: true, + transport: sink.transport, + }); + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined; + const listening = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => new Response('app')); + await listening; + + try { + const baseUrl = `http://localhost:${server.addr.port}`; + const anchorSpanPromise = withTimeout( + sink.waitFor(span => getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/after-tunnel'), + 5_000, + 'segment span for /after-tunnel', + ); + + await (await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' })).text(); + await (await fetch(`${baseUrl}/after-tunnel`)).text(); + + await flush(); + await anchorSpanPromise; + assertEquals( + sink.spans.filter(span => getSpanOp(span) === 'http.server').map(span => span.attributes['url.path']?.value), + ['/after-tunnel'], + ); + } finally { + abortController.abort(); + await server.finished; + } +}); diff --git a/packages/deno/test/deno-serve-tunnel.test.ts b/packages/deno/test/deno-serve-tunnel.test.ts deleted file mode 100644 index 67cd1e61c989..000000000000 --- a/packages/deno/test/deno-serve-tunnel.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// - -import type { TransactionEvent } from '@sentry/core'; -import { getMainCarrier, getTunnelPath } from '@sentry/core'; -import { assertEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; -import { init } from '../build/esm/index.js'; - -const DSN = 'https://username@domain/123'; - -Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => { - getMainCarrier().__SENTRY__ = undefined; - const transactions: string[] = []; - - init({ - dsn: DSN, - tracesSampleRate: 1, - traceLifecycle: 'static', - // A meta-framework answers the tunnel requests, Deno only drops their server spans. - tunnel: true, - beforeSendTransaction: (event: TransactionEvent) => { - transactions.push(event.request?.url ?? ''); - return null; - }, - }); - - const abortController = new AbortController(); - let onListen: ((_: unknown) => void) | undefined = undefined; - const listening = new Promise(resolve => (onListen = resolve)); - const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => new Response('app')); - await listening; - - const baseUrl = `http://localhost:${server.addr.port}`; - const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' }); - assertEquals(await tunnelResponse.text(), 'app'); - await (await fetch(`${baseUrl}/after-tunnel`)).text(); - - abortController.abort(); - await server.finished; - - assertEquals(transactions.length, 1); - assertEquals(transactions[0]?.endsWith('/after-tunnel'), true); -}); From 74fe197af55d0434d46a25c577bbc88082a17e39 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 20 Oct 2026 10:15:51 -0400 Subject: [PATCH 5/5] test(cloudflare): Move the managed tunnel span test to a workerd integration test --- .../suites/tracing/managed-tunnel/index.ts | 24 +++++++++++ .../suites/tracing/managed-tunnel/test.ts | 25 ++++++++++++ .../tracing/managed-tunnel/wrangler.jsonc | 6 +++ packages/cloudflare/test/request.test.ts | 40 ------------------- 4 files changed, 55 insertions(+), 40 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts new file mode 100644 index 000000000000..fc4b5f6b5111 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/index.ts @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/cloudflare'; +import { getTunnelPath } from '@sentry/core'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + // A meta-framework answers the tunnel requests, the Worker only drops their server spans. + tunnel: true, + }), + { + async fetch(request, env) { + if (new URL(request.url).pathname === '/tunnel-path') { + return new Response(getTunnelPath(env.SENTRY_DSN)); + } + + return new Response('app'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts new file mode 100644 index 000000000000..98e9570be20a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +function expectServerSpanAt(path: string) { + return (envelope: Parameters[0]) => { + const serverSpans = getSpansFromEnvelope(envelope).filter(span => getSpanOp(span) === 'http.server'); + + expect(serverSpans.map(span => span.attributes['url.path']?.value)).toEqual([path]); + }; +} + +it('drops the server span of managed tunnel requests', async ({ signal }) => { + // Envelopes are expected in order, so a span of the tunnel request would arrive before the last one. + const runner = createRunner(__dirname) + .expect(expectServerSpanAt('/tunnel-path')) + .expect(expectServerSpanAt('/after-tunnel')) + .start(signal); + + const tunnelPath = await runner.makeRequest('get', '/tunnel-path'); + expect(await runner.makeRequest('post', tunnelPath!, { data: 'envelope' })).toBe('app'); + await runner.makeRequest('get', '/after-tunnel'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/wrangler.jsonc new file mode 100644 index 000000000000..82a7dde2b5c2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/managed-tunnel/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "managed-tunnel", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 48d194545e52..a8c9a3f735f0 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -63,46 +63,6 @@ describe('withSentry', () => { }); }); - describe('managed tunnel', () => { - async function transactionsFor(url: string, tunnel?: true): Promise { - const transactions: string[] = []; - await wrapRequestHandler( - { - options: { - ...MOCK_OPTIONS, - tunnel, - tracesSampleRate: 1, - beforeSendTransaction(event) { - transactions.push(event.transaction ?? ''); - return null; - }, - }, - request: new Request(url, { method: 'POST' }), - context: createMockExecutionContext(), - }, - () => new Response('app'), - ).then(response => response.text()); - - // Wait for async span end and transaction capture - await new Promise(resolve => setTimeout(resolve, 50)); - - return transactions; - } - - test('leaves tunnel requests to the app and drops their server span', async () => { - const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; - - expect(await transactionsFor(tunnelUrl, true)).toEqual([]); - expect(await transactionsFor('https://example.com/api', true)).toEqual(['POST /api']); - }); - - test('keeps the span of the tunnel path when the managed tunnel is off', async () => { - const tunnelUrl = `https://example.com${SentryCore.getTunnelPath(MOCK_OPTIONS.dsn!)}`; - - expect(await transactionsFor(tunnelUrl)).toHaveLength(1); - }); - }); - test('passes through the response from the handler', async () => { const response = new Response('test'); const result = await wrapRequestHandler(