Skip to content

Commit a75f456

Browse files
0xbad0c0d3cod1kandreiborza
authored
Fix/nestjs cron issues (#16792)
Nestjs sync task has the same issue as async tasks - they don't care about exceptions because nestjs `@Cron()` decorator automatically wraps it to `try-catch` block and logs an error. That's why we should capture exception in our `@SentryCron()` decorator. Fixes also #16749 - [ ] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). --------- Co-authored-by: cod1k <cod1k@centro.team> Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com>
1 parent 7f3f6f7 commit a75f456

File tree

7 files changed

+81
-16
lines changed

7 files changed

+81
-16
lines changed

dev-packages/e2e-tests/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ async function run(): Promise<void> {
6666
}
6767

6868
await asyncExec('pnpm clean:test-applications', { env, cwd: __dirname });
69+
await asyncExec('pnpm cache delete "@sentry/*"', { env, cwd: __dirname });
6970

7071
const testAppPaths = appName ? [appName.trim()] : globSync('*', { cwd: `${__dirname}/test-applications/` });
7172

dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.service.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,21 @@ export class AppService {
8585
only supports minute granularity, but we don't want to wait (worst case) a
8686
full minute for the tests to finish.
8787
*/
88-
@Cron('*/5 * * * * *', { name: 'test-cron-error' })
89-
@SentryCron('test-cron-error-slug', monitorConfig)
88+
@Cron('*/5 * * * * *', { name: 'test-async-cron-error' })
89+
@SentryCron('test-async-cron-error-slug', monitorConfig)
9090
async testCronError() {
91-
throw new Error('Test error from cron job');
91+
throw new Error('Test error from cron async job');
92+
}
93+
94+
/*
95+
Actual cron schedule differs from schedule defined in config because Sentry
96+
only supports minute granularity, but we don't want to wait (worst case) a
97+
full minute for the tests to finish.
98+
*/
99+
@Cron('*/5 * * * * *', { name: 'test-sync-cron-error' })
100+
@SentryCron('test-sync-cron-error-slug', monitorConfig)
101+
testSyncCronError() {
102+
throw new Error('Test error from cron sync job');
92103
}
93104

94105
async killTestCron(job: string) {

dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,36 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => {
6262
await fetch(`${baseURL}/kill-test-cron/test-cron-job`);
6363
});
6464

65-
test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => {
65+
test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL }) => {
6666
const errorEventPromise = waitForError('nestjs-basic', event => {
67-
return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job';
67+
return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron async job';
6868
});
6969

7070
const errorEvent = await errorEventPromise;
7171

7272
expect(errorEvent.exception?.values).toHaveLength(1);
73-
expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job');
7473
expect(errorEvent.contexts?.trace).toEqual({
7574
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
7675
span_id: expect.stringMatching(/[a-f0-9]{16}/),
7776
});
7877

7978
// kill cron so tests don't get stuck
80-
await fetch(`${baseURL}/kill-test-cron/test-cron-error`);
79+
await fetch(`${baseURL}/kill-test-cron/test-async-cron-error`);
80+
});
81+
82+
test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) => {
83+
const errorEventPromise = waitForError('nestjs-basic', event => {
84+
return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron sync job';
85+
});
86+
87+
const errorEvent = await errorEventPromise;
88+
89+
expect(errorEvent.exception?.values).toHaveLength(1);
90+
expect(errorEvent.contexts?.trace).toEqual({
91+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
92+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
93+
});
94+
95+
// kill cron so tests don't get stuck
96+
await fetch(`${baseURL}/kill-test-cron/test-sync-cron-error`);
8197
});

dev-packages/e2e-tests/test-applications/node-express/src/app.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ const port = 3030;
2929

3030
app.use(mcpRouter);
3131

32+
app.get('/crash-in-with-monitor/:id', async (req, res) => {
33+
try {
34+
await Sentry.withMonitor('express-crash', async () => {
35+
throw new Error(`This is an exception withMonitor: ${req.params.id}`);
36+
});
37+
res.sendStatus(200);
38+
} catch (error: any) {
39+
res.status(500);
40+
res.send({ message: error.message, pid: process.pid });
41+
}
42+
});
43+
3244
app.get('/test-success', function (req, res) {
3345
res.send({ version: 'v1' });
3446
});

dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ test('Should record caught exceptions with local variable', async ({ baseURL })
3737

3838
const errorEvent = await errorEventPromise;
3939

40-
const frames = errorEvent.exception?.values?.[0].stacktrace?.frames;
41-
expect(frames?.[frames.length - 1].vars?.randomVariableToRecord).toBeDefined();
40+
const frames = errorEvent.exception?.values?.[0]?.stacktrace?.frames;
41+
expect(frames?.[frames.length - 1]?.vars?.randomVariableToRecord).toBeDefined();
42+
});
43+
44+
test('To not crash app from withMonitor', async ({ baseURL }) => {
45+
const doRequest = async (id: number) => {
46+
const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`)
47+
return response.json();
48+
}
49+
const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)])
50+
expect(response1.message).toBe('This is an exception withMonitor: 1')
51+
expect(response2.message).toBe('This is an exception withMonitor: 2')
52+
expect(response1.pid).toBe(response2.pid) //Just to double-check, TBS
4253
});

packages/core/src/exports.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export function captureCheckIn(checkIn: CheckIn, upsertMonitorConfig?: MonitorCo
150150
* Wraps a callback with a cron monitor check in. The check in will be sent to Sentry when the callback finishes.
151151
*
152152
* @param monitorSlug The distinct slug of the monitor.
153+
* @param callback Callback to be monitored
153154
* @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want
154155
* to create a monitor automatically when sending a check in.
155156
*/
@@ -175,18 +176,18 @@ export function withMonitor<T>(
175176
}
176177

177178
if (isThenable(maybePromiseResult)) {
178-
Promise.resolve(maybePromiseResult).then(
179-
() => {
179+
return maybePromiseResult.then(
180+
r => {
180181
finishCheckIn('ok');
182+
return r;
181183
},
182184
e => {
183185
finishCheckIn('error');
184186
throw e;
185187
},
186-
);
187-
} else {
188-
finishCheckIn('ok');
188+
) as T;
189189
}
190+
finishCheckIn('ok');
190191

191192
return maybePromiseResult;
192193
});

packages/nestjs/src/decorators.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MonitorConfig } from '@sentry/core';
2-
import { captureException } from '@sentry/core';
2+
import { captureException, isThenable } from '@sentry/core';
33
import * as Sentry from '@sentry/node';
44
import { startSpan } from '@sentry/node';
55
import { isExpectedError } from './helpers';
@@ -15,7 +15,20 @@ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig):
1515
return Sentry.withMonitor(
1616
monitorSlug,
1717
() => {
18-
return originalMethod.apply(this, args);
18+
let result;
19+
try {
20+
result = originalMethod.apply(this, args);
21+
} catch (e) {
22+
captureException(e);
23+
throw e;
24+
}
25+
if (isThenable(result)) {
26+
return result.then(undefined, e => {
27+
captureException(e);
28+
throw e;
29+
});
30+
}
31+
return result;
1932
},
2033
monitorConfig,
2134
);

0 commit comments

Comments
 (0)