Skip to content

Commit 2a02a85

Browse files
committed
feat(remix): Serve the managed tunnel from the Remix 3 router middleware
Sentry's middleware is already prepended to every `createRouter`, so it now answers tunnel requests before route matching and the app's own middleware.
1 parent 9c5e4d6 commit 2a02a85

8 files changed

Lines changed: 93 additions & 4 deletions

File tree

‎dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ declare const process: { env: Record<string, string | undefined> };
99

1010
Sentry.init({
1111
dsn: process.env.E2E_TEST_DSN,
12-
tunnel: 'http://localhost:3061/',
12+
tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/',
1313
tracesSampleRate: 1.0,
1414
});
1515

‎dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const assets = createAssetServer({
1515
// into a browser module. The asset server substitutes these when it compiles.
1616
define: {
1717
'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN),
18+
'process.env.E2E_TEST_TUNNEL': JSON.stringify(process.env.E2E_TEST_TUNNEL ?? ''),
1819
},
1920
},
2021
});

‎dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,21 @@ declare module 'remix/router' {
1414
}
1515
}
1616

17+
// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one.
18+
function authMiddleware(context: { request: Request }, next: () => Promise<Response>): Promise<Response> | Response {
19+
if (
20+
process.env.E2E_TEST_TUNNEL &&
21+
context.request.method === 'POST' &&
22+
context.request.headers.get('x-auth') !== 'ok'
23+
) {
24+
return new Response('blocked by user auth', { status: 401 });
25+
}
26+
27+
return next();
28+
}
29+
1730
export const router = createRouter<AppContext>({
18-
middleware: [renderMiddleware],
31+
middleware: [authMiddleware, renderMiddleware],
1932
});
2033

2134
router.map(routes, controller);

‎dev-packages/e2e-tests/test-applications/remix-v3/package.json‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"test": "playwright test",
99
"clean": "npx rimraf node_modules pnpm-lock.yaml",
1010
"test:build": "pnpm install",
11-
"test:assert": "pnpm test"
11+
"test:assert": "pnpm test",
12+
"test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3061/1337 playwright test tunnel"
1213
},
1314
"dependencies": {
1415
"@sentry/remix": "file:../../packed/sentry-remix-packed.tgz",
@@ -23,5 +24,14 @@
2324
},
2425
"volta": {
2526
"extends": "../../package.json"
27+
},
28+
"sentryTest": {
29+
"variants": [
30+
{
31+
"build-command": "pnpm test:build",
32+
"assert-command": "pnpm test:assert:tunnel",
33+
"label": "remix-v3 (tunnel)"
34+
}
35+
]
2636
}
2737
}

‎dev-packages/e2e-tests/test-applications/remix-v3/server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { router } from './app/router.ts';
66

77
Sentry.init({
88
dsn: process.env.E2E_TEST_DSN,
9-
tunnel: 'http://localhost:3061/',
9+
tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/',
1010
tracesSampleRate: 1.0,
1111
});
1212

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from '@playwright/test';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
3+
4+
test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');
5+
6+
const APP_NAME = 'remix-v3';
7+
const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/;
8+
9+
test('answers the managed tunnel before route matching and the app middleware', async ({ page }) => {
10+
// The variant's auth middleware rejects every other POST.
11+
expect((await page.request.post('/anything', { data: 'x' })).status()).toBe(401);
12+
13+
const tunnelServerSpanPromise = waitForStreamedSpan(
14+
APP_NAME,
15+
span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')),
16+
);
17+
const tunnelResponsePromise = page.waitForResponse(
18+
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
19+
);
20+
21+
await page.goto('/');
22+
const tunnelResponse = await tunnelResponsePromise;
23+
24+
expect(tunnelResponse.status()).toBe(200);
25+
expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);
26+
27+
// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
28+
// would arrive first.
29+
const anchorSpanPromise = waitForStreamedSpan(
30+
APP_NAME,
31+
span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/items/1',
32+
);
33+
await page.request.get('/api/items/1');
34+
35+
const winner = await Promise.race([
36+
tunnelServerSpanPromise.then(() => 'tunnel' as const),
37+
anchorSpanPromise.then(() => 'anchor' as const),
38+
]);
39+
expect(winner).toBe('anchor');
40+
});

‎packages/remix/src/v3/server/middleware.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getRootSpan,
66
getSpanStatusFromHttpCode,
77
getTraceData,
8+
handleTunnelRequestIfMatched,
89
INTERNAL_setSegmentNameSourceIfSegment,
910
type Scope,
1011
updateSpanName,
@@ -25,6 +26,11 @@ import { resolveRoutePattern } from './route';
2526
*/
2627
export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike {
2728
return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise<Response> {
29+
const tunnelResponse = handleTunnelRequestIfMatched(context.request);
30+
if (tunnelResponse) {
31+
return tunnelResponse;
32+
}
33+
2834
const isolationScope = getIsolationScope();
2935
isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) });
3036

‎packages/remix/test/v3/middleware.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
44
// Trace data as the SDK would produce it for the active request; empty when nothing is active.
55
const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({})));
66

7+
const handleTunnelRequestIfMatched = vi.hoisted(() =>
8+
vi.fn<(request: Request) => Promise<Response> | undefined>(() => undefined),
9+
);
10+
711
vi.mock('@sentry/core', async importOriginal => ({
812
...(await importOriginal<typeof SentryCore>()),
913
getTraceData,
14+
handleTunnelRequestIfMatched,
1015
}));
1116

1217
import { sentryRemixMiddleware } from '../../src/v3/server/middleware';
@@ -27,6 +32,20 @@ function contextFor(url: string, headers: Record<string, string> = {}): RequestC
2732
describe('sentryRemixMiddleware', () => {
2833
beforeEach(() => {
2934
getTraceData.mockReturnValue({});
35+
handleTunnelRequestIfMatched.mockReturnValue(undefined);
36+
});
37+
38+
it('answers tunnel requests before route matching and the rest of the chain', async () => {
39+
const tunnelResponse = new Response('forwarded');
40+
handleTunnelRequestIfMatched.mockReturnValue(Promise.resolve(tunnelResponse));
41+
const next = vi.fn();
42+
const context = contextFor('http://x/abc123');
43+
44+
const response = await sentryRemixMiddleware(matcherFor('/'))(context, next);
45+
46+
expect(response).toBe(tunnelResponse);
47+
expect(handleTunnelRequestIfMatched).toHaveBeenCalledWith(context.request);
48+
expect(next).not.toHaveBeenCalled();
3049
});
3150

3251
it('propagates the trace to HTML responses through Server-Timing, ahead of the route', async () => {

0 commit comments

Comments
 (0)