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/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); + }); +}); 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); +});