Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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;
}

const handler: ExportedHandler<Env> = 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<Env>;
43 changes: 43 additions & 0 deletions dev-packages/cloudflare-integration-tests/suites/tunnel/test.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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();
});
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
@@ -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})',
};
Original file line number Diff line number Diff line change
@@ -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'] });
}
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
],
});
Original file line number Diff line number Diff line change
@@ -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;
});
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading