Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(remix): Prevent capturing pending Promises as exceptions. #6129

Merged
merged 1 commit into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 19 additions & 5 deletions packages/remix/src/utils/instrumentServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function isCatchResponse(response: Response): boolean {

// Based on Remix Implementation
// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/data.ts#L131-L145
function extractData(response: Response): Promise<unknown> {
async function extractData(response: Response): Promise<unknown> {
const contentType = response.headers.get('Content-Type');

// Cloning the response to avoid consuming the original body stream
Expand All @@ -70,14 +70,28 @@ function extractData(response: Response): Promise<unknown> {
return responseClone.text();
}

function captureRemixServerException(err: Error, name: string, request: Request): void {
async function extractResponseError(response: Response): Promise<unknown> {
const responseData = await extractData(response);

if (typeof responseData === 'string') {
return responseData;
}

if (response.statusText) {
return response.statusText;
}

return responseData;
}

async function captureRemixServerException(err: Error, name: string, request: Request): Promise<void> {
// Skip capturing if the thrown error is not a 5xx response
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
if (isResponse(err) && err.status < 500) {
return;
}

captureException(isResponse(err) ? extractData(err) : err, scope => {
captureException(isResponse(err) ? await extractResponseError(err) : err, scope => {
scope.setSDKProcessingMetadata({ request });
scope.addEventProcessor(event => {
addExceptionMechanism(event, {
Expand Down Expand Up @@ -128,7 +142,7 @@ function makeWrappedDocumentRequestFunction(

span?.finish();
} catch (err) {
captureRemixServerException(err, 'documentRequest', request);
await captureRemixServerException(err, 'documentRequest', request);
throw err;
}

Expand Down Expand Up @@ -165,7 +179,7 @@ function makeWrappedDataFunction(origFn: DataFunction, id: string, name: 'action
currentScope.setSpan(activeTransaction);
span?.finish();
} catch (err) {
captureRemixServerException(err, name, args.request);
await captureRemixServerException(err, name, args.request);
throw err;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ export const action: ActionFunction = async ({ params: { id } }) => {
throw redirect('/action-json-response/-1');
}

if (id === '-3') {
throw json({}, { status: 500, statusText: 'Sentry Test Error' });
}

if (id === '-4') {
throw json({ data: 1234 }, { status: 500 });
}

if (id === '-5') {
throw json('Sentry Test Error [string body]', { status: 500 });
}

if (id === '-6') {
throw json({}, { status: 500 });
}

return json({ test: 'test' });
};

Expand Down
200 changes: 200 additions & 0 deletions packages/remix/test/integration/test/server/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,204 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
},
});
});

it('handles a thrown `json()` error response with `statusText`', async () => {
const env = await RemixTestEnv.init(adapter);
const url = `${env.url}/action-json-response/-3`;

const envelopes = await env.getMultipleEnvelopeRequest({
url,
count: 2,
method: 'post',
envelopeType: ['transaction', 'event'],
});

const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');

assertSentryTransaction(transaction[2], {
contexts: {
trace: {
op: 'http.server',
status: 'internal_error',
tags: {
method: 'POST',
'http.status_code': '500',
},
},
},
tags: {
transaction: 'routes/action-json-response/$id',
},
});

assertSentryEvent(event[2], {
exception: {
values: [
{
type: 'Error',
value: 'Sentry Test Error',
stacktrace: expect.any(Object),
mechanism: {
data: {
function: 'action',
},
handled: true,
type: 'instrument',
},
},
],
},
});
});

it('handles a thrown `json()` error response without `statusText`', async () => {
const env = await RemixTestEnv.init(adapter);
const url = `${env.url}/action-json-response/-4`;

const envelopes = await env.getMultipleEnvelopeRequest({
url,
count: 2,
method: 'post',
envelopeType: ['transaction', 'event'],
});

const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');

assertSentryTransaction(transaction[2], {
contexts: {
trace: {
op: 'http.server',
status: 'internal_error',
tags: {
method: 'POST',
'http.status_code': '500',
},
},
},
tags: {
transaction: 'routes/action-json-response/$id',
},
});

assertSentryEvent(event[2], {
exception: {
values: [
{
type: 'Error',
value: 'Non-Error exception captured with keys: data',
stacktrace: expect.any(Object),
mechanism: {
data: {
function: 'action',
},
handled: true,
type: 'instrument',
},
},
],
},
});
});

it('handles a thrown `json()` error response with string body', async () => {
const env = await RemixTestEnv.init(adapter);
const url = `${env.url}/action-json-response/-5`;

const envelopes = await env.getMultipleEnvelopeRequest({
url,
count: 2,
method: 'post',
envelopeType: ['transaction', 'event'],
});

const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');

assertSentryTransaction(transaction[2], {
contexts: {
trace: {
op: 'http.server',
status: 'internal_error',
tags: {
method: 'POST',
'http.status_code': '500',
},
},
},
tags: {
transaction: 'routes/action-json-response/$id',
},
});

assertSentryEvent(event[2], {
exception: {
values: [
{
type: 'Error',
value: 'Sentry Test Error [string body]',
stacktrace: expect.any(Object),
mechanism: {
data: {
function: 'action',
},
handled: true,
type: 'instrument',
},
},
],
},
});
});

it('handles a thrown `json()` error response with an empty object', async () => {
const env = await RemixTestEnv.init(adapter);
const url = `${env.url}/action-json-response/-6`;

const envelopes = await env.getMultipleEnvelopeRequest({
url,
count: 2,
method: 'post',
envelopeType: ['transaction', 'event'],
});

const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');

assertSentryTransaction(transaction[2], {
contexts: {
trace: {
op: 'http.server',
status: 'internal_error',
tags: {
method: 'POST',
'http.status_code': '500',
},
},
},
tags: {
transaction: 'routes/action-json-response/$id',
},
});

assertSentryEvent(event[2], {
exception: {
values: [
{
type: 'Error',
value: 'Non-Error exception captured with keys: [object has no keys]',
stacktrace: expect.any(Object),
mechanism: {
data: {
function: 'action',
},
handled: true,
type: 'instrument',
},
},
],
},
});
});
});