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
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
18 changes: 17 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,9 @@
"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",
"test:assert:tunnel-plain-server": "E2E_TEST_PLAIN_SERVER=1 pnpm test:assert:tunnel"
},
"dependencies": {
"@sentry/remix": "file:../../packed/sentry-remix-packed.tgz",
Expand All @@ -23,5 +25,19 @@
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build",
"assert-command": "pnpm test:assert:tunnel",
"label": "remix-v3 (tunnel)"
},
{
"build-command": "pnpm test:build",
"assert-command": "pnpm test:assert:tunnel-plain-server",
"label": "remix-v3 (tunnel, plain server)"
}
]
}
}
58 changes: 48 additions & 10 deletions dev-packages/e2e-tests/test-applications/remix-v3/server.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,65 @@
import * as Sentry from '@sentry/remix/v3';
import * as http from 'node:http';
import { Readable } from 'node:stream';
import { createRequestListener } from 'remix/node-fetch-server';

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,
});

const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3060;

const server = http.createServer(
createRequestListener(request => {
// Handled outside the router on purpose: the only hook that can see this is the listener's
// `onError`, which is the case the router middleware cannot cover.
if (new URL(request.url).pathname === '/plain-throw') {
throw new Error('Plain handler failed');
// The plain server variant serves the router without `createRequestListener`, so only the router middleware can
// answer the tunnel there.
const plainServer = !!process.env.E2E_TEST_PLAIN_SERVER;

function handle(request: Request): Response | Promise<Response> {
// Handled outside the router on purpose: the only hook that can see this is the listener's
// `onError`, which is the case the router middleware cannot cover.
if (new URL(request.url).pathname === '/plain-throw') {
throw new Error('Plain handler failed');
}
// The tunnel variant checks that the managed tunnel answers before code the app runs in front of its router. With
// the plain server nothing runs ahead of this, so it would block the tunnel there.
if (
process.env.E2E_TEST_TUNNEL &&
!plainServer &&
request.method === 'POST' &&
request.headers.get('x-app-auth') !== 'ok'
) {
return new Response('blocked by the app before its router', { status: 403 });
}
return router.fetch(request);
}

/**
* A bare `node:http` adapter for the fetch handler.
*/
function toNodeListener(handler: (request: Request) => Response | Promise<Response>): http.RequestListener {
return async (req, res) => {
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const request = new Request(`http://localhost:${port}${req.url}`, {
method: req.method,
headers: req.headers as HeadersInit,
body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
duplex: 'half',
} as RequestInit);

try {
const response = await handler(request);
res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(Buffer.from(await response.arrayBuffer()));
} catch {
res.writeHead(500).end();
}
return router.fetch(request);
}),
);
};
}

const server = http.createServer(plainServer ? toNodeListener(handle) : createRequestListener(handle));

server.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test';
import { isServerSpanAt, watchForTunnelSpan } from '@sentry-internal/test-utils';

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

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

test('answers the managed tunnel before the app handler, route matching and the app middleware', async ({ page }) => {
// The variant's handler rejects every other POST before its router, except with the plain server, where nothing ahead
// of the router could answer the tunnel. Its auth middleware rejects the POSTs that get through.
if (!process.env.E2E_TEST_PLAIN_SERVER) {
expect((await page.request.post('/anything', { data: 'x' })).status()).toBe(403);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditional assertion in tunnel test

Low Severity

Flagged because the review guidelines ask to split a test that uses a conditional for different paths. The E2E_TEST_PLAIN_SERVER branch only asserts the pre-router 403 in one variant, so a failure on that path is easy to miss or misread as a skip.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 53b7826. Configure here.

expect((await page.request.post('/anything', { data: 'x', headers: { 'x-app-auth': 'ok' } })).status()).toBe(401);

const raceTunnelSpan = watchForTunnelSpan('remix-v3', isServerSpanAt(TUNNEL_PATH));
const tunnelResponsePromise = page.waitForResponse(
response => response.request().method() === 'POST' && TUNNEL_PATH.test(new URL(response.url()).pathname),
);

await page.goto('/');

expect((await tunnelResponsePromise).status()).toBe(200);
expect(await raceTunnelSpan(isServerSpanAt('/api/items/1'), () => page.request.get('/api/items/1'))).toBe('anchor');
});
23 changes: 20 additions & 3 deletions packages/remix/src/v3/server/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as diagnosticsChannel from 'node:diagnostics_channel';
import { createMultiMatcher } from 'remix/route-pattern/match';
import { consoleSandbox } from '@sentry/core';
import { handleTunnelRequestIfMatched } from '@sentry/core/server';
import type { OrchestrionChannelContext } from '@sentry/server-utils';
import { remixV3Channels } from '@sentry/server-utils/orchestrion/config';

Expand Down Expand Up @@ -58,9 +59,9 @@ function subscribeToCreateRouter(): void {
}

/**
* Report errors from any fetch handler, router or not. The middleware only sees failures inside a
* router; this is the only hook that covers a plain handler. No abort guard is needed, because the
* listener drops aborted requests before calling `onError`.
* Report errors from any fetch handler, router or not, and answer managed tunnel requests before it runs.
* The middleware only sees failures inside a router; this is the only hook that covers a plain handler.
* No abort guard is needed, because the listener drops aborted requests before calling `onError`.
*/
function subscribeToCreateRequestListener(): void {
diagnosticsChannel
Expand All @@ -71,6 +72,7 @@ function subscribeToCreateRequestListener(): void {
start(data) {
try {
injectOnError(ensureOptions(data.arguments, 1));
wrapFetchHandler(data.arguments);
} catch {
// Ignored on purpose.
}
Expand All @@ -82,6 +84,21 @@ function subscribeToCreateRequestListener(): void {
});
}

/**
* Answers managed tunnel requests before the app's fetch handler, so code the app runs in front of its router never
* sees them. The router middleware still answers them for apps served some other way.
*/
function wrapFetchHandler(args: unknown[]): void {
const handler = args[0];
if (typeof handler !== 'function') {
return;
}

args[0] = function sentryFetchHandler(this: unknown, request: Request, ...rest: unknown[]): unknown {
return handleTunnelRequestIfMatched(request) ?? handler.call(this, request, ...rest);
};
}

function injectOnError(raw: Record<string, unknown> | undefined): void {
if (!raw || markInjected(raw)) {
return;
Expand Down
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 @@ -10,6 +10,7 @@ import {
updateSpanName,
winterCGRequestToRequestData,
} from '@sentry/core';
import { handleTunnelRequestIfMatched } from '@sentry/core/server';

import { formatRouteTiming } from '../routeTiming';
import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types';
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;
}
Comment on lines +29 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A general idea: you added the tunnel in sentryRemixMiddleware but createRequestListener runs earlier, so we could put the tunnel logic there.

wdyt?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense, currently it didn't cover apps without a router.


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

Expand Down
39 changes: 39 additions & 0 deletions packages/remix/test/v3/instrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ vi.mock('@sentry/core', async importOriginal => ({
consoleSandbox: (callback: () => unknown) => callback(),
}));

const TUNNEL_URL = 'http://localhost/fdv680';
const tunnelResponse = new Response(null, { status: 200 });

// Answers only `TUNNEL_URL`, like a server with the managed tunnel at that path.
vi.mock('@sentry/core/server', () => ({
handleTunnelRequestIfMatched: (request: Request) =>
request.url === TUNNEL_URL ? Promise.resolve(tunnelResponse) : undefined,
}));

const { instrumentRemixV3 } = await import('../../src/v3/server/instrument');

const startChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_ROUTER}:start`);
Expand Down Expand Up @@ -135,3 +144,33 @@ describe('the createRequestListener error hook', () => {
expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) }));
});
});

describe('the createRequestListener tunnel', () => {
beforeAll(() => {
instrumentRemixV3();
});

it("answers a managed tunnel request without calling the app's handler", async () => {
const appHandler = vi.fn(() => new Response('app'));
const args: unknown[] = [appHandler];

publishCreateRequestListener(args);

expect(await (args[0] as (request: Request) => unknown)(new Request(TUNNEL_URL, { method: 'POST' }))).toBe(
tunnelResponse,
);
expect(appHandler).not.toHaveBeenCalled();
});

it("hands every other request to the app's handler", () => {
const appResponse = new Response('app');
const appHandler = vi.fn(() => appResponse);
const args: unknown[] = [appHandler];
const request = new Request('http://localhost/page');

publishCreateRequestListener(args);

expect((args[0] as (request: Request, client: unknown) => unknown)(request, 'client')).toBe(appResponse);
expect(appHandler).toHaveBeenCalledWith(request, 'client');
});
});
24 changes: 24 additions & 0 deletions packages/remix/test/v3/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import type * as SentryCore from '@sentry/core';
import type * as SentryCoreServer from '@sentry/core/server';
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,
}));

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

import { sentryRemixMiddleware } from '../../src/v3/server/middleware';
import type { MatcherLike, RequestContextLike } from '../../src/v3/types';

Expand All @@ -27,6 +37,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