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', 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/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/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..1504ba345677 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/managed-tunnel/test.ts @@ -0,0 +1,29 @@ +import { URL_PATH } from '@sentry/conventions/attributes'; +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 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(); + + 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); + }); +});