feat(nestjs): Instrument @nestjs/schedule#19735
Conversation
size-limit report 📦
|
| expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ | ||
| handled: false, | ||
| type: 'auto.cron.nestjs.async', | ||
| type: 'auto.schedule.nestjs.cron', |
There was a problem hiding this comment.
m: This would in theory break existing queries – maybe worth calling it out in the changelog
There was a problem hiding this comment.
Yes definitely. I will also update this to auto.function.nestjs.cron (and similar for the others) to align with the span ops
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: SentryCron silently loses error capture on older schedule versions
- Restored
SentryCronsync/asynccaptureExceptionfallback when schedule auto-instrumentation is absent while skipping fallback capture when an instrumented handler is detected.
- Restored
Or push these changes by commenting:
@cursor push 72745f5800
Preview (72745f5800)
diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts
--- a/packages/nestjs/src/decorators.ts
+++ b/packages/nestjs/src/decorators.ts
@@ -1,5 +1,10 @@
import type { MonitorConfig } from '@sentry/core';
-import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
+import {
+ captureException,
+ isThenable,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+} from '@sentry/core';
import * as Sentry from '@sentry/node';
import { startSpan } from '@sentry/node';
import { isExpectedError } from './helpers';
@@ -12,13 +17,32 @@
const originalMethod = descriptor.value as (...args: unknown[]) => Promise<unknown>;
descriptor.value = function (...args: unknown[]) {
- return Sentry.withMonitor(
- monitorSlug,
- () => {
- return originalMethod.apply(this, args);
- },
- monitorConfig,
- );
+ const shouldCaptureErrors = !isScheduleInstrumentationActiveForHandler(this, propertyKey);
+ let result: unknown;
+
+ try {
+ result = Sentry.withMonitor(
+ monitorSlug,
+ () => {
+ return originalMethod.apply(this, args);
+ },
+ monitorConfig,
+ );
+ } catch (error) {
+ if (shouldCaptureErrors) {
+ captureException(error, { mechanism: { handled: false, type: 'auto.function.nestjs.cron' } });
+ }
+ throw error;
+ }
+
+ if (shouldCaptureErrors && isThenable(result)) {
+ return result.then(undefined, (error: unknown) => {
+ captureException(error, { mechanism: { handled: false, type: 'auto.function.nestjs.cron' } });
+ throw error;
+ });
+ }
+
+ return result;
};
copyFunctionNameAndMetadata({ originalMethod, descriptor });
@@ -117,3 +141,12 @@
}
}
}
+
+function isScheduleInstrumentationActiveForHandler(instance: unknown, propertyKey: string | symbol): boolean {
+ if (typeof instance !== 'object' || instance === null) {
+ return false;
+ }
+
+ const handler = (instance as Record<string | symbol, unknown>)[propertyKey];
+ return typeof handler === 'function' && '__SENTRY_INSTRUMENTED__' in handler;
+}
diff --git a/packages/nestjs/test/decorators.test.ts b/packages/nestjs/test/decorators.test.ts
--- a/packages/nestjs/test/decorators.test.ts
+++ b/packages/nestjs/test/decorators.test.ts
@@ -253,6 +253,92 @@
getMetadataSpy.mockRestore();
defineMetadataSpy.mockRestore();
});
+
+ it('should capture sync errors as a fallback when schedule instrumentation is not active', () => {
+ const error = new Error('cron failed');
+ const withMonitorSpy = vi.spyOn(core, 'withMonitor').mockImplementation((_slug, callback) => {
+ return callback();
+ });
+ const captureExceptionSpy = vi.spyOn(core, 'captureException');
+
+ const originalMethod = () => {
+ throw error;
+ };
+
+ const descriptor: PropertyDescriptor = {
+ value: originalMethod,
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ };
+
+ const decoratedDescriptor = SentryCron('monitor-slug')({}, 'cronMethod', descriptor);
+ const decoratedMethod = decoratedDescriptor?.value as typeof originalMethod;
+
+ expect(() => decoratedMethod()).toThrow(error);
+ expect(withMonitorSpy).toHaveBeenCalledTimes(1);
+ expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.function.nestjs.cron',
+ },
+ });
+ });
+
+ it('should capture async errors as a fallback when schedule instrumentation is not active', async () => {
+ const error = new Error('cron failed');
+ vi.spyOn(core, 'withMonitor').mockImplementation((_slug, callback) => {
+ return callback();
+ });
+ const captureExceptionSpy = vi.spyOn(core, 'captureException');
+
+ const originalMethod = () => Promise.reject(error);
+
+ const descriptor: PropertyDescriptor = {
+ value: originalMethod,
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ };
+
+ const decoratedDescriptor = SentryCron('monitor-slug')({}, 'cronMethod', descriptor);
+ const decoratedMethod = decoratedDescriptor?.value as typeof originalMethod;
+
+ await expect(decoratedMethod()).rejects.toThrow(error);
+ expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
+ mechanism: {
+ handled: false,
+ type: 'auto.function.nestjs.cron',
+ },
+ });
+ });
+
+ it('should not capture fallback errors when schedule instrumentation is active', () => {
+ const error = new Error('cron failed');
+ vi.spyOn(core, 'withMonitor').mockImplementation((_slug, callback) => {
+ return callback();
+ });
+ const captureExceptionSpy = vi.spyOn(core, 'captureException');
+
+ const originalMethod = () => {
+ throw error;
+ };
+
+ const descriptor: PropertyDescriptor = {
+ value: originalMethod,
+ writable: true,
+ enumerable: true,
+ configurable: true,
+ };
+
+ const decoratedDescriptor = SentryCron('monitor-slug')({}, 'cronMethod', descriptor);
+ const decoratedMethod = decoratedDescriptor?.value as typeof originalMethod;
+
+ const instrumentedCronMethod = Object.assign(() => undefined, { __SENTRY_INSTRUMENTED__: true });
+
+ expect(() => decoratedMethod.call({ cronMethod: instrumentedCronMethod })).toThrow(error);
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+ });
});
describe('SentryExceptionCaptured decorator', () => {This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
| }); | ||
| } | ||
| return result; | ||
| return originalMethod.apply(this, args); |
There was a problem hiding this comment.
SentryCron silently loses error capture on older schedule versions
Medium Severity
SentryCron no longer calls captureException on errors, relying entirely on the new schedule auto-instrumentation. However, SentryNestScheduleInstrumentation only activates for @nestjs/schedule >=4.0.0, while the SDK's peer dependencies support NestJS ^8.0.0 || ^9.0.0 — which use @nestjs/schedule v2/v3. Users on those older versions who rely on @SentryCron for error capture will silently stop receiving error events, with no warning or fallback.
Additional Locations (1)
node-overhead report 🧳Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.
|



Instruments the
@Cron,@Intervaland@Timeoutdecorators from@nestjs/schedule(npm) to capture errors and fork isolation scopes to prevent leakage into subsequent http requests.So far we only had a manual
@SentryCrondecorator that users could apply to get checkins and exceptions from crons.@SentryCronis now reduced to only send check-ins if applied (no exception capture anymore since this is handled by the auto-instrumentation).Closes #19704