Skip to content

Commit d045ba1

Browse files
committed
fix(core): Ensure errors thrown in async cron jobs bubble up
1 parent 738870d commit d045ba1

File tree

4 files changed

+91
-9
lines changed

4 files changed

+91
-9
lines changed

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ export class AppController {
7777
return { result: await this.appService.testSpanDecoratorSync() };
7878
}
7979

80-
@Get('kill-test-cron')
81-
async killTestCron() {
82-
this.appService.killTestCron();
80+
@Get('kill-test-cron/:job')
81+
async killTestCron(@Param('job') job: string) {
82+
this.appService.killTestCron(job);
8383
}
8484

8585
@Get('flush')

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

+13-2
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,19 @@ export class AppService {
8080
console.log('Test cron!');
8181
}
8282

83-
async killTestCron() {
84-
this.schedulerRegistry.deleteCronJob('test-cron-job');
83+
/*
84+
Actual cron schedule differs from schedule defined in config because Sentry
85+
only supports minute granularity, but we don't want to wait (worst case) a
86+
full minute for the tests to finish.
87+
*/
88+
@Cron('*/5 * * * * *', { name: 'test-cron-error' })
89+
@SentryCron('test-cron-error-slug', monitorConfig)
90+
async testCronError() {
91+
throw new Error('Test error from cron job');
92+
}
93+
94+
async killTestCron(job: string) {
95+
this.schedulerRegistry.deleteCronJob(job);
8596
}
8697

8798
use() {

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

+30-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForEnvelopeItem } from '@sentry-internal/test-utils';
2+
import { waitForEnvelopeItem, waitForError } from '@sentry-internal/test-utils';
33

44
test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => {
55
const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => {
6-
return envelope[0].type === 'check_in' && envelope[1]['status'] === 'in_progress';
6+
return (
7+
envelope[0].type === 'check_in' &&
8+
envelope[1]['monitor_slug'] === 'test-cron-slug' &&
9+
envelope[1]['status'] === 'in_progress'
10+
);
711
});
812

913
const okEnvelopePromise = waitForEnvelopeItem('nestjs-basic', envelope => {
10-
return envelope[0].type === 'check_in' && envelope[1]['status'] === 'ok';
14+
return (
15+
envelope[0].type === 'check_in' &&
16+
envelope[1]['monitor_slug'] === 'test-cron-slug' &&
17+
envelope[1]['status'] === 'ok'
18+
);
1119
});
1220

1321
const inProgressEnvelope = await inProgressEnvelopePromise;
@@ -51,5 +59,23 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => {
5159
);
5260

5361
// kill cron so tests don't get stuck
54-
await fetch(`${baseURL}/kill-test-cron`);
62+
await fetch(`${baseURL}/kill-test-cron/test-cron-job`);
63+
});
64+
65+
test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => {
66+
const errorEventPromise = waitForError('nestjs-basic', event => {
67+
return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job';
68+
});
69+
70+
const errorEvent = await errorEventPromise;
71+
72+
expect(errorEvent.exception?.values).toHaveLength(1);
73+
expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job');
74+
expect(errorEvent.contexts?.trace).toEqual({
75+
trace_id: expect.any(String),
76+
span_id: expect.any(String),
77+
});
78+
79+
// kill cron so tests don't get stuck
80+
await fetch(`${baseURL}/kill-test-cron/test-cron-error`);
5581
});

packages/core/test/lib/base.test.ts

+45
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
lastEventId,
1010
makeSession,
1111
setCurrentClient,
12+
withMonitor,
1213
} from '../../src';
1314
import * as integrationModule from '../../src/integration';
1415
import { TestClient, getDefaultTestClientOptions } from '../mocks/client';
@@ -2090,4 +2091,48 @@ describe('BaseClient', () => {
20902091
expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 });
20912092
});
20922093
});
2094+
2095+
describe('withMonitor', () => {
2096+
test('handles successful synchronous operations', () => {
2097+
const result = 'foo';
2098+
const callback = jest.fn().mockReturnValue(result);
2099+
2100+
const returnedResult = withMonitor('test-monitor', callback);
2101+
2102+
expect(returnedResult).toBe(result);
2103+
expect(callback).toHaveBeenCalledTimes(1);
2104+
});
2105+
2106+
test('handles synchronous errors', () => {
2107+
const error = new Error('Test error');
2108+
const callback = jest.fn().mockImplementation(() => {
2109+
throw error;
2110+
});
2111+
2112+
expect(() => withMonitor('test-monitor', callback)).toThrowError(error);
2113+
});
2114+
2115+
test('handles successful asynchronous operations', async () => {
2116+
const result = 'foo';
2117+
const callback = jest.fn().mockResolvedValue(result);
2118+
2119+
const promise = withMonitor('test-monitor', callback);
2120+
await expect(promise).resolves.toEqual(result);
2121+
});
2122+
2123+
// This test is skipped because jest keeps retrying ad infinitum
2124+
// when encountering an unhandled rejections.
2125+
// We could set "NODE_OPTIONS='--unhandled-rejections=warn' but it
2126+
// would affect the entire test suite.
2127+
// Maybe this can be re-enabled when switching to vitest.
2128+
//
2129+
// eslint-disable-next-line @sentry-internal/sdk/no-skipped-tests
2130+
test.skip('handles asynchronous errors', async () => {
2131+
const error = new Error('Test error');
2132+
const callback = jest.fn().mockRejectedValue(error);
2133+
2134+
const promise = await withMonitor('test-monitor', callback);
2135+
await expect(promise).rejects.toThrowError(error);
2136+
});
2137+
});
20932138
});

0 commit comments

Comments
 (0)