From b9afed048cea5f9e7893be523ad16b5a9c95d0de Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 3 Aug 2022 11:49:50 +0100 Subject: [PATCH 1/4] test(remix): Add server-side instrumentation integration tests. --- packages/node-integration-tests/package.json | 1 + .../node-integration-tests/utils/index.ts | 34 ++++++++-- packages/remix/test/integration/app/root.tsx | 2 +- .../app/routes/action-json-response/$id.tsx | 19 ++++++ .../app/routes/loader-json-response/$id.tsx | 4 ++ .../integration/test/server/action.test.ts | 68 +++++++++++++++++++ .../integration/test/server/loader.test.ts | 65 +++++++++++++++++- packages/remix/tsconfig.json | 3 +- yarn.lock | 13 ++++ 9 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 packages/remix/test/integration/app/routes/action-json-response/$id.tsx create mode 100644 packages/remix/test/integration/test/server/action.test.ts 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..3af8c79dfb87 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. * @@ -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/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..8e87679c2677 --- /dev/null +++ b/packages/remix/test/integration/app/routes/action-json-response/$id.tsx @@ -0,0 +1,19 @@ +import { ActionFunction, json } from '@remix-run/node'; +import { useActionData } from '@remix-run/react'; + +export const action: ActionFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Error'); + } + + return json({ test: 'test' }); +}; + +export default function ActionJSONResponse() { + const { test } = useActionData(); + return ( +
+

{test}

+
+ ); +} 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..bcdf6b5b45e5 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 @@ -4,6 +4,10 @@ import { useLoaderData } from '@remix-run/react'; type LoaderData = { id: string }; export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Error'); + } + return json({ id, }); 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..f90b7649b354 --- /dev/null +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -0,0 +1,68 @@ +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, { + spans: [ + { + description: 'routes/action-json-response/$id', + op: 'remix.server.action', + }, + { + 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: 'Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: 'action', + }, + 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..792c36530d6a 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -1,7 +1,68 @@ -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, { + 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/-1`; + + 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: 'Error', + 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); 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" From d572c1e0f9f1c4ff1a86c8530a8473783b9a68fa Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 8 Aug 2022 17:15:55 +0100 Subject: [PATCH 2/4] Do not assert `timestamps` as numbers. Check if they are defined. --- packages/node-integration-tests/utils/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-integration-tests/utils/index.ts b/packages/node-integration-tests/utils/index.ts index 3af8c79dfb87..5e317864e344 100644 --- a/packages/node-integration-tests/utils/index.ts +++ b/packages/node-integration-tests/utils/index.ts @@ -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, From 7e197cc70356642ebcee9d4393eb71bef76750eb Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 8 Aug 2022 17:22:24 +0100 Subject: [PATCH 3/4] Assert transaction names. --- packages/remix/test/integration/test/server/action.test.ts | 1 + packages/remix/test/integration/test/server/loader.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index f90b7649b354..008a18478c6e 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -16,6 +16,7 @@ describe('Remix API Actions', () => { const transaction = envelope[2]; assertSentryTransaction(transaction, { + transaction: 'routes/action-json-response/$id', spans: [ { description: 'routes/action-json-response/$id', diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index 792c36530d6a..9d257b37edb8 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -16,6 +16,7 @@ describe('Remix API Loaders', () => { const transaction = envelope[2]; assertSentryTransaction(transaction, { + transaction: 'root', spans: [ { description: 'root', From f29c0f0666837f9700d69d051a76fbcae32dc8a9 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 9 Aug 2022 10:17:39 +0100 Subject: [PATCH 4/4] Add thrown 500 redirects. --- .../test/integration/app/entry.server.tsx | 2 + .../app/routes/action-json-response/$id.tsx | 20 ++++-- .../app/routes/loader-json-response/$id.tsx | 10 ++- .../integration/test/server/action.test.ts | 64 ++++++++++++++++++- .../integration/test/server/loader.test.ts | 62 +++++++++++++++++- 5 files changed, 148 insertions(+), 10 deletions(-) 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/routes/action-json-response/$id.tsx b/packages/remix/test/integration/app/routes/action-json-response/$id.tsx index 8e87679c2677..4763e37282d4 100644 --- a/packages/remix/test/integration/app/routes/action-json-response/$id.tsx +++ b/packages/remix/test/integration/app/routes/action-json-response/$id.tsx @@ -1,19 +1,31 @@ -import { ActionFunction, json } from '@remix-run/node'; +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('Error'); + 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 { test } = useActionData(); + const data = useActionData(); + return (
-

{test}

+

{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 bcdf6b5b45e5..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,11 +1,15 @@ -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 new Error('Error'); + throw redirect('/loader-json-response/-2'); } return json({ @@ -18,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 index 008a18478c6e..1c32cd86574d 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -22,6 +22,10 @@ describe('Remix API Actions', () => { 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', @@ -52,7 +56,7 @@ describe('Remix API Actions', () => { values: [ { type: 'Error', - value: 'Error', + value: 'Unexpected Server Error', stacktrace: expect.any(Object), mechanism: { data: { @@ -66,4 +70,62 @@ describe('Remix API Actions', () => { }, }); }); + + 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 9d257b37edb8..4da695979b95 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -28,7 +28,7 @@ describe('Remix API Loaders', () => { it('reports an error thrown from the loader', async () => { const baseURL = await runServer(); - const url = `${baseURL}/loader-json-response/-1`; + const url = `${baseURL}/loader-json-response/-2`; const [transaction, event] = await getMultipleEnvelopeRequest(url, 2); @@ -48,7 +48,7 @@ describe('Remix API Loaders', () => { values: [ { type: 'Error', - value: 'Error', + value: 'Unexpected Server Error from Loader', stacktrace: expect.any(Object), mechanism: { data: { @@ -86,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', + }, + }, + ], + }, + }); + }); });