From cef33a84d5ec80251487e5114d551c6d8c24b387 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 13:33:20 -0400 Subject: [PATCH 1/3] 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 ae30155c3c6a..b4118c0a8c3d 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 2b60b2c56877374dfeaa7377cd30636b1055023f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 14:14:04 -0400 Subject: [PATCH 2/3] 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 17266686b800054d5db5a3c71a443e5b1f9090c8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 15 Oct 2026 21:40:36 -0400 Subject: [PATCH 3/3] 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();