Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c2b209d
ref(cloudflare): Use the shared request handler wrapper
isaacs Oct 20, 2026
8ecf660
fix(cloudflare): Attach request data to the current scope without Asy…
isaacs Oct 23, 2026
1c8c79c
feat: Answer managed tunnel requests in the runtimes
logaretm Oct 29, 2026
aaa30d0
ref: Remove the framework tunnel hooks
logaretm Oct 29, 2026
dabb352
ref: Install the runtime tunnel hooks in `init`
logaretm Oct 29, 2026
dec95b9
feat(aws-serverless): Answer managed tunnel requests in `wrapHandler`
logaretm Oct 29, 2026
7f0d38e
fix(nuxt): Give the Cloudflare request its body and support Nitro v3
logaretm Oct 29, 2026
1742481
fix(astro): Wrap Cloudflare Pages builds with `withSentry`
logaretm Oct 29, 2026
d1c549a
feat(node): Answer managed tunnel requests on Lambda
logaretm Oct 29, 2026
b663d6c
fix(core): Share the managed tunnel state across copies of core
logaretm Oct 29, 2026
caba154
fix(node): Keep the Lambda handler loader out of the main entry
logaretm Oct 29, 2026
3f767b3
test(tanstackstart-react): Drop the managed tunnel 405 test
logaretm Oct 29, 2026
aa25934
fix(astro): Initialize the server SDK from the server entry
logaretm Oct 30, 2026
42534db
test: Throw malformed and hostile requests at the runtime tunnel
logaretm Oct 30, 2026
5772e1a
test(bun): Skip the tunnel hardening node suite on Bun
logaretm Oct 30, 2026
82b3ff4
ref(nextjs): Forward tunnel route requests with core's `handleTunnelR…
logaretm Oct 28, 2026
c501ea8
feat(nextjs): Serve the browser's managed tunnel under the app's `bas…
logaretm Oct 29, 2026
579a3d9
feat(nextjs): Serve the managed tunnel under the app's `basePath` on …
logaretm Oct 29, 2026
09ad112
test(nextjs): Cover the managed tunnel under a base path
logaretm Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '521 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
7 changes: 7 additions & 0 deletions dev-packages/bun-integration-tests/node-suites/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const NO_HTTP_SERVER_SPANS = [
'suites/tracing/httpServerSpans-unrouted/test.ts',
'suites/tracing/ignoreSpans/**',
'suites/tracing/managed-tunnel/test.ts',
'suites/tunnel/hardening/test.ts',
'suites/tracing/meta-tags-twp-errors/test.ts',
'suites/tracing/meta-tags/test.ts',
'suites/tracing/requestData/test.ts',
Expand Down Expand Up @@ -193,7 +194,13 @@ export const NODE_SUITES_EXCLUDE = ['**/node_modules/**', ...NODE_ONLY, ...NO_AU
export const SENTRY_NODE_EXCLUDE = [...NO_HTTP_SERVER_SPANS, ...NO_FETCH_INSTRUMENTATION];

// Excluded only in the `node-suites-sentry-bun` project, which maps `@sentry/node` to `@sentry/bun`.
// Bun answers nothing on a connection whose first request awaits anything while a second one is
// queued behind it, which the pipelined cases need. `packages/bun` runs the same cases against Bun's
// servers without those.
const BUN_DROPS_PIPELINED_REQUESTS = ['suites/tunnel/hardening/test.ts'];

export const SENTRY_BUN_EXCLUDE = [
...BUN_DROPS_PIPELINED_REQUESTS,
...HTTP_SERVER_OPTIONS_IGNORED,
...NO_EXPRESS_INSTRUMENTATION,
...NO_NATIVE_NODE_FETCH_INTEGRATION,
Expand Down
5 changes: 5 additions & 0 deletions dev-packages/cloudflare-integration-tests/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ type StartResult = {
completed(): Promise<void>;
/** Every non-ignored envelope received so far, matched or not, for count assertions. */
getReceivedEnvelopes(): Envelope[];
/** The port wrangler serves the worker on, once it is ready. */
getPort(): Promise<number>;
makeRequest<T>(
method: 'get' | 'post',
path: string,
Expand Down Expand Up @@ -558,6 +560,9 @@ export function createRunner(...paths: string[]) {
getReceivedEnvelopes: function (): Envelope[] {
return receivedEnvelopes;
},
getPort: function (): Promise<number> {
return workerPortPromise;
},
makeRequest: async function <T>(
method: 'get' | 'post',
path: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core';
import {
CLIENT_ADDRESS,
SENTRY_SEGMENT_NAME_SOURCE,
SENTRY_SDK_NAME,
SENTRY_SDK_VERSION,
Expand Down Expand Up @@ -205,6 +206,15 @@ it('sends a streamed span envelope with correct spans for a manually started spa
type: 'string',
value: 'localhost',
},
// From the `CF-Connecting-IP` header that `wrangler dev` sets.
[CLIENT_ADDRESS]: {
type: 'string',
value: '127.0.0.1',
},
'http.response.header.content-type': {
type: 'array',
value: ['text/plain;charset=UTF-8'],
},
'url.full': {
type: 'string',
value: expect.stringMatching(/^http:\/\/localhost:.+$/),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ import { defineCloudflareOptions, serveTunnelIntegration } from '@sentry/cloudfl
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
Expand Up @@ -10,15 +10,16 @@ function expectServerSpanAt(path: string) {
};
}

it('drops the server span of managed tunnel requests', async ({ signal }) => {
it('answers managed tunnel requests before the app, without a server span', 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');
// The tunnel rejects the body as an envelope. The app would have answered with a 200.
await runner.makeRequest('post', tunnelPath!, { data: 'envelope', expectError: true });
await runner.makeRequest('get', '/after-tunnel');

await runner.completed();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { Event } from '@sentry/core';
import { HTTP_SERVER } from '@sentry/conventions/op';
import { expect, it } from 'vitest';
import { createRunner } from '../../../../runner';
import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils';

it('does not capture spans emitted through @opentelemetry/api inside wrapRequestHandler from the /request subpath', async ({
signal,
}) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.transaction).toBe('GET /');
expect(event.contexts?.trace?.op).toBe('http.server');
expect(event.spans).toEqual([]);
// Only the request's segment span. The OpenTelemetry span is not captured.
const spans = getSpansFromEnvelope(envelope);
expect(spans).toHaveLength(1);
expect(spans[0]?.is_segment).toBe(true);
expect(spans[0]?.name).toBe('GET /');
expect(getSpanOp(spans[0]!)).toBe(HTTP_SERVER);
})
.start(signal);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
ALLOWED_DSNS: string;
TUNNEL_PATH: string;
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
integrations: [Sentry.serveTunnelIntegration({ allowedDsns: env.ALLOWED_DSNS.split(',') })],
}),
{
async fetch(request, env) {
const { pathname } = new URL(request.url);
if (pathname === '/health') {
return new Response('ok');
}
if (pathname === '/done') {
Sentry.captureMessage('done');
return new Response('done');
}
// The app's own handler on the tunnel path, which the runtime answers before it.
if (pathname === env.TUNNEL_PATH) {
return new Response('app tunnel handler', { status: 418 });
}

return new Response('not found', { status: 404 });
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { getTunnelPath } from '@sentry/core';
import type { FakeIngests } from '@sentry-internal/test-utils';
import { EXPECTED_TUNNEL_OUTCOMES, runTunnelHardeningCases, startFakeIngests } from '@sentry-internal/test-utils';
import { afterAll, beforeAll, expect, it } from 'vitest';
import { createRunner } from '../../runner';

// `wrangler dev` stalls the next request after any worker answers before reading a large body, Sentry or not.
const WRANGLER_DEV_STALLS = ['70KB header line', 'binary garbage', 'rejected tunnel with a big unread body, then app'];

let ingests: FakeIngests;

beforeAll(async () => {
ingests = await startFakeIngests();
});

afterAll(async () => {
await ingests.close();
});

it('keeps the worker serving whatever is thrown at the tunnel, and reports nothing of its own', async ({ signal }) => {
const runner = createRunner(__dirname)
.withWranglerArgs(
'--var',
`ALLOWED_DSNS:${ingests.allDsns().join(',')}`,
'--var',
`TUNNEL_PATH:${getTunnelPath(ingests.dsn('ok'))}`,
)
// The first event is the app's own, so the tunnel captured no error along the way.
.expect(envelope => {
expect(envelope[1][0]?.[1]).toMatchObject({ message: 'done' });
})
.start(signal);
await runner.makeRequest('get', '/health');

const results = await runTunnelHardeningCases({
port: await runner.getPort(),
ingests,
getTunnelPath,
skip: WRANGLER_DEV_STALLS,
});

expect(results.filter(result => result.health !== '200')).toEqual([]);
expect(Object.fromEntries(results.map(result => [result.name, result.outcome]))).toMatchObject(
Object.fromEntries(
Object.entries(EXPECTED_TUNNEL_OUTCOMES).filter(([name]) => !WRANGLER_DEV_STALLS.includes(name)),
),
);

await runner.makeRequest('get', '/done');
await runner.completed();
}, 180_000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.

const DSN = 'https://username@domain/123';

Deno.test('Deno.serve drops the server span of managed tunnel requests', async () => {
Deno.test('Deno.serve answers managed tunnel requests before the app, without a server span', 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,
});
Expand All @@ -33,7 +32,9 @@ Deno.test('Deno.serve drops the server span of managed tunnel requests', async (
'segment span for /after-tunnel',
);

await (await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' })).text();
const tunnelResponse = await fetch(`${baseUrl}${getTunnelPath(DSN)}`, { method: 'POST', body: 'envelope' });
// Answered by the tunnel, which rejects the body as an envelope, not by the app.
assertEquals(await tunnelResponse.text(), 'Invalid envelope');
await (await fetch(`${baseUrl}/after-tunnel`)).text();

await flush();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn';
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
// The other variants use the tunnelRoute of withSentryConfig
tunnel: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? true : undefined,
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { withSentryConfig } from '@sentry/nextjs/config';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {};
// The managed variants serve the managed tunnel under a base path, without a tunnel route.
const isManagedMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed';

const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs' } : {};

export default withSentryConfig(nextConfig, {
silent: true,
tunnelRoute: true,
tunnelRoute: isManagedMode ? undefined : true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test:build-webpack": "pnpm install && pnpm build-webpack",
"test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build",
"test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack",
"test:build-middleware-webpack": "pnpm install && rm proxy.ts && cp tunnel-middleware/middleware.ts middleware.ts && pnpm build-webpack",
"test:build-canary": "pnpm install && pnpm add next@canary && pnpm build",
"test:build-latest": "pnpm install && pnpm add next@latest && pnpm build",
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
Expand Down Expand Up @@ -72,6 +73,16 @@
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack",
"label": "nextjs-16-tunnel (handler, webpack, edge)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack"
},
{
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build",
"label": "nextjs-16-tunnel (managed, turbopack)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert"
},
{
"build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build-middleware-webpack",
"label": "nextjs-16-tunnel (managed, webpack, edge middleware)",
"assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert-webpack"
}
],
"optionalVariants": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// The handler variants send the server events to the event proxy too, so the tests also see server spans.
// The handler and managed variants send the server events to the event proxy too, so the tests also see server
// spans.
export const SENTRY_DSN =
process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler'
process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' ||
process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed'
? 'http://public@localhost:3031/1337'
: // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs
'https://public@o12345.ingest.us.sentry.io/67890';
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn';
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
// Serves the managed tunnel of the managed variants
integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [],
tracesSampleRate: 1.0,
// debug: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn';
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: SENTRY_DSN,
// No tunnel option - using tunnelRoute from withSentryConfig
// Serves the managed tunnel of the managed variants
integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [],
tracesSampleRate: 1.0,
// debug: true,
});
Loading
Loading