Skip to content

Commit 0ee35f0

Browse files
authored
fix(remix): Resolve Remix Request API compatibility issues. (#6215)
Ref: #6139 and #6139 (comment) This PR is not a complete solution for all issues mentioned in #6139, but it aims to solve the parsing issue, which should fix auto-instrumented usage. Remix uses its own implementation of Fetch API [1] on both back-end and front-end, which has different structures storing `headers` and `url`. That has caused `RequestData` integration to not work properly on Remix projects. I first attempted adding support to the core parser, and `PolymorphicRequest`. But it seemed very complex (if possible), because we have to type Proxy objects and Symbols, which our current TypeScript version doesn't fully support. So, I vendored / modified a couple of unexported utilities from `@remix-run/web-fetch` [2], to convert request objects to a compatible structure that we can consume in `RequestData` integration. [1] https://github.com/remix-run/web-std-io/tree/main/packages/fetch [2] https://www.npmjs.com/package/@remix-run/web-fetch
1 parent fae0682 commit 0ee35f0

File tree

5 files changed

+169
-7
lines changed

5 files changed

+169
-7
lines changed

packages/remix/src/performance/client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ErrorBoundaryProps, WINDOW , withErrorBoundary } from '@sentry/react';
1+
import { ErrorBoundaryProps, WINDOW, withErrorBoundary } from '@sentry/react';
22
import { Transaction, TransactionContext } from '@sentry/types';
33
import { logger } from '@sentry/utils';
44
import * as React from 'react';

packages/remix/src/utils/instrumentServer.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import {
2121
DataFunctionArgs,
2222
HandleDocumentRequestFunction,
2323
ReactRouterDomPkg,
24+
RemixRequest,
2425
RequestHandler,
2526
RouteMatch,
2627
ServerBuild,
2728
ServerRoute,
2829
ServerRouteManifest,
2930
} from './types';
31+
import { normalizeRemixRequest } from './web-fetch';
3032

3133
// Flag to track if the core request handler is instrumented.
3234
export let isRequestHandlerWrapped = false;
@@ -91,8 +93,10 @@ async function captureRemixServerException(err: Error, name: string, request: Re
9193
return;
9294
}
9395

96+
const normalizedRequest = normalizeRemixRequest(request as unknown as any);
97+
9498
captureException(isResponse(err) ? await extractResponseError(err) : err, scope => {
95-
scope.setSDKProcessingMetadata({ request });
99+
scope.setSDKProcessingMetadata({ request: normalizedRequest });
96100
scope.addEventProcessor(event => {
97101
addExceptionMechanism(event, {
98102
type: 'instrument',
@@ -252,6 +256,7 @@ function makeWrappedRootLoader(origLoader: DataFunction): DataFunction {
252256
// Note: `redirect` and `catch` responses do not have bodies to extract
253257
if (isResponse(res) && !isRedirectResponse(res) && !isCatchResponse(res)) {
254258
const data = await extractData(res);
259+
255260
if (typeof data === 'object') {
256261
return json(
257262
{ ...data, ...traceAndBaggage },
@@ -363,15 +368,20 @@ export function getTransactionName(
363368
function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBuild): RequestHandler {
364369
const routes = createRoutes(build.routes);
365370
const pkg = loadModule<ReactRouterDomPkg>('react-router-dom');
366-
return async function (this: unknown, request: Request, loadContext?: unknown): Promise<Response> {
371+
372+
return async function (this: unknown, request: RemixRequest, loadContext?: unknown): Promise<Response> {
367373
const local = domain.create();
368374
return local.bind(async () => {
369375
const hub = getCurrentHub();
370376
const options = hub.getClient()?.getOptions();
371377
const scope = hub.getScope();
372378

379+
const normalizedRequest = normalizeRemixRequest(request);
380+
373381
if (scope) {
374-
scope.setSDKProcessingMetadata({ request });
382+
scope.setSDKProcessingMetadata({
383+
request: normalizedRequest,
384+
});
375385
}
376386

377387
if (!options || !hasTracingEnabled(options)) {

packages/remix/src/utils/types.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,26 @@
33
// Types vendored from @remix-run/[email protected]:
44
// https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts
55
import type * as Express from 'express';
6+
import { Agent } from 'https';
67
import type { ComponentType } from 'react';
78

9+
export type RemixRequestState = {
10+
method: string;
11+
redirect: RequestRedirect;
12+
headers: Headers;
13+
parsedURL: URL;
14+
signal: AbortSignal | null;
15+
size: number | null;
16+
};
17+
18+
export type RemixRequest = Request &
19+
Record<symbol | string, RemixRequestState> & {
20+
agent: Agent | ((parsedURL: URL) => Agent) | undefined;
21+
};
22+
823
export type AppLoadContext = any;
924
export type AppData = any;
10-
export type RequestHandler = (request: Request, loadContext?: AppLoadContext) => Promise<Response>;
25+
export type RequestHandler = (request: RemixRequest, loadContext?: AppLoadContext) => Promise<Response>;
1126
export type CreateRequestHandlerFunction = (this: unknown, build: ServerBuild, ...args: any[]) => RequestHandler;
1227
export type ServerRouteManifest = RouteManifest<Omit<ServerRoute, 'children'>>;
1328
export type Params<Key extends string = string> = {
@@ -104,7 +119,7 @@ export interface ServerBuild {
104119
}
105120

106121
export interface HandleDocumentRequestFunction {
107-
(request: Request, responseStatusCode: number, responseHeaders: Headers, context: EntryContext):
122+
(request: RemixRequest, responseStatusCode: number, responseHeaders: Headers, context: EntryContext):
108123
| Promise<Response>
109124
| Response;
110125
}
@@ -119,7 +134,7 @@ interface ServerEntryModule {
119134
}
120135

121136
export interface DataFunctionArgs {
122-
request: Request;
137+
request: RemixRequest;
123138
context: AppLoadContext;
124139
params: Params;
125140
}

packages/remix/src/utils/web-fetch.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Based on Remix's implementation of Fetch API
2+
// https://github.com/remix-run/web-std-io/tree/main/packages/fetch
3+
4+
import { RemixRequest } from './types';
5+
6+
/*
7+
* Symbol extractor utility to be able to access internal fields of Remix requests.
8+
*/
9+
const getInternalSymbols = (
10+
request: Record<string, unknown>,
11+
): {
12+
bodyInternalsSymbol: string;
13+
requestInternalsSymbol: string;
14+
} => {
15+
const symbols = Object.getOwnPropertySymbols(request);
16+
return {
17+
bodyInternalsSymbol: symbols.find(symbol => symbol.toString().includes('Body internals')) as any,
18+
requestInternalsSymbol: symbols.find(symbol => symbol.toString().includes('Request internals')) as any,
19+
};
20+
};
21+
22+
/**
23+
* Vendored from:
24+
* https://github.com/remix-run/web-std-io/blob/f715b354c8c5b8edc550c5442dec5712705e25e7/packages/fetch/src/utils/get-search.js#L5
25+
*/
26+
export const getSearch = (parsedURL: URL): string => {
27+
if (parsedURL.search) {
28+
return parsedURL.search;
29+
}
30+
31+
const lastOffset = parsedURL.href.length - 1;
32+
const hash = parsedURL.hash || (parsedURL.href[lastOffset] === '#' ? '#' : '');
33+
return parsedURL.href[lastOffset - hash.length] === '?' ? '?' : '';
34+
};
35+
36+
/**
37+
* Convert a Request to Node.js http request options.
38+
* The options object to be passed to http.request
39+
* Vendored / modified from:
40+
* https://github.com/remix-run/web-std-io/blob/f715b354c8c5b8edc550c5442dec5712705e25e7/packages/fetch/src/request.js#L259
41+
*/
42+
export const normalizeRemixRequest = (request: RemixRequest): Record<string, any> => {
43+
const { requestInternalsSymbol, bodyInternalsSymbol } = getInternalSymbols(request);
44+
45+
if (!requestInternalsSymbol) {
46+
throw new Error('Could not find request internals symbol');
47+
}
48+
49+
const { parsedURL } = request[requestInternalsSymbol];
50+
const headers = new Headers(request[requestInternalsSymbol].headers);
51+
52+
// Fetch step 1.3
53+
if (!headers.has('Accept')) {
54+
headers.set('Accept', '*/*');
55+
}
56+
57+
// HTTP-network-or-cache fetch steps 2.4-2.7
58+
let contentLengthValue = null;
59+
if (request.body === null && /^(post|put)$/i.test(request.method)) {
60+
contentLengthValue = '0';
61+
}
62+
63+
if (request.body !== null) {
64+
const totalBytes = request[bodyInternalsSymbol].size;
65+
// Set Content-Length if totalBytes is a number (that is not NaN)
66+
if (typeof totalBytes === 'number' && !Number.isNaN(totalBytes)) {
67+
contentLengthValue = String(totalBytes);
68+
}
69+
}
70+
71+
if (contentLengthValue) {
72+
headers.set('Content-Length', contentLengthValue);
73+
}
74+
75+
// HTTP-network-or-cache fetch step 2.11
76+
if (!headers.has('User-Agent')) {
77+
headers.set('User-Agent', 'node-fetch');
78+
}
79+
80+
// HTTP-network-or-cache fetch step 2.15
81+
if (request.compress && !headers.has('Accept-Encoding')) {
82+
headers.set('Accept-Encoding', 'gzip,deflate,br');
83+
}
84+
85+
let { agent } = request;
86+
87+
if (typeof agent === 'function') {
88+
agent = agent(parsedURL);
89+
}
90+
91+
if (!headers.has('Connection') && !agent) {
92+
headers.set('Connection', 'close');
93+
}
94+
95+
// HTTP-network fetch step 4.2
96+
// chunked encoding is handled by Node.js
97+
const search = getSearch(parsedURL);
98+
99+
// Manually spread the URL object instead of spread syntax
100+
const requestOptions = {
101+
path: parsedURL.pathname + search,
102+
pathname: parsedURL.pathname,
103+
hostname: parsedURL.hostname,
104+
protocol: parsedURL.protocol,
105+
port: parsedURL.port,
106+
hash: parsedURL.hash,
107+
search: parsedURL.search,
108+
// @ts-ignore - it does not has a query
109+
query: parsedURL.query,
110+
href: parsedURL.href,
111+
method: request.method,
112+
// @ts-ignore - not sure what this supposed to do
113+
headers: headers[Symbol.for('nodejs.util.inspect.custom')](),
114+
insecureHTTPParser: request.insecureHTTPParser,
115+
agent,
116+
117+
// [SENTRY] For compatibility with Sentry SDK RequestData parser, adding `originalUrl` property.
118+
originalUrl: parsedURL.href,
119+
};
120+
121+
return requestOptions;
122+
};

packages/remix/test/integration/test/server/action.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
3333
request: {
3434
method: 'POST',
3535
url,
36+
cookies: expect.any(Object),
37+
headers: {
38+
'user-agent': expect.any(String),
39+
host: 'localhost:8000',
40+
},
3641
},
3742
});
3843
});
@@ -101,6 +106,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
101106
request: {
102107
method: 'POST',
103108
url,
109+
cookies: expect.any(Object),
110+
headers: {
111+
'user-agent': expect.any(String),
112+
host: 'localhost:8000',
113+
},
104114
},
105115
});
106116

@@ -116,6 +126,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
116126
request: {
117127
method: 'POST',
118128
url,
129+
cookies: expect.any(Object),
130+
headers: {
131+
'user-agent': expect.any(String),
132+
host: 'localhost:8000',
133+
},
119134
},
120135
});
121136
});

0 commit comments

Comments
 (0)