diff --git a/packages/node-integration-tests/package.json b/packages/node-integration-tests/package.json index 33a2579ea831..ce592de5619e 100644 --- a/packages/node-integration-tests/package.json +++ b/packages/node-integration-tests/package.json @@ -23,6 +23,7 @@ "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", "apollo-server": "^3.6.7", + "axios": "^0.27.2", "cors": "^2.8.5", "express": "^4.17.3", "graphql": "^16.3.0", diff --git a/packages/node-integration-tests/utils/index.ts b/packages/node-integration-tests/utils/index.ts index 3ceb89cc3cb8..5e317864e344 100644 --- a/packages/node-integration-tests/utils/index.ts +++ b/packages/node-integration-tests/utils/index.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { parseSemver } from '@sentry/utils'; +import { logger, parseSemver } from '@sentry/utils'; +import axios from 'axios'; import { Express } from 'express'; import * as http from 'http'; import { RequestOptions } from 'https'; import nock from 'nock'; import * as path from 'path'; import { getPortPromise } from 'portfinder'; - /** * Returns`describe` or `describe.skip` depending on allowed major versions of Node. * @@ -33,7 +33,7 @@ export const conditionalTest = (allowedVersion: { min?: number; max?: number }): export const assertSentryEvent = (actual: Record, expected: Record): void => { expect(actual).toMatchObject({ event_id: expect.any(String), - timestamp: expect.any(Number), + timestamp: expect.anything(), ...expected, }); }; @@ -47,8 +47,8 @@ export const assertSentryEvent = (actual: Record, expected: Rec export const assertSentryTransaction = (actual: Record, expected: Record): void => { expect(actual).toMatchObject({ event_id: expect.any(String), - timestamp: expect.any(Number), - start_timestamp: expect.any(Number), + timestamp: expect.anything(), + start_timestamp: expect.anything(), spans: expect.any(Array), type: 'transaction', ...expected, @@ -71,12 +71,18 @@ export const parseEnvelope = (body: string): Array> => { * @param url The url the intercepted requests will be directed to. * @param count The expected amount of requests to the envelope endpoint. If * the amount of sentrequests is lower than`count`, this function will not resolve. + * @param method The method of the request. Defaults to `GET`. * @returns The intercepted envelopes. */ -export const getMultipleEnvelopeRequest = async (url: string, count: number): Promise[][]> => { +export const getMultipleEnvelopeRequest = async ( + url: string, + count: number, + method: 'get' | 'post' = 'get', +): Promise[][]> => { const envelopes: Record[][] = []; - return new Promise(resolve => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { nock('https://dsn.ingest.sentry.io') .post('/api/1337/envelope/', body => { const envelope = parseEnvelope(body); @@ -92,7 +98,17 @@ export const getMultipleEnvelopeRequest = async (url: string, count: number): Pr .query(true) // accept any query params - used for sentry_key param .reply(200); - http.get(url); + try { + if (method === 'get') { + await axios.get(url); + } else { + await axios.post(url); + } + } catch (e) { + // We sometimes expect the request to fail, but not the test. + // So, we do nothing. + logger.warn(e); + } }); }; @@ -133,10 +149,14 @@ export const getAPIResponse = async (url: URL, headers?: Record) * Intercepts and extracts a single request containing a Sentry envelope * * @param url The url the intercepted request will be directed to. + * @param method The method of the request. Defaults to `GET`. * @returns The extracted envelope. */ -export const getEnvelopeRequest = async (url: string): Promise>> => { - return (await getMultipleEnvelopeRequest(url, 1))[0]; +export const getEnvelopeRequest = async ( + url: string, + method: 'get' | 'post' = 'get', +): Promise>> => { + return (await getMultipleEnvelopeRequest(url, 1, method))[0]; }; /** diff --git a/packages/remix/test/integration/app/entry.server.tsx b/packages/remix/test/integration/app/entry.server.tsx index e51fd3f73f87..ae879492e236 100644 --- a/packages/remix/test/integration/app/entry.server.tsx +++ b/packages/remix/test/integration/app/entry.server.tsx @@ -6,6 +6,8 @@ import * as Sentry from '@sentry/remix'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, + // Disabling to test series of envelopes deterministically. + autoSessionTracking: false, }); export default function handleRequest( diff --git a/packages/remix/test/integration/app/root.tsx b/packages/remix/test/integration/app/root.tsx index cbc85172bdfb..e2833effa83b 100644 --- a/packages/remix/test/integration/app/root.tsx +++ b/packages/remix/test/integration/app/root.tsx @@ -10,7 +10,7 @@ export const meta: MetaFunction = ({ data }) => ({ baggage: data.sentryBaggage, }); -function App() { +export function App() { return ( diff --git a/packages/remix/test/integration/app/routes/action-json-response/$id.tsx b/packages/remix/test/integration/app/routes/action-json-response/$id.tsx new file mode 100644 index 000000000000..4763e37282d4 --- /dev/null +++ b/packages/remix/test/integration/app/routes/action-json-response/$id.tsx @@ -0,0 +1,31 @@ +import { ActionFunction, json, redirect, LoaderFunction } from '@remix-run/node'; +import { useActionData } from '@remix-run/react'; + +export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Unexpected Server Error from Loader'); + } +}; + +export const action: ActionFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Unexpected Server Error'); + } + + if (id === '-2') { + // Note: This GET request triggers to the `Loader` of the URL, not the `Action`. + throw redirect('/action-json-response/-1'); + } + + return json({ test: 'test' }); +}; + +export default function ActionJSONResponse() { + const data = useActionData(); + + return ( +
+

{data && data.test ? data.test : 'Not Found'}

+
+ ); +} diff --git a/packages/remix/test/integration/app/routes/loader-json-response/$id.tsx b/packages/remix/test/integration/app/routes/loader-json-response/$id.tsx index 833b59f5bda2..55b53e2d70dc 100644 --- a/packages/remix/test/integration/app/routes/loader-json-response/$id.tsx +++ b/packages/remix/test/integration/app/routes/loader-json-response/$id.tsx @@ -1,9 +1,17 @@ -import { json, LoaderFunction } from '@remix-run/node'; +import { json, LoaderFunction, redirect } from '@remix-run/node'; import { useLoaderData } from '@remix-run/react'; type LoaderData = { id: string }; export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-2') { + throw new Error('Unexpected Server Error from Loader'); + } + + if (id === '-1') { + throw redirect('/loader-json-response/-2'); + } + return json({ id, }); @@ -14,7 +22,7 @@ export default function LoaderJSONResponse() { return (
-

{data.id}

+

{data && data.id ? data.id : 'Not Found'}

); } diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts new file mode 100644 index 000000000000..1c32cd86574d --- /dev/null +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -0,0 +1,131 @@ +import { + assertSentryTransaction, + getEnvelopeRequest, + runServer, + getMultipleEnvelopeRequest, + assertSentryEvent, +} from './utils/helpers'; + +jest.spyOn(console, 'error').mockImplementation(); + +describe('Remix API Actions', () => { + it('correctly instruments a parameterized Remix API action', async () => { + const baseURL = await runServer(); + const url = `${baseURL}/action-json-response/123123`; + const envelope = await getEnvelopeRequest(url, 'post'); + const transaction = envelope[2]; + + assertSentryTransaction(transaction, { + transaction: 'routes/action-json-response/$id', + spans: [ + { + description: 'routes/action-json-response/$id', + op: 'remix.server.action', + }, + { + description: 'routes/action-json-response/$id', + op: 'remix.server.loader', + }, + { + description: 'routes/action-json-response/$id', + op: 'remix.server.documentRequest', + }, + ], + }); + }); + + it('reports an error thrown from the action', async () => { + const baseURL = await runServer(); + const url = `${baseURL}/action-json-response/-1`; + + const [transaction, event] = await getMultipleEnvelopeRequest(url, 2, 'post'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + status: 'internal_error', + tags: { + 'http.status_code': '500', + }, + }, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Unexpected Server Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: 'action', + }, + handled: true, + type: 'instrument', + }, + }, + ], + }, + }); + }); + + it('handles a thrown 500 response', async () => { + const baseURL = await runServer(); + const url = `${baseURL}/action-json-response/-2`; + + const [transaction_1, event, transaction_2] = await getMultipleEnvelopeRequest(url, 3, 'post'); + + assertSentryTransaction(transaction_1[2], { + contexts: { + trace: { + op: 'http.server', + status: 'ok', + tags: { + method: 'POST', + 'http.status_code': '302', + }, + }, + }, + tags: { + transaction: 'routes/action-json-response/$id', + }, + }); + + assertSentryTransaction(transaction_2[2], { + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + tags: { + method: 'GET', + 'http.status_code': '500', + }, + }, + }, + tags: { + transaction: 'routes/action-json-response/$id', + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Unexpected Server Error from Loader', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: 'loader', + }, + handled: true, + type: 'instrument', + }, + }, + ], + }, + }); + }); +}); diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index 6811a4d9550a..4da695979b95 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -1,7 +1,69 @@ -import { assertSentryTransaction, getEnvelopeRequest, runServer } from './utils/helpers'; +import { + assertSentryTransaction, + getEnvelopeRequest, + runServer, + getMultipleEnvelopeRequest, + assertSentryEvent, +} from './utils/helpers'; + +jest.spyOn(console, 'error').mockImplementation(); describe('Remix API Loaders', () => { - it('correctly instruments a Remix API loader', async () => { + it('does not add a loader if there is not one defined.', async () => { + const baseURL = await runServer(); + const url = `${baseURL}/`; + const envelope = await getEnvelopeRequest(url); + const transaction = envelope[2]; + + assertSentryTransaction(transaction, { + transaction: 'root', + spans: [ + { + description: 'root', + op: 'remix.server.documentRequest', + }, + ], + }); + }); + + it('reports an error thrown from the loader', async () => { + const baseURL = await runServer(); + const url = `${baseURL}/loader-json-response/-2`; + + const [transaction, event] = await getMultipleEnvelopeRequest(url, 2); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + status: 'internal_error', + tags: { + 'http.status_code': '500', + }, + }, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Unexpected Server Error from Loader', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: 'loader', + }, + handled: true, + type: 'instrument', + }, + }, + ], + }, + }); + }); + + it('correctly instruments a parameterized Remix API loader', async () => { const baseURL = await runServer(); const url = `${baseURL}/loader-json-response/123123`; const envelope = await getEnvelopeRequest(url); @@ -24,4 +86,62 @@ describe('Remix API Loaders', () => { ], }); }); + + it('handles a thrown 500 response', async () => { + const baseURL = await runServer(); + const url = `${baseURL}/loader-json-response/-1`; + + const [transaction_1, event, transaction_2] = await getMultipleEnvelopeRequest(url, 3); + + assertSentryTransaction(transaction_1[2], { + contexts: { + trace: { + op: 'http.server', + status: 'ok', + tags: { + method: 'GET', + 'http.status_code': '302', + }, + }, + }, + tags: { + transaction: 'routes/loader-json-response/$id', + }, + }); + + assertSentryTransaction(transaction_2[2], { + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + tags: { + method: 'GET', + 'http.status_code': '500', + }, + }, + }, + tags: { + transaction: 'routes/loader-json-response/$id', + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Unexpected Server Error from Loader', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: 'loader', + }, + handled: true, + type: 'instrument', + }, + }, + ], + }, + }); + }); }); diff --git a/packages/remix/tsconfig.json b/packages/remix/tsconfig.json index 2845677dcc52..f1f9d9ccc513 100644 --- a/packages/remix/tsconfig.json +++ b/packages/remix/tsconfig.json @@ -4,6 +4,7 @@ "include": ["src/**/*"], "compilerOptions": { - "jsx": "react" + "jsx": "react", + "module": "es2020" } } diff --git a/yarn.lock b/yarn.lock index 752747d71c27..b1ffb4ea488c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7146,6 +7146,14 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -13706,6 +13714,11 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== +follow-redirects@^1.14.9: + version "1.15.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" + integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"