Skip to content

feat(nestjs): Instrument @nestjs/schedule#19735

Open
nicohrubec wants to merge 11 commits intodevelopfrom
nh/nestjs-schedule-instrumentation
Open

feat(nestjs): Instrument @nestjs/schedule#19735
nicohrubec wants to merge 11 commits intodevelopfrom
nh/nestjs-schedule-instrumentation

Conversation

@nicohrubec
Copy link
Member

@nicohrubec nicohrubec commented Mar 10, 2026

Instruments the @Cron, @Interval and @Timeout decorators 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 @SentryCron decorator that users could apply to get checkins and exceptions from crons. @SentryCron is now reduced to only send check-ins if applied (no exception capture anymore since this is handled by the auto-instrumentation).

Closes #19704

@github-actions
Copy link
Contributor

github-actions bot commented Mar 10, 2026

size-limit report 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 25.64 kB +0.05% +12 B 🔺
@sentry/browser - with treeshaking flags 24.14 kB +0.03% +7 B 🔺
@sentry/browser (incl. Tracing) 42.62 kB +0.44% +184 B 🔺
@sentry/browser (incl. Tracing, Profiling) 47.28 kB +0.4% +187 B 🔺
@sentry/browser (incl. Tracing, Replay) 81.42 kB +0.22% +171 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 71 kB +0.18% +125 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 86.12 kB +0.21% +178 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 98.37 kB +0.18% +167 B 🔺
@sentry/browser (incl. Feedback) 42.45 kB +0.03% +9 B 🔺
@sentry/browser (incl. sendFeedback) 30.31 kB +0.05% +14 B 🔺
@sentry/browser (incl. FeedbackAsync) 35.36 kB +0.04% +12 B 🔺
@sentry/browser (incl. Metrics) 26.92 kB +0.48% +126 B 🔺
@sentry/browser (incl. Logs) 27.07 kB +0.48% +128 B 🔺
@sentry/browser (incl. Metrics & Logs) 27.74 kB +0.46% +127 B 🔺
@sentry/react 27.39 kB +0.04% +9 B 🔺
@sentry/react (incl. Tracing) 44.95 kB +0.41% +180 B 🔺
@sentry/vue 30.08 kB +0.03% +7 B 🔺
@sentry/vue (incl. Tracing) 44.48 kB +0.42% +183 B 🔺
@sentry/svelte 25.66 kB +0.04% +9 B 🔺
CDN Bundle 28.27 kB +0.35% +97 B 🔺
CDN Bundle (incl. Tracing) 43.5 kB +0.55% +237 B 🔺
CDN Bundle (incl. Logs, Metrics) 29.13 kB +0.42% +121 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 44.34 kB +0.56% +243 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 68.2 kB +0.16% +107 B 🔺
CDN Bundle (incl. Tracing, Replay) 80.32 kB +0.23% +183 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 81.22 kB +0.27% +218 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 85.86 kB +0.24% +205 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 86.76 kB +0.27% +226 B 🔺
CDN Bundle - uncompressed 82.56 kB +0.26% +211 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 128.5 kB +0.34% +433 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 85.43 kB +0.29% +244 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 131.37 kB +0.36% +466 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 209.06 kB +0.11% +212 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 245.35 kB +0.17% +401 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 248.21 kB +0.18% +434 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 258.26 kB +0.16% +401 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 261.11 kB +0.17% +434 B 🔺
@sentry/nextjs (client) 47.37 kB +0.39% +184 B 🔺
@sentry/sveltekit (client) 43.07 kB +0.42% +178 B 🔺
@sentry/node-core 52.27 kB +0.06% +31 B 🔺
@sentry/node 174.76 kB +0.04% +54 B 🔺
@sentry/node - without tracing 97.43 kB +0.06% +50 B 🔺
@sentry/aws-serverless 113.23 kB +0.04% +45 B 🔺

View base workflow run

@nicohrubec nicohrubec marked this pull request as ready for review March 10, 2026 14:30
Copy link
Member

@chargome chargome left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one!

expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({
handled: false,
type: 'auto.cron.nestjs.async',
type: 'auto.schedule.nestjs.cron',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: This would in theory break existing queries – maybe worth calling it out in the changelog

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes definitely. I will also update this to auto.function.nestjs.cron (and similar for the others) to align with the span ops

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SentryCron sync/async captureException fallback when schedule auto-instrumentation is absent while skipping fallback capture when an instrumented handler is detected.

Create PR

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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

@github-actions
Copy link
Contributor

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.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 9,200 - 8,644 +6%
GET With Sentry 1,646 18% 1,616 +2%
GET With Sentry (error only) 6,011 65% 5,983 +0%
POST Baseline 1,147 - 1,171 -2%
POST With Sentry 550 48% 561 -2%
POST With Sentry (error only) 1,029 90% 1,024 +0%
MYSQL Baseline 3,186 - 3,140 +1%
MYSQL With Sentry 394 12% 400 -2%
MYSQL With Sentry (error only) 2,629 83% 2,571 +2%

View base workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add instrumentation for @nestjs/schedule

2 participants