Skip to content
Draft
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
Expand Up @@ -9,7 +9,7 @@ declare const process: { env: Record<string, string | undefined> };

Sentry.init({
dsn: process.env.E2E_TEST_DSN,
tunnel: 'http://localhost:3061/',
tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/',
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const assets = createAssetServer({
// into a browser module. The asset server substitutes these when it compiles.
define: {
'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN),
'process.env.E2E_TEST_TUNNEL': JSON.stringify(process.env.E2E_TEST_TUNNEL ?? ''),
},
},
});
Expand Down
15 changes: 14 additions & 1 deletion dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,21 @@ declare module 'remix/router' {
}
}

// The tunnel variant checks that the managed tunnel answers before an auth middleware like this one.
function authMiddleware(context: { request: Request }, next: () => Promise<Response>): Promise<Response> | Response {
if (
process.env.E2E_TEST_TUNNEL &&
context.request.method === 'POST' &&
context.request.headers.get('x-auth') !== 'ok'
) {
return new Response('blocked by user auth', { status: 401 });
}

return next();
}

export const router = createRouter<AppContext>({
middleware: [renderMiddleware],
middleware: [authMiddleware, renderMiddleware],
});

router.map(routes, controller);
Expand Down
12 changes: 11 additions & 1 deletion dev-packages/e2e-tests/test-applications/remix-v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install",
"test:assert": "pnpm test"
"test:assert": "pnpm test",
"test:assert:tunnel": "E2E_TEST_TUNNEL=1 E2E_TEST_DSN=http://public@localhost:3061/1337 playwright test tunnel"
},
"dependencies": {
"@sentry/remix": "file:../../packed/sentry-remix-packed.tgz",
Expand All @@ -23,5 +24,14 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build",
"assert-command": "pnpm test:assert:tunnel",
"label": "remix-v3 (tunnel)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { router } from './app/router.ts';

Sentry.init({
dsn: process.env.E2E_TEST_DSN,
tunnel: 'http://localhost:3061/',
tunnel: process.env.E2E_TEST_TUNNEL ? true : 'http://localhost:3061/',
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';

test.skip(!process.env.E2E_TEST_TUNNEL, 'Only runs in the tunnel variant');

const APP_NAME = 'remix-v3';
const TUNNEL_PATH = /^\/[a-z0-9]{1,7}$/;

test('answers the managed tunnel before route matching and the app middleware', async ({ page }) => {
// The variant's auth middleware rejects every other POST.
expect((await page.request.post('/anything', { data: 'x' })).status()).toBe(401);

const tunnelServerSpanPromise = waitForStreamedSpan(
APP_NAME,
span => getSpanOp(span) === 'http.server' && TUNNEL_PATH.test(String(span.attributes['url.path']?.value ?? '')),
);
const tunnelResponsePromise = page.waitForResponse(
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
);

await page.goto('/');
const tunnelResponse = await tunnelResponsePromise;

expect(tunnelResponse.status()).toBe(200);
expect(new URL(tunnelResponse.url()).origin).toBe(new URL(page.url()).origin);

// Anchor on a regular server span requested after the tunnel traffic: spans flush in order, so a tunnel span
// would arrive first.
const anchorSpanPromise = waitForStreamedSpan(
APP_NAME,
span => getSpanOp(span) === 'http.server' && String(span.attributes['url.path']?.value ?? '') === '/api/items/1',
);
await page.request.get('/api/items/1');

const winner = await Promise.race([
tunnelServerSpanPromise.then(() => 'tunnel' as const),
anchorSpanPromise.then(() => 'anchor' as const),
]);
expect(winner).toBe('anchor');
});
6 changes: 6 additions & 0 deletions packages/remix/src/v3/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getRootSpan,
getSpanStatusFromHttpCode,
getTraceData,
handleTunnelRequestIfMatched,
INTERNAL_setSegmentNameSourceIfSegment,
type Scope,
updateSpanName,
Expand All @@ -25,6 +26,11 @@ import { resolveRoutePattern } from './route';
*/
export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike {
return async function sentryMiddleware(context: RequestContextLike, next: NextFunctionLike): Promise<Response> {
const tunnelResponse = handleTunnelRequestIfMatched(context.request);
if (tunnelResponse) {
return tunnelResponse;
}

const isolationScope = getIsolationScope();
isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(context.request) });

Expand Down
19 changes: 19 additions & 0 deletions packages/remix/test/v3/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// Trace data as the SDK would produce it for the active request; empty when nothing is active.
const getTraceData = vi.hoisted(() => vi.fn<() => { 'sentry-trace'?: string; baggage?: string }>(() => ({})));

const handleTunnelRequestIfMatched = vi.hoisted(() =>
vi.fn<(request: Request) => Promise<Response> | undefined>(() => undefined),
);

vi.mock('@sentry/core', async importOriginal => ({
...(await importOriginal<typeof SentryCore>()),
getTraceData,
handleTunnelRequestIfMatched,
}));

import { sentryRemixMiddleware } from '../../src/v3/server/middleware';
Expand All @@ -27,6 +32,20 @@ function contextFor(url: string, headers: Record<string, string> = {}): RequestC
describe('sentryRemixMiddleware', () => {
beforeEach(() => {
getTraceData.mockReturnValue({});
handleTunnelRequestIfMatched.mockReturnValue(undefined);
});

it('answers tunnel requests before route matching and the rest of the chain', async () => {
const tunnelResponse = new Response('forwarded');
handleTunnelRequestIfMatched.mockReturnValue(Promise.resolve(tunnelResponse));
const next = vi.fn();
const context = contextFor('http://x/abc123');

const response = await sentryRemixMiddleware(matcherFor('/'))(context, next);

expect(response).toBe(tunnelResponse);
expect(handleTunnelRequestIfMatched).toHaveBeenCalledWith(context.request);
expect(next).not.toHaveBeenCalled();
});

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