Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getTunnelPath } from '@sentry/core';

interface Env {
SENTRY_DSN: string;
}

// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time,
// with the options from `instrument.server.ts`.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (new URL(request.url).pathname === '/tunnel-path') {
return new Response(getTunnelPath(env.SENTRY_DSN));
}

return new Response('app');
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1,
// A meta-framework answers the tunnel requests, the Worker only drops their server spans.
integrations: [serveTunnelIntegration()],
}));
Original file line number Diff line number Diff line change
@@ -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<typeof getSpansFromEnvelope>[0]) => {
const serverSpans = getSpansFromEnvelope(envelope).filter(span => getSpanOp(span) === 'http.server');

expect(serverSpans.map(span => span.attributes['url.path']?.value)).toEqual([path]);
Comment thread
logaretm marked this conversation as resolved.
};
}

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<string>('get', '/tunnel-path');
expect(await runner.makeRequest<string>('post', tunnelPath!, { data: 'envelope' })).toBe('app');
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
Comment thread
logaretm marked this conversation as resolved.
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "../../../node_modules/wrangler/config-schema.json",
"name": "managed-tunnel",
// `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves
// the built output.
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// <reference lib="deno.ns" />

import { getTunnelPath } from '@sentry/core';
import { flush, init, serveTunnelIntegration } from '@sentry/deno';
import { assertEquals } from 'https://deno.land/[email protected]/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.
integrations: [serveTunnelIntegration()],
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;
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '1.0',
tracesSampleRate: 1.0,
// A meta-framework answers the tunnel requests, Node only drops their server spans.
integrations: [Sentry.serveTunnelIntegration()],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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://[email protected]/1337')!);
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
});
44 changes: 44 additions & 0 deletions packages/bun/test/managedTunnel.test.ts
Original file line number Diff line number Diff line change
@@ -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, serveTunnelIntegration } 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.
integrations: [serveTunnelIntegration()],
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);
});
});
Loading