Skip to content

Commit a943366

Browse files
feat(api): replace sentry plugin with sdk (freeCodeCamp#58912)
Co-authored-by: Naomi <accounts+github@nhcarrigan.com>
1 parent 382f60c commit a943366

File tree

6 files changed

+1226
-129
lines changed

6 files changed

+1226
-129
lines changed

api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
"@fastify/swagger-ui": "1.10.2",
1515
"@fastify/type-provider-typebox": "3.6.0",
1616
"@growthbook/growthbook": "1.3.1",
17-
"@immobiliarelabs/fastify-sentry": "7.1.1",
1817
"@prisma/client": "5.5.2",
18+
"@sentry/node": "9.1.0",
1919
"ajv": "8.12.0",
2020
"ajv-formats": "2.1.1",
2121
"date-fns": "2.30.0",
@@ -45,6 +45,7 @@
4545
"@types/validator": "13.11.2",
4646
"dotenv-cli": "7.3.0",
4747
"jest": "29.7.0",
48+
"msw": "^2.7.0",
4849
"prisma": "5.5.2",
4950
"supertest": "6.3.3",
5051
"ts-jest": "29.1.2",

api/src/instrument.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/node';
2+
import type { FastifyError } from 'fastify';
3+
4+
import { SENTRY_DSN, SENTRY_ENVIRONMENT } from './utils/env';
5+
6+
const shouldIgnoreError = (error: FastifyError): boolean => {
7+
return !!error.statusCode && error.statusCode < 500;
8+
};
9+
10+
// Ensure to call this before importing any other modules!
11+
Sentry.init({
12+
dsn: SENTRY_DSN,
13+
environment: SENTRY_ENVIRONMENT,
14+
maxValueLength: 8192, // the default is 250, which is too small.
15+
beforeSend: (event, hint) =>
16+
shouldIgnoreError(hint.originalException as FastifyError) ? null : event
17+
});

api/src/plugins/error-handling.test.ts

Lines changed: 209 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
1-
import Fastify, { type FastifyInstance } from 'fastify';
1+
const SENTRY_DSN = 'https://anything@goes/123';
2+
3+
import Fastify, { FastifyError, type FastifyInstance } from 'fastify';
24
import accepts from '@fastify/accepts';
5+
import { http, HttpResponse } from 'msw';
6+
import { setupServer } from 'msw/node';
7+
import '../instrument';
38

49
import errorHandling from './error-handling';
510
import redirectWithMessage, { formatMessage } from './redirect-with-message';
611

12+
jest.mock('../utils/env', () => {
13+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
14+
return {
15+
...jest.requireActual('../utils/env'),
16+
SENTRY_DSN
17+
};
18+
});
19+
20+
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
21+
722
describe('errorHandling', () => {
823
let fastify: FastifyInstance;
924

@@ -14,13 +29,33 @@ describe('errorHandling', () => {
1429
await fastify.register(errorHandling);
1530

1631
fastify.get('/test', async (_req, _reply) => {
17-
throw new Error('a very bad thing happened');
18-
return { ok: true };
32+
const error = Error('a very bad thing happened') as FastifyError;
33+
error.statusCode = 500;
34+
throw error;
35+
});
36+
fastify.get('/test-bad-request', async (_req, _reply) => {
37+
const error = Error('a very bad thing happened') as FastifyError;
38+
error.statusCode = 400;
39+
throw error;
40+
});
41+
fastify.get('/test-csrf-token', async (_req, _reply) => {
42+
const error = Error() as FastifyError;
43+
error.code = 'FST_CSRF_INVALID_TOKEN';
44+
error.statusCode = 403;
45+
throw error;
46+
});
47+
48+
fastify.get('/test-csrf-secret', async (_req, _reply) => {
49+
const error = Error() as FastifyError;
50+
error.code = 'FST_CSRF_MISSING_SECRET';
51+
error.statusCode = 403;
52+
throw error;
1953
});
2054
});
2155

2256
afterEach(async () => {
2357
await fastify.close();
58+
jest.clearAllMocks();
2459
});
2560

2661
it('should redirect to the referer if the request does not Accept json', async () => {
@@ -33,14 +68,26 @@ describe('errorHandling', () => {
3368
}
3469
});
3570

71+
expect(res.statusCode).toEqual(302);
72+
});
73+
74+
it('should add a generic flash message if it is a server error (i.e. 500+)', async () => {
75+
const res = await fastify.inject({
76+
method: 'GET',
77+
url: '/test',
78+
headers: {
79+
referer: 'https://www.freecodecamp.org/anything',
80+
accept: 'text/plain'
81+
}
82+
});
83+
3684
expect(res.headers['location']).toEqual(
3785
'https://www.freecodecamp.org/anything?' +
3886
formatMessage({
3987
type: 'danger',
4088
content: 'flash.generic-error'
4189
})
4290
);
43-
expect(res.statusCode).toEqual(302);
4491
});
4592

4693
it('should return a json response if the request does Accept json', async () => {
@@ -53,11 +100,11 @@ describe('errorHandling', () => {
53100
}
54101
});
55102

103+
expect(res.statusCode).toEqual(500);
56104
expect(res.json()).toEqual({
57105
message: 'flash.generic-error',
58106
type: 'danger'
59107
});
60-
expect(res.statusCode).toEqual(500);
61108
});
62109

63110
it('should redirect if the request prefers text/html to json', async () => {
@@ -71,13 +118,163 @@ describe('errorHandling', () => {
71118
}
72119
});
73120

74-
expect(res.headers['location']).toEqual(
75-
'https://www.freecodecamp.org/anything?' +
76-
formatMessage({
77-
type: 'danger',
78-
content: 'flash.generic-error'
79-
})
80-
);
81121
expect(res.statusCode).toEqual(302);
82122
});
123+
124+
it('should respect the error status code', async () => {
125+
const res = await fastify.inject({
126+
method: 'GET',
127+
url: '/test-bad-request'
128+
});
129+
130+
expect(res.statusCode).toEqual(400);
131+
});
132+
133+
it('should return the error message if the status is not 500 ', async () => {
134+
const res = await fastify.inject({
135+
method: 'GET',
136+
url: '/test-bad-request'
137+
});
138+
139+
expect(res.json()).toEqual({
140+
message: 'a very bad thing happened',
141+
type: 'danger'
142+
});
143+
});
144+
145+
it('should convert CSRF errors to a generic error message', async () => {
146+
const resToken = await fastify.inject({
147+
method: 'GET',
148+
url: '/test-csrf-token'
149+
});
150+
const resSecret = await fastify.inject({
151+
method: 'GET',
152+
url: '/test-csrf-secret'
153+
});
154+
155+
expect(resToken.json()).toEqual({
156+
message: 'flash.generic-error',
157+
type: 'danger'
158+
});
159+
expect(resSecret.json()).toEqual({
160+
message: 'flash.generic-error',
161+
type: 'danger'
162+
});
163+
});
164+
165+
it('should call fastify.log.error when an unhandled error occurs', async () => {
166+
const logSpy = jest.spyOn(fastify.log, 'error');
167+
168+
await fastify.inject({
169+
method: 'GET',
170+
url: '/test'
171+
});
172+
173+
expect(logSpy).toHaveBeenCalledWith(Error('a very bad thing happened'));
174+
});
175+
176+
it('should call fastify.log.warn when a bad request error occurs', async () => {
177+
const logSpy = jest.spyOn(fastify.log, 'warn');
178+
179+
await fastify.inject({
180+
method: 'GET',
181+
url: '/test-bad-request'
182+
});
183+
184+
expect(logSpy).toHaveBeenCalledWith(Error('a very bad thing happened'));
185+
});
186+
187+
it('should NOT log when a CSRF error is thrown', async () => {
188+
const errorLogSpy = jest.spyOn(fastify.log, 'error');
189+
const warnLogSpy = jest.spyOn(fastify.log, 'warn');
190+
191+
await fastify.inject({
192+
method: 'GET',
193+
url: '/test-csrf-token'
194+
});
195+
196+
expect(errorLogSpy).not.toHaveBeenCalled();
197+
expect(warnLogSpy).not.toHaveBeenCalled();
198+
199+
await fastify.inject({
200+
method: 'GET',
201+
url: '/test-csrf-secret'
202+
});
203+
204+
expect(errorLogSpy).not.toHaveBeenCalled();
205+
expect(warnLogSpy).not.toHaveBeenCalled();
206+
});
207+
208+
describe('Sentry integration', () => {
209+
let mockServer: ReturnType<typeof setupServer>;
210+
211+
beforeAll(() => {
212+
// The assumption is that Sentry is the only library making requests. Also, we
213+
// only want to know if a request was made, not what it was.
214+
const sentryHandler = http.post('*', () =>
215+
HttpResponse.json({ success: true })
216+
);
217+
mockServer = setupServer(sentryHandler);
218+
mockServer.listen();
219+
});
220+
221+
afterEach(() => {
222+
mockServer.resetHandlers();
223+
});
224+
225+
afterAll(() => {
226+
mockServer.close();
227+
});
228+
229+
const createRequestListener = () =>
230+
new Promise(resolve => {
231+
mockServer.events.on('request:start', () => {
232+
resolve(true);
233+
});
234+
});
235+
236+
it('should capture the error with Sentry', async () => {
237+
const receivedRequest = createRequestListener();
238+
239+
await fastify.inject({
240+
method: 'GET',
241+
url: '/test'
242+
});
243+
244+
expect(await Promise.race([receivedRequest, delay(1000)])).toBe(true);
245+
});
246+
247+
it('should NOT capture CSRF token errors with Sentry', async () => {
248+
const receivedRequest = createRequestListener();
249+
250+
await fastify.inject({
251+
method: 'GET',
252+
url: '/test-csrf-token'
253+
});
254+
255+
expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined();
256+
});
257+
258+
it('should NOT capture CSRF secret errors with Sentry', async () => {
259+
const receivedRequest = createRequestListener();
260+
261+
await fastify.inject({
262+
method: 'GET',
263+
url: '/test-csrf-secret'
264+
});
265+
266+
expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined();
267+
});
268+
269+
it('should NOT capture bad requests with Sentry', async () => {
270+
const receivedRequest = createRequestListener();
271+
272+
await fastify.inject({
273+
method: 'GET',
274+
url: '/test-bad-request'
275+
});
276+
277+
expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined();
278+
});
279+
});
83280
});

0 commit comments

Comments
 (0)