Skip to content

Commit 9d38065

Browse files
authored
fix(remix): Support merging json responses from root loader functions. (#5548)
Introduces support of `json` responses while injecting `sentryTrace` and `sentryBaggage` into the `root` loader. Added a set of tests to ensure that we don't omit user-defined loader responses.
1 parent 8db56b1 commit 9d38065

File tree

6 files changed

+214
-24
lines changed

6 files changed

+214
-24
lines changed

packages/remix/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@
6161
"lint:prettier": "prettier --check \"{src,test,scripts}/**/*.ts\"",
6262
"test": "run-s test:unit",
6363
"test:integration": "run-s test:integration:prepare test:integration:client test:integration:server",
64+
"test:integration:clean": "(cd test/integration && rimraf .cache build node_modules)",
6465
"test:integration:ci": "run-s test:integration:prepare test:integration:client:ci test:integration:server",
65-
"test:integration:prepare": "(cd test/integration && yarn)",
66+
"test:integration:prepare": "yarn test:integration:clean && (cd test/integration && yarn)",
6667
"test:integration:client": "yarn playwright install-deps && yarn playwright test test/integration/test/client/",
6768
"test:integration:client:ci": "yarn test:integration:client --browser='all' --reporter='line'",
6869
"test:integration:server": "jest --config=test/integration/jest.config.js test/integration/test/server/",

packages/remix/src/utils/instrumentServer.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export interface RouteMatch<Route> {
100100
}
101101

102102
// Taken from Remix Implementation
103-
// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L54-L62
103+
// https://github.com/remix-run/remix/blob/32300ec6e6e8025602cea63e17a2201989589eab/packages/remix-server-runtime/responses.ts#L60-L77
104104
// eslint-disable-next-line @typescript-eslint/no-explicit-any
105105
function isResponse(value: any): value is Response {
106106
return (
@@ -114,6 +114,15 @@ function isResponse(value: any): value is Response {
114114
);
115115
}
116116

117+
const redirectStatusCodes = new Set([301, 302, 303, 307, 308]);
118+
function isRedirectResponse(response: Response): boolean {
119+
return redirectStatusCodes.has(response.status);
120+
}
121+
122+
function isCatchResponse(response: Response): boolean {
123+
return response.headers.get('X-Remix-Catch') != null;
124+
}
125+
117126
// Based on Remix Implementation
118127
// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/data.ts#L131-L145
119128
function extractData(response: Response): Promise<unknown> {
@@ -207,6 +216,7 @@ function makeWrappedDataFunction(origFn: DataFunction, name: 'action' | 'loader'
207216
try {
208217
const span = activeTransaction.startChild({
209218
op: `remix.server.${name}`,
219+
// TODO: Consider using the `id` of the route this function belongs to
210220
description: activeTransaction.name,
211221
tags: {
212222
name,
@@ -235,8 +245,8 @@ function makeWrappedAction(origAction: DataFunction): DataFunction {
235245
return makeWrappedDataFunction(origAction, 'action');
236246
}
237247

238-
function makeWrappedLoader(origAction: DataFunction): DataFunction {
239-
return makeWrappedDataFunction(origAction, 'loader');
248+
function makeWrappedLoader(origLoader: DataFunction): DataFunction {
249+
return makeWrappedDataFunction(origLoader, 'loader');
240250
}
241251

242252
function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string } {
@@ -262,8 +272,21 @@ function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string }
262272
function makeWrappedRootLoader(origLoader: DataFunction): DataFunction {
263273
return async function (this: unknown, args: DataFunctionArgs): Promise<Response | AppData> {
264274
const res = await origLoader.call(this, args);
275+
const traceAndBaggage = getTraceAndBaggage();
276+
277+
// Note: `redirect` and `catch` responses do not have bodies to extract
278+
if (isResponse(res) && !isRedirectResponse(res) && !isCatchResponse(res)) {
279+
const data = await extractData(res);
280+
281+
if (typeof data === 'object') {
282+
return { ...data, ...traceAndBaggage };
283+
} else {
284+
__DEBUG_BUILD__ && logger.warn('Skipping injection of trace and baggage as the response body is not an object');
285+
return data;
286+
}
287+
}
265288

266-
return { ...res, ...getTraceAndBaggage() };
289+
return { ...res, ...traceAndBaggage };
267290
};
268291
}
269292

packages/remix/test/integration/app/root.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MetaFunction } from '@remix-run/node';
1+
import { MetaFunction, LoaderFunction, json, redirect } from '@remix-run/node';
22
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
33
import { withSentry } from '@sentry/remix';
44

@@ -10,7 +10,35 @@ export const meta: MetaFunction = ({ data }) => ({
1010
baggage: data.sentryBaggage,
1111
});
1212

13-
export function App() {
13+
export const loader: LoaderFunction = async ({ request }) => {
14+
const url = new URL(request.url);
15+
const type = url.searchParams.get('type');
16+
17+
switch (type) {
18+
case 'empty':
19+
return {};
20+
case 'plain':
21+
return {
22+
data_one: [],
23+
data_two: 'a string',
24+
};
25+
case 'json':
26+
return json({ data_one: [], data_two: 'a string' }, { headers: { 'Cache-Control': 'max-age=300' } });
27+
case 'null':
28+
return null;
29+
case 'undefined':
30+
return undefined;
31+
case 'throwRedirect':
32+
throw redirect('/?type=plain');
33+
case 'returnRedirect':
34+
return redirect('/?type=plain');
35+
default: {
36+
return {};
37+
}
38+
}
39+
};
40+
41+
function App() {
1442
return (
1543
<html lang="en">
1644
<head>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
async function getRouteData(page: Page): Promise<any> {
4+
return page.evaluate('window.__remixContext.routeData').catch(err => {
5+
console.warn(err);
6+
7+
return {};
8+
});
9+
}
10+
11+
async function extractTraceAndBaggageFromMeta(
12+
page: Page,
13+
): Promise<{ sentryTrace?: string | null; sentryBaggage?: string | null }> {
14+
const sentryTraceTag = await page.$('meta[name="sentry-trace"]');
15+
const sentryTraceContent = await sentryTraceTag?.getAttribute('content');
16+
17+
const sentryBaggageTag = await page.$('meta[name="baggage"]');
18+
const sentryBaggageContent = await sentryBaggageTag?.getAttribute('content');
19+
20+
return { sentryTrace: sentryTraceContent, sentryBaggage: sentryBaggageContent };
21+
}
22+
23+
test('should inject `sentry-trace` and `baggage` into root loader returning an empty object.', async ({ page }) => {
24+
await page.goto('/?type=empty');
25+
26+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
27+
28+
expect(sentryTrace).toEqual(expect.any(String));
29+
expect(sentryBaggage).toEqual(expect.any(String));
30+
31+
const rootData = (await getRouteData(page))['root'];
32+
33+
expect(rootData).toMatchObject({
34+
sentryTrace,
35+
sentryBaggage,
36+
});
37+
});
38+
39+
test('should inject `sentry-trace` and `baggage` into root loader returning a plain object.', async ({ page }) => {
40+
await page.goto('/?type=plain');
41+
42+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
43+
44+
expect(sentryTrace).toEqual(expect.any(String));
45+
expect(sentryBaggage).toEqual(expect.any(String));
46+
47+
const rootData = (await getRouteData(page))['root'];
48+
49+
expect(rootData).toMatchObject({
50+
data_one: [],
51+
data_two: 'a string',
52+
sentryTrace: sentryTrace,
53+
sentryBaggage: sentryBaggage,
54+
});
55+
});
56+
57+
test('should inject `sentry-trace` and `baggage` into root loader returning a `JSON response`.', async ({ page }) => {
58+
await page.goto('/?type=json');
59+
60+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
61+
62+
expect(sentryTrace).toEqual(expect.any(String));
63+
expect(sentryBaggage).toEqual(expect.any(String));
64+
65+
const rootData = (await getRouteData(page))['root'];
66+
67+
expect(rootData).toMatchObject({
68+
data_one: [],
69+
data_two: 'a string',
70+
sentryTrace: sentryTrace,
71+
sentryBaggage: sentryBaggage,
72+
});
73+
});
74+
75+
test('should inject `sentry-trace` and `baggage` into root loader returning `null`.', async ({ page }) => {
76+
await page.goto('/?type=null');
77+
78+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
79+
80+
expect(sentryTrace).toEqual(expect.any(String));
81+
expect(sentryBaggage).toEqual(expect.any(String));
82+
83+
const rootData = (await getRouteData(page))['root'];
84+
85+
expect(rootData).toMatchObject({
86+
sentryTrace: sentryTrace,
87+
sentryBaggage: sentryBaggage,
88+
});
89+
});
90+
91+
test('should inject `sentry-trace` and `baggage` into root loader returning `undefined`.', async ({ page }) => {
92+
await page.goto('/?type=undefined');
93+
94+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
95+
96+
expect(sentryTrace).toEqual(expect.any(String));
97+
expect(sentryBaggage).toEqual(expect.any(String));
98+
99+
const rootData = (await getRouteData(page))['root'];
100+
101+
expect(rootData).toMatchObject({
102+
sentryTrace: sentryTrace,
103+
sentryBaggage: sentryBaggage,
104+
});
105+
});
106+
107+
test('should inject `sentry-trace` and `baggage` into root loader throwing a redirection to a plain object.', async ({
108+
page,
109+
}) => {
110+
await page.goto('/?type=throwRedirect');
111+
112+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
113+
114+
expect(sentryTrace).toEqual(expect.any(String));
115+
expect(sentryBaggage).toEqual(expect.any(String));
116+
117+
const rootData = (await getRouteData(page))['root'];
118+
119+
expect(rootData).toMatchObject({
120+
sentryTrace: sentryTrace,
121+
sentryBaggage: sentryBaggage,
122+
});
123+
});
124+
125+
test('should inject `sentry-trace` and `baggage` into root loader returning a redirection to a plain object', async ({
126+
page,
127+
}) => {
128+
await page.goto('/?type=returnRedirect');
129+
130+
const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page);
131+
132+
expect(sentryTrace).toEqual(expect.any(String));
133+
expect(sentryBaggage).toEqual(expect.any(String));
134+
135+
const rootData = (await getRouteData(page))['root'];
136+
137+
expect(rootData).toMatchObject({
138+
sentryTrace: sentryTrace,
139+
sentryBaggage: sentryBaggage,
140+
});
141+
});

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ describe('Remix API Actions', () => {
2222
description: 'routes/action-json-response/$id',
2323
op: 'remix.server.action',
2424
},
25+
// TODO: These two spans look exactly the same, but they are not.
26+
// One is from the parent route, and the other is from the route we are reaching.
27+
// We need to pass the names of the routes as their descriptions while wrapping loaders and actions.
28+
{
29+
description: 'routes/action-json-response/$id',
30+
op: 'remix.server.loader',
31+
},
2532
{
2633
description: 'routes/action-json-response/$id',
2734
op: 'remix.server.loader',

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

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,6 @@ import {
99
jest.spyOn(console, 'error').mockImplementation();
1010

1111
describe('Remix API Loaders', () => {
12-
it('does not add a loader if there is not one defined.', async () => {
13-
const baseURL = await runServer();
14-
const url = `${baseURL}/`;
15-
const envelope = await getEnvelopeRequest(url);
16-
const transaction = envelope[2];
17-
18-
assertSentryTransaction(transaction, {
19-
transaction: 'root',
20-
spans: [
21-
{
22-
description: 'root',
23-
op: 'remix.server.documentRequest',
24-
},
25-
],
26-
});
27-
});
28-
2912
it('reports an error thrown from the loader', async () => {
3013
const baseURL = await runServer();
3114
const url = `${baseURL}/loader-json-response/-2`;
@@ -75,6 +58,13 @@ describe('Remix API Loaders', () => {
7558
source: 'route',
7659
},
7760
spans: [
61+
// TODO: These two spans look exactly the same, but they are not.
62+
// One is from the parent route, and the other is from the route we are reaching.
63+
// We need to pass the names of the routes as their descriptions while wrapping loaders and actions.
64+
{
65+
description: 'routes/loader-json-response/$id',
66+
op: 'remix.server.loader',
67+
},
7868
{
7969
description: 'routes/loader-json-response/$id',
8070
op: 'remix.server.loader',

0 commit comments

Comments
 (0)