diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel/index.ts b/dev-packages/cloudflare-integration-tests/suites/tunnel/index.ts new file mode 100644 index 000000000000..64380031b485 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel/index.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +const handler: ExportedHandler = Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN }), { + async fetch(request, env) { + const { pathname } = new URL(request.url); + if (pathname === '/dsn') { + return new Response(env.SENTRY_DSN); + } + if (pathname === '/done') { + Sentry.captureMessage('done'); + return new Response('done'); + } + + return (await Sentry.handleTunnelRequestIfMatched(request)) ?? new Response('app'); + }, +}); + +export default { + async fetch(request, env, ctx) { + // There's no client before `withSentry`, so the tunnel has to be named. + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request, { + allowedDsns: [env.SENTRY_DSN], + basePath: '/outside', + }); + + return tunnelResponse ?? handler.fetch!(request, env, ctx); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts b/dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts new file mode 100644 index 000000000000..245abc4600f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts @@ -0,0 +1,43 @@ +import type { Envelope, Event } from '@sentry/core'; +import { createEnvelope, getTunnelPath, serializeEnvelope, uuid4 } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +/** + * An event envelope as the browser SDK sends it through the tunnel, for the worker's DSN. + */ +function browserEnvelope(dsn: string, message: string): string { + const eventId = uuid4(); + + return serializeEnvelope( + createEnvelope({ dsn, event_id: eventId }, [[{ type: 'event' }, { event_id: eventId, message }]]), + ) as string; +} + +/** + * Expects an envelope whose event has this message. + */ +function eventWithMessage(message: string): (envelope: Envelope) => void { + return envelope => { + expect((envelope[1][0]?.[1] as Event).message).toBe(message); + }; +} + +it('forwards with handleTunnelRequestIfMatched inside withSentry, and outside it with named options', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(eventWithMessage('inside withSentry')) + .expect(eventWithMessage('outside withSentry')) + .expect(eventWithMessage('done')) + .start(signal); + const dsn = (await runner.makeRequest('get', '/dsn'))!; + const tunnelPath = getTunnelPath(dsn)!; + + await runner.makeRequest('post', tunnelPath, { data: browserEnvelope(dsn, 'inside withSentry') }); + await runner.makeRequest('post', `/outside${tunnelPath}`, { data: browserEnvelope(dsn, 'outside withSentry') }); + expect(await runner.makeRequest('post', '/api', { data: browserEnvelope(dsn, 'not a tunnel') })).toBe('app'); + + await runner.makeRequest('get', '/done'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tunnel/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tunnel/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tunnel/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/middleware.ts new file mode 100644 index 000000000000..2273819a23fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/middleware.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse, type NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + return (await Sentry.handleTunnelRequestIfMatched(request)) ?? NextResponse.next(); +} + +export const config = { + // The managed tunnel path is one segment of up to 7 characters, derived from the DSN. + matcher: '/:path([a-z0-9]{1,7})', +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/tunnel.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/tunnel.ts new file mode 100644 index 000000000000..6077b1029767 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/tunnel.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +// The tunnel streams the raw body, so Next must not parse it first. +export const config = { api: { bodyParser: false } }; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await Sentry.handleTunnelRequest(req, res, { allowedDsns: ['http://public@localhost:3031/1337'] }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts index 0ef32107efff..9a967644e8b1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts @@ -9,4 +9,8 @@ Sentry.init({ // We are doing a lot of events at once in this test bufferSize: 1000, }, + // Lets the middleware tunnel envelopes for the event proxy's DSN. + integrations: [ + Sentry.serveTunnelIntegration({ allowedDsns: ['http://public@localhost:3031/1337'], basePath: '/my-app' }), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/tunnel.test.ts new file mode 100644 index 000000000000..ef505967ddb7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/tunnel.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { randomUUID } from 'node:crypto'; + +// The event proxy's DSN, so the forwarded envelopes arrive at the proxy. +const BROWSER_DSN = 'http://public@localhost:3031/1337'; +// `getTunnelPath(BROWSER_DSN)` +const TUNNEL_PATH = '/qpo967'; + +/** + * An error envelope as the browser SDK sends it through the tunnel. + */ +function browserEnvelope(message: string): string { + const eventId = randomUUID().replace(/-/g, ''); + + return [ + JSON.stringify({ dsn: BROWSER_DSN, event_id: eventId }), + JSON.stringify({ type: 'event' }), + JSON.stringify({ event_id: eventId, exception: { values: [{ type: 'Error', value: message }] } }), + ].join('\n'); +} + +test('Forwards a tunnel request from a Pages Router API route without a body parser', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Tunneled through a Pages Router API route'; + }); + + const response = await request.post('/my-app/api/tunnel', { + data: browserEnvelope('Tunneled through a Pages Router API route'), + }); + + expect(response.status()).toBe(200); + await errorEventPromise; +}); + +test('Forwards a tunnel request from middleware under the basePath', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Tunneled through middleware'; + }); + + const response = await request.post(`/my-app${TUNNEL_PATH}`, { + data: browserEnvelope('Tunneled through middleware'), + }); + + expect(response.status()).toBe(200); + await errorEventPromise; +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/express.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/express.mjs new file mode 100644 index 000000000000..91f1690d265c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/express.mjs @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = express(); + +app.get('/dsn', (_request, response) => { + response.send(process.env.SENTRY_DSN); +}); + +// The test's last request, so the runner only stops the scenario after every check. +app.get('/done', (_request, response) => { + Sentry.captureMessage('done'); + response.send('ok'); +}); + +// The tunnel posts `application/x-sentry-envelope`, which `express.json()` leaves alone. +app.use(express.json()); + +// Middleware style +app.use((request, response, next) => Sentry.handleTunnelRequestIfMatched(request, response) ?? next()); + +// Route style +app.post('/route-tunnel', (request, response) => Sentry.handleTunnelRequest(request, response)); + +// A parser that reads every body leaves the tunnel nothing to forward. +app.post('/parsed-tunnel', express.raw({ type: '*/*' }), (request, response) => + Sentry.handleTunnelRequest(request, response), +); + +app.use((_request, response) => { + response.send('app'); +}); + +const server = app.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/fastify.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/fastify.mjs new file mode 100644 index 000000000000..560a891f5f9f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/fastify.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Fastify from 'fastify'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = Fastify(); + +app.addHook('onRequest', async (request, reply) => { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request.raw, reply.raw); + if (tunnelResponse) { + await tunnelResponse; + return reply; + } +}); + +app.get('/dsn', async () => process.env.SENTRY_DSN); + +// The test's last request, so the runner only stops the scenario after every check. +app.get('/done', async () => { + Sentry.captureMessage('done'); + return 'ok'; +}); + +app.listen({ port: 0 }).then(() => { + sendPortToRunner(app.server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http.mjs new file mode 100644 index 000000000000..ec147c813a9e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http from 'http'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const server = http.createServer((request, response) => { + if (request.url === '/dsn') { + response.end(process.env.SENTRY_DSN); + return; + } + + // The test's last request, so the runner only stops the scenario after every check. + if (request.url === '/done') { + Sentry.captureMessage('done'); + response.end('ok'); + return; + } + + if (!Sentry.handleTunnelRequestIfMatched(request, response)) { + response.end('app'); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http2.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http2.mjs new file mode 100644 index 000000000000..b036aa7c850d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/http2.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import http2 from 'http2'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const server = http2.createServer((request, response) => { + if (request.url === '/dsn') { + response.end(process.env.SENTRY_DSN); + return; + } + + // The test's last request, so the runner only stops the scenario after every check. + if (request.url === '/done') { + Sentry.captureMessage('done'); + response.end('ok'); + return; + } + + if (!Sentry.handleTunnelRequestIfMatched(request, response)) { + response.end('app'); + } +}); + +server.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/koa.mjs b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/koa.mjs new file mode 100644 index 000000000000..70f6c1a367b7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/koa.mjs @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Koa from 'koa'; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +const app = new Koa(); + +app.use(async (ctx, next) => { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(ctx.req, ctx.res); + if (tunnelResponse) { + await tunnelResponse; + return; + } + + await next(); +}); + +app.use(ctx => { + // `/done` is the test's last request, so the runner only stops the scenario after every check. + if (ctx.path === '/done') { + Sentry.captureMessage('done'); + } + ctx.body = ctx.path === '/dsn' ? process.env.SENTRY_DSN : 'app'; +}); + +const server = app.listen(0, () => { + sendPortToRunner(server.address().port); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/test.ts b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/test.ts new file mode 100644 index 000000000000..a9a4fb8387e8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/manual-wiring/test.ts @@ -0,0 +1,195 @@ +import { createEnvelope, getTunnelPath, serializeEnvelope, uuid4 } from '@sentry/core'; +import * as http from 'node:http'; +import * as http2 from 'node:http2'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +type Runner = ReturnType['start']>; + +/** + * An event envelope as the browser SDK sends it through the tunnel, for the scenario's DSN. + */ +async function browserEnvelope(runner: Runner, message: string): Promise<{ tunnelPath: string; envelope: string }> { + const dsn = (await runner.makeRequest('get', '/dsn'))!; + const eventId = uuid4(); + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: eventId, sent_at: new Date().toISOString() }, [ + [{ type: 'event' }, { event_id: eventId, message }], + ]), + ) as string; + + return { tunnelPath: getTunnelPath(dsn)!, envelope }; +} + +/** + * POSTs to the scenario server and returns the status and body, also for error responses. + */ +async function post(runner: Runner, path: string, body: string): Promise<{ status: number; text: string }> { + const response = await fetch(`http://localhost:${runner.getPort()}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/x-sentry-envelope' }, + body, + }); + + return { status: response.status, text: await response.text() }; +} + +/** + * POSTs a chunked body, without `Content-Length`. + */ +function postChunked(runner: Runner, path: string, body: string): Promise { + return new Promise((resolve, reject) => { + const request = http.request({ port: runner.getPort(), path, method: 'POST' }, response => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + request.write(body.slice(0, 5)); + request.end(body.slice(5)); + }); +} + +/** + * Waits for the scenario server's port, which `makeRequest` does for HTTP/1 requests. + */ +async function waitForPort(runner: Runner): Promise { + for (let i = 0; i < 200 && !runner.getPort(); i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + return runner.getPort()!; +} + +/** + * Makes an HTTP/2 request and returns the status and body. + */ +function http2Request( + client: http2.ClientHttp2Session, + method: string, + path: string, + body?: string, +): Promise<{ status: unknown; body: string }> { + return new Promise(resolve => { + const request = client.request({ + ':method': method, + ':path': path, + ...(body && { 'content-type': 'application/x-sentry-envelope', 'content-length': Buffer.byteLength(body) }), + }); + let status: unknown; + let responseBody = ''; + request.on('response', headers => (status = headers[':status'])); + request.on('data', chunk => (responseBody += chunk)); + request.on('end', () => resolve({ status, body: responseBody })); + request.end(body); + }); +} + +describe('wiring the managed tunnel by hand', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('forwards from node:http with handleTunnelRequestIfMatched and leaves the rest to the app', async () => { + const runner = createRunner(__dirname, 'http.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via node:http' } }) + .expect({ event: { message: 'done' } }) + .start(); + const { tunnelPath, envelope } = await browserEnvelope(runner, 'via node:http'); + + expect(await post(runner, tunnelPath, envelope)).toEqual({ status: 200, text: expect.any(String) }); + expect(await post(runner, '/api', envelope)).toEqual({ status: 200, text: 'app' }); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('rejects a chunked upload without Content-Length with a 411 and forwards nothing', async () => { + const runner = createRunner(__dirname, 'http.mjs') + .withMockSentryServer() + .expect({ event: { message: 'after the chunked upload' } }) + .expect({ event: { message: 'done' } }) + .start(); + const chunked = await browserEnvelope(runner, 'chunked upload'); + const after = await browserEnvelope(runner, 'after the chunked upload'); + + expect(await postChunked(runner, chunked.tunnelPath, chunked.envelope)).toBe(411); + expect((await post(runner, after.tunnelPath, after.envelope)).status).toBe(200); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('forwards from node:http2, whose requests carry pseudo-headers', async () => { + const runner = createRunner(__dirname, 'http2.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via node:http2' } }) + .expect({ event: { message: 'done' } }) + .start(); + const client = http2.connect(`http://localhost:${await waitForPort(runner)}`); + + const dsn = (await http2Request(client, 'GET', '/dsn')).body; + const eventId = uuid4(); + const envelope = serializeEnvelope( + createEnvelope({ dsn, event_id: eventId }, [ + [{ type: 'event' }, { event_id: eventId, message: 'via node:http2' }], + ]), + ) as string; + + expect((await http2Request(client, 'POST', getTunnelPath(dsn)!, envelope)).status).toBe(200); + expect(await http2Request(client, 'POST', '/api', envelope)).toEqual({ status: 200, body: 'app' }); + + await http2Request(client, 'GET', '/done'); + client.close(); + await runner.completed(); + }); + + test('forwards from Express as middleware and as a route, and gets nothing to forward behind a catch-all parser', async () => { + const runner = createRunner(__dirname, 'express.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via express middleware' } }) + .expect({ event: { message: 'via express route' } }) + .expect({ event: { message: 'done' } }) + .start(); + const middleware = await browserEnvelope(runner, 'via express middleware'); + const route = await browserEnvelope(runner, 'via express route'); + const parsed = await browserEnvelope(runner, 'behind express.raw'); + + expect((await post(runner, middleware.tunnelPath, middleware.envelope)).status).toBe(200); + expect((await post(runner, '/parsed-tunnel', parsed.envelope)).status).toBe(400); + expect((await post(runner, '/route-tunnel', route.envelope)).status).toBe(200); + expect(await post(runner, '/api', route.envelope)).toEqual({ status: 200, text: 'app' }); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('forwards from a Fastify onRequest hook', async () => { + const runner = createRunner(__dirname, 'fastify.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via fastify' } }) + .expect({ event: { message: 'done' } }) + .start(); + const { tunnelPath, envelope } = await browserEnvelope(runner, 'via fastify'); + + expect((await post(runner, tunnelPath, envelope)).status).toBe(200); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); + + test('forwards from a Koa middleware', async () => { + const runner = createRunner(__dirname, 'koa.mjs') + .withMockSentryServer() + .expect({ event: { message: 'via koa' } }) + .expect({ event: { message: 'done' } }) + .start(); + const { tunnelPath, envelope } = await browserEnvelope(runner, 'via koa'); + + expect((await post(runner, tunnelPath, envelope)).status).toBe(200); + expect(await post(runner, '/api', envelope)).toEqual({ status: 200, text: 'app' }); + + await runner.makeRequest('get', '/done'); + await runner.completed(); + }); +});