From 573bfe88c1d5ad363efb27650997dcad565c6c6c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 13:56:26 +0100 Subject: [PATCH 01/12] feat(nestjs): Instrument @nestjs/schedule --- .../nestjs-basic/src/app.controller.ts | 21 +++++- .../nestjs-basic/src/app.module.ts | 2 + .../nestjs-basic/src/schedule.service.ts | 43 +++++++++++ .../tests/schedule-instrumentation.test.ts | 75 +++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts index 035106a14b21..3e637d8f40af 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Param, ParseIntPipe, UseFilters, UseGuards, UseInterce import { flush } from '@sentry/nestjs'; import { AppService } from './app.service'; import { AsyncInterceptor } from './async-example.interceptor'; +import { ScheduleService } from './schedule.service'; import { ExampleInterceptor1 } from './example-1.interceptor'; import { ExampleInterceptor2 } from './example-2.interceptor'; import { ExampleExceptionGlobalFilter } from './example-global-filter.exception'; @@ -12,7 +13,10 @@ import { ExampleGuard } from './example.guard'; @Controller() @UseFilters(ExampleLocalFilter) export class AppController { - constructor(private readonly appService: AppService) {} + constructor( + private readonly appService: AppService, + private readonly scheduleService: ScheduleService, + ) {} @Get('test-transaction') testTransaction() { @@ -87,6 +91,21 @@ export class AppController { this.appService.killTestCron(job); } + @Get('kill-test-schedule-cron/:name') + killTestScheduleCron(@Param('name') name: string) { + this.scheduleService.killCron(name); + } + + @Get('kill-test-schedule-interval/:name') + killTestScheduleInterval(@Param('name') name: string) { + this.scheduleService.killInterval(name); + } + + @Get('test-schedule-isolation') + testScheduleIsolation() { + return { message: 'ok' }; + } + @Get('flush') async flush() { await flush(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts index 3de3c82dc925..7393e9b438c2 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts @@ -6,12 +6,14 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ExampleGlobalFilter } from './example-global.filter'; import { ExampleMiddleware } from './example.middleware'; +import { ScheduleService } from './schedule.service'; @Module({ imports: [SentryModule.forRoot(), ScheduleModule.forRoot()], controllers: [AppController], providers: [ AppService, + ScheduleService, { provide: APP_FILTER, useClass: SentryGlobalFilter, diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts new file mode 100644 index 000000000000..ff8c1e71c870 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, Interval, SchedulerRegistry, Timeout } from '@nestjs/schedule'; +import * as Sentry from '@sentry/nestjs'; + +@Injectable() +export class ScheduleService { + constructor(private schedulerRegistry: SchedulerRegistry) {} + + // --- @Cron error test (auto-instrumentation, no @SentryCron) --- + @Cron('*/5 * * * * *', { name: 'test-schedule-cron-error' }) + async handleCronError() { + throw new Error('Test error from schedule cron'); + } + + // --- @Interval error test --- + @Interval('test-schedule-interval-error', 2000) + async handleIntervalError() { + throw new Error('Test error from schedule interval'); + } + + // --- @Timeout error test --- + @Timeout('test-schedule-timeout-error', 500) + async handleTimeoutError() { + throw new Error('Test error from schedule timeout'); + } + + // --- Isolation scope test: adds breadcrumb that should NOT leak to HTTP requests --- + @Interval('test-schedule-isolation', 2000) + handleIsolationBreadcrumb() { + Sentry.addBreadcrumb({ + message: 'leaked-breadcrumb-from-schedule', + level: 'info', + }); + } + + killCron(name: string) { + this.schedulerRegistry.deleteCronJob(name); + } + + killInterval(name: string) { + this.schedulerRegistry.deleteInterval(name); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts new file mode 100644 index 000000000000..39d563b85d53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ baseURL }) => { + const errorEventPromise = waitForError('nestjs-basic', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule cron'; + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.schedule.nestjs.cron', + }); + + // kill cron so tests don't get stuck + await fetch(`${baseURL}/kill-test-schedule-cron/test-schedule-cron-error`); +}); + +test('Sends exceptions to Sentry on error in @Interval decorated method', async ({ baseURL }) => { + const errorEventPromise = waitForError('nestjs-basic', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule interval'; + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.schedule.nestjs.interval', + }); + + // kill interval so tests don't get stuck + await fetch(`${baseURL}/kill-test-schedule-interval/test-schedule-interval-error`); +}); + +test('Sends exceptions to Sentry on error in @Timeout decorated method', async () => { + const errorEventPromise = waitForError('nestjs-basic', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule timeout'; + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.schedule.nestjs.timeout', + }); +}); + +test('Scheduled task breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => { + // The app runs @Interval('test-schedule-isolation', 2000) which adds a breadcrumb. + // Without isolation scope forking, this breadcrumb leaks into the default isolation scope + // and gets cloned into subsequent HTTP requests. + + // Wait for at least one interval tick to fire + await new Promise(resolve => setTimeout(resolve, 3000)); + + const transactionPromise = waitForTransaction('nestjs-basic', transactionEvent => { + return transactionEvent.transaction === 'GET /test-schedule-isolation'; + }); + + await fetch(`${baseURL}/test-schedule-isolation`); + + const transaction = await transactionPromise; + + const leakedBreadcrumb = (transaction.breadcrumbs || []).find( + (b: any) => b.message === 'leaked-breadcrumb-from-schedule', + ); + expect(leakedBreadcrumb).toBeUndefined(); + + // kill interval so tests don't get stuck + await fetch(`${baseURL}/kill-test-schedule-interval/test-schedule-isolation`); +}); From 0908a8d3048f1ac8e597a30555d078f34efd8652 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 14:12:01 +0100 Subject: [PATCH 02/12] feat(nestjs): Instrument @nestjs/schedule --- .../nestjs-basic/src/app.controller.ts | 8 + .../nestjs-basic/src/schedule.service.ts | 4 +- .../nestjs-basic/tests/cron-decorator.test.ts | 12 +- .../tests/schedule-instrumentation.test.ts | 14 +- packages/nestjs/src/integrations/nest.ts | 6 + .../sentry-nest-schedule-instrumentation.ts | 171 ++++++++++++++++++ packages/nestjs/src/integrations/types.ts | 9 + .../nestjs/test/integrations/schedule.test.ts | 139 ++++++++++++++ 8 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts create mode 100644 packages/nestjs/test/integrations/schedule.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts index 3e637d8f40af..2e6301ba5eef 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts @@ -106,6 +106,14 @@ export class AppController { return { message: 'ok' }; } + @Get('trigger-schedule-timeout-error') + async triggerScheduleTimeoutError() { + // Manually calls the @Timeout-decorated method to test instrumentation + // without relying on NestJS scheduler timing. + await this.scheduleService.handleTimeoutError(); + return { message: 'triggered' }; + } + @Get('flush') async flush() { await flush(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts index ff8c1e71c870..75b32a9c10c5 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -19,7 +19,9 @@ export class ScheduleService { } // --- @Timeout error test --- - @Timeout('test-schedule-timeout-error', 500) + // Use a very long delay so this doesn't fire on its own during tests. + // The test triggers the method via an HTTP endpoint instead. + @Timeout('test-schedule-timeout-error', 60000) async handleTimeoutError() { throw new Error('Test error from schedule timeout'); } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts index e0610f36c676..5942f44bf36e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts @@ -64,7 +64,11 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => { test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-basic', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron async job'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from cron async job' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.cron.nestjs.async' + ); }); const errorEvent = await errorEventPromise; @@ -86,7 +90,11 @@ test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL } test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-basic', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron sync job'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from cron sync job' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.cron.nestjs' + ); }); const errorEvent = await errorEventPromise; diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts index 39d563b85d53..f5037e52544f 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -35,9 +35,19 @@ test('Sends exceptions to Sentry on error in @Interval decorated method', async await fetch(`${baseURL}/kill-test-schedule-interval/test-schedule-interval-error`); }); -test('Sends exceptions to Sentry on error in @Timeout decorated method', async () => { +test('Sends exceptions to Sentry on error in @Timeout decorated method', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-basic', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule timeout'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from schedule timeout' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.timeout' + ); + }); + + // Trigger the @Timeout-decorated method via HTTP endpoint since @Timeout + // fires once and timing is unreliable across test runs. + fetch(`${baseURL}/trigger-schedule-timeout-error`).catch(() => { + // Expected to fail since the handler throws }); const errorEvent = await errorEventPromise; diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts index 75dc1f845693..7534ba7aef03 100644 --- a/packages/nestjs/src/integrations/nest.ts +++ b/packages/nestjs/src/integrations/nest.ts @@ -3,6 +3,7 @@ import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node'; import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation'; import { SentryNestInstrumentation } from './sentry-nest-instrumentation'; +import { SentryNestScheduleInstrumentation } from './sentry-nest-schedule-instrumentation'; const INTEGRATION_NAME = 'Nest'; @@ -18,11 +19,16 @@ const instrumentNestEvent = generateInstrumentOnce(`${INTEGRATION_NAME}.Event`, return new SentryNestEventInstrumentation(); }); +const instrumentNestSchedule = generateInstrumentOnce(`${INTEGRATION_NAME}.Schedule`, () => { + return new SentryNestScheduleInstrumentation(); +}); + export const instrumentNest = Object.assign( (): void => { instrumentNestCore(); instrumentNestCommon(); instrumentNestEvent(); + instrumentNestSchedule(); }, { id: INTEGRATION_NAME }, ); diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts new file mode 100644 index 000000000000..606e27181c04 --- /dev/null +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -0,0 +1,171 @@ +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, +} from '@opentelemetry/instrumentation'; +import { captureException, isThenable, SDK_VERSION, withIsolationScope } from '@sentry/core'; +import type { ScheduleDecoratorTarget } from './types'; + +const supportedVersions = ['>=4.0.0']; +const COMPONENT = '@nestjs/schedule'; + +/** + * Custom instrumentation for nestjs schedule module. + * + * This hooks into the `@Cron`, `@Interval`, and `@Timeout` decorators, which are applied on scheduled task handlers. + * It forks the isolation scope for each handler invocation, preventing data leakage to subsequent HTTP requests. + */ +export class SentryNestScheduleInstrumentation extends InstrumentationBase { + public constructor(config: InstrumentationConfig = {}) { + super('sentry-nestjs-schedule', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + */ + public init(): InstrumentationNodeModuleDefinition { + const moduleDef = new InstrumentationNodeModuleDefinition(COMPONENT, supportedVersions); + + moduleDef.files.push(this._getCronFileInstrumentation(supportedVersions)); + moduleDef.files.push(this._getIntervalFileInstrumentation(supportedVersions)); + moduleDef.files.push(this._getTimeoutFileInstrumentation(supportedVersions)); + return moduleDef; + } + + /** + * Wraps the @Cron decorator. + */ + private _getCronFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + return new InstrumentationNodeModuleFile( + '@nestjs/schedule/dist/decorators/cron.decorator.js', + versions, + (moduleExports: { Cron: ScheduleDecoratorTarget }) => { + if (isWrapped(moduleExports.Cron)) { + this._unwrap(moduleExports, 'Cron'); + } + this._wrap(moduleExports, 'Cron', this._createWrapDecorator('auto.schedule.nestjs.cron')); + return moduleExports; + }, + (moduleExports: { Cron: ScheduleDecoratorTarget }) => { + this._unwrap(moduleExports, 'Cron'); + }, + ); + } + + /** + * Wraps the @Interval decorator. + */ + private _getIntervalFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + return new InstrumentationNodeModuleFile( + '@nestjs/schedule/dist/decorators/interval.decorator.js', + versions, + (moduleExports: { Interval: ScheduleDecoratorTarget }) => { + if (isWrapped(moduleExports.Interval)) { + this._unwrap(moduleExports, 'Interval'); + } + this._wrap(moduleExports, 'Interval', this._createWrapDecorator('auto.schedule.nestjs.interval')); + return moduleExports; + }, + (moduleExports: { Interval: ScheduleDecoratorTarget }) => { + this._unwrap(moduleExports, 'Interval'); + }, + ); + } + + /** + * Wraps the @Timeout decorator. + */ + private _getTimeoutFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + return new InstrumentationNodeModuleFile( + '@nestjs/schedule/dist/decorators/timeout.decorator.js', + versions, + (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { + if (isWrapped(moduleExports.Timeout)) { + this._unwrap(moduleExports, 'Timeout'); + } + this._wrap(moduleExports, 'Timeout', this._createWrapDecorator('auto.schedule.nestjs.timeout')); + return moduleExports; + }, + (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { + this._unwrap(moduleExports, 'Timeout'); + }, + ); + } + + /** + * Creates a wrapper function for a schedule decorator (@Cron, @Interval, or @Timeout). + */ + private _createWrapDecorator(mechanismType: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function wrapDecorator(original: any) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function wrappedDecorator(...decoratorArgs: any[]) { + // Get the original decorator result + const decoratorResult = original(...decoratorArgs); + + // Return a new decorator function that wraps the handler + return (target: ScheduleDecoratorTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + if ( + !descriptor.value || + typeof descriptor.value !== 'function' || + target.__SENTRY_INTERNAL__ || + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + descriptor.value.__SENTRY_INSTRUMENTED__ + ) { + return decoratorResult(target, propertyKey, descriptor); + } + + const originalHandler = descriptor.value; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const handlerName = originalHandler.name || propertyKey; + + // Wrap the handler with isolation scope and error capture + descriptor.value = function (...args: unknown[]) { + return withIsolationScope(() => { + let result; + try { + result = originalHandler.apply(this, args); + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + throw error; + } + + if (isThenable(result)) { + return result.then(undefined, (error: unknown) => { + captureException(error, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + throw error; + }); + } + + return result; + }); + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + descriptor.value.__SENTRY_INSTRUMENTED__ = true; + + // Preserve the original function name + Object.defineProperty(descriptor.value, 'name', { + value: handlerName, + configurable: true, + }); + + // Apply the original decorator + return decoratorResult(target, propertyKey, descriptor); + }; + }; + }; + } +} diff --git a/packages/nestjs/src/integrations/types.ts b/packages/nestjs/src/integrations/types.ts index 8283e652edfb..88ab09c913e8 100644 --- a/packages/nestjs/src/integrations/types.ts +++ b/packages/nestjs/src/integrations/types.ts @@ -95,6 +95,15 @@ export interface OnEventTarget { __SENTRY_INTERNAL__?: boolean; } +/** + * Represents a target method in NestJS annotated with @Cron, @Interval, or @Timeout. + */ +export interface ScheduleDecoratorTarget { + name: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; +} + /** * Represents an express NextFunction. */ diff --git a/packages/nestjs/test/integrations/schedule.test.ts b/packages/nestjs/test/integrations/schedule.test.ts new file mode 100644 index 000000000000..c57dfb502517 --- /dev/null +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -0,0 +1,139 @@ +import 'reflect-metadata'; +import * as core from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SentryNestScheduleInstrumentation } from '../../src/integrations/sentry-nest-schedule-instrumentation'; +import type { ScheduleDecoratorTarget } from '../../src/integrations/types'; + +describe('ScheduleInstrumentation', () => { + let instrumentation: SentryNestScheduleInstrumentation; + let mockTarget: ScheduleDecoratorTarget; + + beforeEach(() => { + instrumentation = new SentryNestScheduleInstrumentation(); + mockTarget = { + name: 'TestClass', + } as ScheduleDecoratorTarget; + vi.spyOn(core, 'captureException'); + vi.spyOn(core, 'withIsolationScope').mockImplementation(callback => { + return (callback as () => unknown)(); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('init()', () => { + it('should return module definition with correct component name', () => { + const moduleDef = instrumentation.init(); + expect(moduleDef.name).toBe('@nestjs/schedule'); + }); + + it('should have three file instrumentations', () => { + const moduleDef = instrumentation.init(); + expect(moduleDef.files).toHaveLength(3); + }); + }); + + describe.each([ + { decoratorName: 'Cron', fileIndex: 0, mechanismType: 'auto.schedule.nestjs.cron' }, + { decoratorName: 'Interval', fileIndex: 1, mechanismType: 'auto.schedule.nestjs.interval' }, + { decoratorName: 'Timeout', fileIndex: 2, mechanismType: 'auto.schedule.nestjs.timeout' }, + ])('$decoratorName decorator wrapping', ({ decoratorName, fileIndex, mechanismType }) => { + let wrappedDecorator: any; + let descriptor: PropertyDescriptor; + let originalHandler: vi.Mock; + let mockDecorator: vi.Mock; + + beforeEach(() => { + originalHandler = vi.fn().mockReturnValue('result'); + descriptor = { + value: originalHandler, + }; + + mockDecorator = vi.fn().mockImplementation(() => { + return (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) => { + return descriptor; + }; + }); + + const moduleDef = instrumentation.init(); + const file = moduleDef.files[fileIndex]; + const moduleExports = { [decoratorName]: mockDecorator }; + file?.patch(moduleExports); + wrappedDecorator = moduleExports[decoratorName]; + }); + + it('should call withIsolationScope on handler execution', () => { + const decorated = wrappedDecorator('test-arg'); + decorated(mockTarget, 'testMethod', descriptor); + + descriptor.value(); + + expect(core.withIsolationScope).toHaveBeenCalled(); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should capture sync exceptions and rethrow', () => { + const error = new Error('Test error'); + originalHandler.mockImplementation(() => { + throw error; + }); + + const decorated = wrappedDecorator('test-arg'); + decorated(mockTarget, 'testMethod', descriptor); + + expect(() => descriptor.value()).toThrow(error); + expect(core.captureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + }); + + it('should capture async exceptions and rethrow', async () => { + const error = new Error('Test error'); + originalHandler.mockReturnValue(Promise.reject(error)); + + const decorated = wrappedDecorator('test-arg'); + decorated(mockTarget, 'testMethod', descriptor); + + await expect(descriptor.value()).rejects.toThrow(error); + expect(core.captureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + }); + + it('should skip wrapping for internal Sentry handlers', () => { + const internalTarget = { + ...mockTarget, + __SENTRY_INTERNAL__: true, + }; + + const decorated = wrappedDecorator('test-arg'); + decorated(internalTarget, 'testMethod', descriptor); + + expect(descriptor.value).toBe(originalHandler); + }); + + it('should skip wrapping if already instrumented', () => { + originalHandler.__SENTRY_INSTRUMENTED__ = true; + + const decorated = wrappedDecorator('test-arg'); + decorated(mockTarget, 'testMethod', descriptor); + + expect(descriptor.value).toBe(originalHandler); + }); + + it('should preserve the original function name', () => { + const decorated = wrappedDecorator('test-arg'); + decorated(mockTarget, 'testMethod', descriptor); + + expect(descriptor.value.name).toBe('spy'); + }); + }); +}); From b33344e3146dc6b73e4b235d41337e4341b0ae1e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 14:30:43 +0100 Subject: [PATCH 03/12] updates --- .../nestjs-basic/src/schedule.service.ts | 2 +- .../tests/schedule-instrumentation.test.ts | 14 +++++++++++--- .../sentry-nest-schedule-instrumentation.ts | 2 ++ packages/nestjs/test/integrations/schedule.test.ts | 6 ++++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts index 75b32a9c10c5..10046feccd5b 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -8,7 +8,7 @@ export class ScheduleService { // --- @Cron error test (auto-instrumentation, no @SentryCron) --- @Cron('*/5 * * * * *', { name: 'test-schedule-cron-error' }) - async handleCronError() { + handleCronError() { throw new Error('Test error from schedule cron'); } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts index f5037e52544f..aae906b39384 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -3,7 +3,11 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-basic', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule cron'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from schedule cron' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + ); }); const errorEvent = await errorEventPromise; @@ -20,7 +24,11 @@ test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ b test('Sends exceptions to Sentry on error in @Interval decorated method', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-basic', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule interval'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from schedule interval' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.interval' + ); }); const errorEvent = await errorEventPromise; @@ -46,7 +54,7 @@ test('Sends exceptions to Sentry on error in @Timeout decorated method', async ( // Trigger the @Timeout-decorated method via HTTP endpoint since @Timeout // fires once and timing is unreliable across test runs. - fetch(`${baseURL}/trigger-schedule-timeout-error`).catch(() => { + await fetch(`${baseURL}/trigger-schedule-timeout-error`).catch(() => { // Expected to fail since the handler throws }); diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index 606e27181c04..e9c37f9f4b53 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -160,6 +160,8 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { Object.defineProperty(descriptor.value, 'name', { value: handlerName, configurable: true, + enumerable: true, + writable: true, }); // Apply the original decorator diff --git a/packages/nestjs/test/integrations/schedule.test.ts b/packages/nestjs/test/integrations/schedule.test.ts index c57dfb502517..528feb401b11 100644 --- a/packages/nestjs/test/integrations/schedule.test.ts +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -46,7 +46,9 @@ describe('ScheduleInstrumentation', () => { let mockDecorator: vi.Mock; beforeEach(() => { - originalHandler = vi.fn().mockReturnValue('result'); + originalHandler = vi.fn(function testHandler() { + return 'result'; + }); descriptor = { value: originalHandler, }; @@ -133,7 +135,7 @@ describe('ScheduleInstrumentation', () => { const decorated = wrappedDecorator('test-arg'); decorated(mockTarget, 'testMethod', descriptor); - expect(descriptor.value.name).toBe('spy'); + expect(descriptor.value.name).toBe('testHandler'); }); }); }); From 6c8438b2371cb334811f9d54db70bdefe44f4167 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 14:48:28 +0100 Subject: [PATCH 04/12] cleanup --- .../test-applications/nestjs-basic/src/app.controller.ts | 1 + .../nestjs-basic/src/schedule.service.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts index 2e6301ba5eef..6186c26cc65c 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.controller.ts @@ -110,6 +110,7 @@ export class AppController { async triggerScheduleTimeoutError() { // Manually calls the @Timeout-decorated method to test instrumentation // without relying on NestJS scheduler timing. + // Without this, it's hard to get the timing right for the test. await this.scheduleService.handleTimeoutError(); return { message: 'triggered' }; } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts index 10046feccd5b..38b56136ab20 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -6,19 +6,19 @@ import * as Sentry from '@sentry/nestjs'; export class ScheduleService { constructor(private schedulerRegistry: SchedulerRegistry) {} - // --- @Cron error test (auto-instrumentation, no @SentryCron) --- + // @Cron error test (auto-instrumentation, no @SentryCron) @Cron('*/5 * * * * *', { name: 'test-schedule-cron-error' }) handleCronError() { throw new Error('Test error from schedule cron'); } - // --- @Interval error test --- + // @Interval error test @Interval('test-schedule-interval-error', 2000) async handleIntervalError() { throw new Error('Test error from schedule interval'); } - // --- @Timeout error test --- + // @Timeout error test // Use a very long delay so this doesn't fire on its own during tests. // The test triggers the method via an HTTP endpoint instead. @Timeout('test-schedule-timeout-error', 60000) @@ -26,7 +26,7 @@ export class ScheduleService { throw new Error('Test error from schedule timeout'); } - // --- Isolation scope test: adds breadcrumb that should NOT leak to HTTP requests --- + // Isolation scope test: adds breadcrumb that should NOT leak to HTTP requests @Interval('test-schedule-isolation', 2000) handleIsolationBreadcrumb() { Sentry.addBreadcrumb({ From da1d4a71328e1243a3d5690e8271a489ee55b5de Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 14:54:05 +0100 Subject: [PATCH 05/12] Do not capture exceptions from sentrycron --- .../nestjs-basic/tests/cron-decorator.test.ts | 8 +++---- packages/nestjs/src/decorators.ts | 22 ++----------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts index 5942f44bf36e..a204d0c1d043 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts @@ -67,7 +67,7 @@ test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL } return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from cron async job' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.cron.nestjs.async' + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' ); }); @@ -81,7 +81,7 @@ test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL } expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.cron.nestjs.async', + type: 'auto.schedule.nestjs.cron', }); // kill cron so tests don't get stuck @@ -93,7 +93,7 @@ test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from cron sync job' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.cron.nestjs' + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' ); }); @@ -107,7 +107,7 @@ test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.cron.nestjs', + type: 'auto.schedule.nestjs.cron', }); // kill cron so tests don't get stuck diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 8f1a7151894f..53e7bea05866 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -1,10 +1,5 @@ import type { MonitorConfig } from '@sentry/core'; -import { - captureException, - isThenable, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, -} from '@sentry/core'; +import { captureException, 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'; @@ -20,20 +15,7 @@ export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): return Sentry.withMonitor( monitorSlug, () => { - let result; - try { - result = originalMethod.apply(this, args); - } catch (e) { - captureException(e, { mechanism: { handled: false, type: 'auto.cron.nestjs' } }); - throw e; - } - if (isThenable(result)) { - return result.then(undefined, e => { - captureException(e, { mechanism: { handled: false, type: 'auto.cron.nestjs.async' } }); - throw e; - }); - } - return result; + return originalMethod.apply(this, args); }, monitorConfig, ); From d57b9c4e8d7bdd10abbd47af90562e4e6f8751f3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 15:09:03 +0100 Subject: [PATCH 06/12] remove useless unit tests and add changelog --- CHANGELOG.md | 4 ++++ .../nestjs/test/integrations/schedule.test.ts | 18 ------------------ 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1aa175f9bc..07a6c703a07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(nestjs): Instrument `@nestjs/schedule` decorators ([#19735](https://github.com/getsentry/sentry-javascript/pull/19735))** + + Automatically capture exceptions thrown in `@Cron`, `@Interval`, and `@Timeout` decorated methods. + ## 10.43.0 ### Important Changes diff --git a/packages/nestjs/test/integrations/schedule.test.ts b/packages/nestjs/test/integrations/schedule.test.ts index 528feb401b11..e0037da4087b 100644 --- a/packages/nestjs/test/integrations/schedule.test.ts +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -23,18 +23,6 @@ describe('ScheduleInstrumentation', () => { vi.restoreAllMocks(); }); - describe('init()', () => { - it('should return module definition with correct component name', () => { - const moduleDef = instrumentation.init(); - expect(moduleDef.name).toBe('@nestjs/schedule'); - }); - - it('should have three file instrumentations', () => { - const moduleDef = instrumentation.init(); - expect(moduleDef.files).toHaveLength(3); - }); - }); - describe.each([ { decoratorName: 'Cron', fileIndex: 0, mechanismType: 'auto.schedule.nestjs.cron' }, { decoratorName: 'Interval', fileIndex: 1, mechanismType: 'auto.schedule.nestjs.interval' }, @@ -131,11 +119,5 @@ describe('ScheduleInstrumentation', () => { expect(descriptor.value).toBe(originalHandler); }); - it('should preserve the original function name', () => { - const decorated = wrappedDecorator('test-arg'); - decorated(mockTarget, 'testMethod', descriptor); - - expect(descriptor.value.name).toBe('testHandler'); - }); }); }); From 7d4a5bdb718c8b911ec47dac2d3ec0fa345a88c7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 15:12:36 +0100 Subject: [PATCH 07/12] . --- packages/nestjs/test/integrations/schedule.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/nestjs/test/integrations/schedule.test.ts b/packages/nestjs/test/integrations/schedule.test.ts index e0037da4087b..77b3476c927e 100644 --- a/packages/nestjs/test/integrations/schedule.test.ts +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -110,14 +110,5 @@ describe('ScheduleInstrumentation', () => { expect(descriptor.value).toBe(originalHandler); }); - it('should skip wrapping if already instrumented', () => { - originalHandler.__SENTRY_INSTRUMENTED__ = true; - - const decorated = wrappedDecorator('test-arg'); - decorated(mockTarget, 'testMethod', descriptor); - - expect(descriptor.value).toBe(originalHandler); - }); - }); }); From c55868217678ef4f38e2071afc2e373138899931 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 15:28:10 +0100 Subject: [PATCH 08/12] . --- .../src/integrations/sentry-nest-schedule-instrumentation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index e9c37f9f4b53..4b3b6a1c6df6 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -121,11 +121,13 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const handlerName = originalHandler.name || propertyKey; - // Wrap the handler with isolation scope and error capture + // Not using async/await here to avoid changing the return type of sync handlers. + // This means we need to handle sync and async errors separately. descriptor.value = function (...args: unknown[]) { return withIsolationScope(() => { let result; try { + // Catches errors from sync handlers result = originalHandler.apply(this, args); } catch (error) { captureException(error, { @@ -137,6 +139,7 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { throw error; } + // Catches errors from async handlers (rejected promises bypass try/catch) if (isThenable(result)) { return result.then(undefined, (error: unknown) => { captureException(error, { From 21ab9a7a3e1d78a40c35f37d75c1c484dce8af8d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 10 Mar 2026 15:53:37 +0100 Subject: [PATCH 09/12] fix tests --- .../nestjs-11/tests/cron-decorator.test.ts | 8 ++++++-- .../nestjs-fastify/tests/cron-decorator.test.ts | 8 ++++++-- packages/nestjs/test/integrations/schedule.test.ts | 1 - 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts index bf5e29004066..fdb58c34ef98 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts @@ -64,7 +64,11 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => { test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-11', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from cron job' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + ); }); const errorEvent = await errorEventPromise; @@ -73,7 +77,7 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job'); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.cron.nestjs.async', + type: 'auto.schedule.nestjs.cron', }); expect(errorEvent.contexts?.trace).toEqual({ diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts index 1e9d62c2c96a..ee75e23284a3 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts @@ -64,7 +64,11 @@ test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => { test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { const errorEventPromise = waitForError('nestjs-fastify', event => { - return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job'; + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from cron job' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + ); }); const errorEvent = await errorEventPromise; @@ -73,7 +77,7 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job'); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.cron.nestjs.async', + type: 'auto.schedule.nestjs.cron', }); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/packages/nestjs/test/integrations/schedule.test.ts b/packages/nestjs/test/integrations/schedule.test.ts index 77b3476c927e..c8403984dfb9 100644 --- a/packages/nestjs/test/integrations/schedule.test.ts +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -109,6 +109,5 @@ describe('ScheduleInstrumentation', () => { expect(descriptor.value).toBe(originalHandler); }); - }); }); From 15bacc95abff35ea9f9c5f204fbb1d9197ee9fcc Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 11 Mar 2026 08:17:56 +0100 Subject: [PATCH 10/12] update mechanism type and changelog --- CHANGELOG.md | 3 +++ .../nestjs-11/tests/cron-decorator.test.ts | 4 ++-- .../nestjs-basic/tests/cron-decorator.test.ts | 8 ++++---- .../tests/schedule-instrumentation.test.ts | 12 ++++++------ .../nestjs-fastify/tests/cron-decorator.test.ts | 4 ++-- .../sentry-nest-schedule-instrumentation.ts | 6 +++--- packages/nestjs/test/integrations/schedule.test.ts | 6 +++--- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a6c703a07f..af27abe3117b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Automatically capture exceptions thrown in `@Cron`, `@Interval`, and `@Timeout` decorated methods. + The exception mechanism type for `@Cron` errors changed from `auto.cron.nestjs.async` to `auto.function.nestjs.cron`. + If you have Sentry queries or alerts that filter on the old mechanism type, update them accordingly. + ## 10.43.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts index fdb58c34ef98..18f084800fcf 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts @@ -67,7 +67,7 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -77,7 +77,7 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job'); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.cron', + type: 'auto.function.nestjs.cron', }); expect(errorEvent.contexts?.trace).toEqual({ diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts index a204d0c1d043..6aeeae723a64 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts @@ -67,7 +67,7 @@ test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL } return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from cron async job' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -81,7 +81,7 @@ test('Sends exceptions to Sentry on error in async cron job', async ({ baseURL } expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.cron', + type: 'auto.function.nestjs.cron', }); // kill cron so tests don't get stuck @@ -93,7 +93,7 @@ test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from cron sync job' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -107,7 +107,7 @@ test('Sends exceptions to Sentry on error in sync cron job', async ({ baseURL }) expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.cron', + type: 'auto.function.nestjs.cron', }); // kill cron so tests don't get stuck diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts index aae906b39384..cffca36be97e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -6,7 +6,7 @@ test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ b return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule cron' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -15,7 +15,7 @@ test('Sends exceptions to Sentry on error in @Cron decorated method', async ({ b expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.cron', + type: 'auto.function.nestjs.cron', }); // kill cron so tests don't get stuck @@ -27,7 +27,7 @@ test('Sends exceptions to Sentry on error in @Interval decorated method', async return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule interval' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.interval' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.interval' ); }); @@ -36,7 +36,7 @@ test('Sends exceptions to Sentry on error in @Interval decorated method', async expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.interval', + type: 'auto.function.nestjs.interval', }); // kill interval so tests don't get stuck @@ -48,7 +48,7 @@ test('Sends exceptions to Sentry on error in @Timeout decorated method', async ( return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from schedule timeout' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.timeout' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.timeout' ); }); @@ -63,7 +63,7 @@ test('Sends exceptions to Sentry on error in @Timeout decorated method', async ( expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.timeout', + type: 'auto.function.nestjs.timeout', }); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts index ee75e23284a3..f00beed5c9b2 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/cron-decorator.test.ts @@ -67,7 +67,7 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { return ( !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.schedule.nestjs.cron' + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' ); }); @@ -77,7 +77,7 @@ test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => { expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job'); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, - type: 'auto.schedule.nestjs.cron', + type: 'auto.function.nestjs.cron', }); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index 4b3b6a1c6df6..bb1912f3a7a9 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -45,7 +45,7 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { if (isWrapped(moduleExports.Cron)) { this._unwrap(moduleExports, 'Cron'); } - this._wrap(moduleExports, 'Cron', this._createWrapDecorator('auto.schedule.nestjs.cron')); + this._wrap(moduleExports, 'Cron', this._createWrapDecorator('auto.function.nestjs.cron')); return moduleExports; }, (moduleExports: { Cron: ScheduleDecoratorTarget }) => { @@ -65,7 +65,7 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { if (isWrapped(moduleExports.Interval)) { this._unwrap(moduleExports, 'Interval'); } - this._wrap(moduleExports, 'Interval', this._createWrapDecorator('auto.schedule.nestjs.interval')); + this._wrap(moduleExports, 'Interval', this._createWrapDecorator('auto.function.nestjs.interval')); return moduleExports; }, (moduleExports: { Interval: ScheduleDecoratorTarget }) => { @@ -85,7 +85,7 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { if (isWrapped(moduleExports.Timeout)) { this._unwrap(moduleExports, 'Timeout'); } - this._wrap(moduleExports, 'Timeout', this._createWrapDecorator('auto.schedule.nestjs.timeout')); + this._wrap(moduleExports, 'Timeout', this._createWrapDecorator('auto.function.nestjs.timeout')); return moduleExports; }, (moduleExports: { Timeout: ScheduleDecoratorTarget }) => { diff --git a/packages/nestjs/test/integrations/schedule.test.ts b/packages/nestjs/test/integrations/schedule.test.ts index c8403984dfb9..3694499c1919 100644 --- a/packages/nestjs/test/integrations/schedule.test.ts +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -24,9 +24,9 @@ describe('ScheduleInstrumentation', () => { }); describe.each([ - { decoratorName: 'Cron', fileIndex: 0, mechanismType: 'auto.schedule.nestjs.cron' }, - { decoratorName: 'Interval', fileIndex: 1, mechanismType: 'auto.schedule.nestjs.interval' }, - { decoratorName: 'Timeout', fileIndex: 2, mechanismType: 'auto.schedule.nestjs.timeout' }, + { decoratorName: 'Cron', fileIndex: 0, mechanismType: 'auto.function.nestjs.cron' }, + { decoratorName: 'Interval', fileIndex: 1, mechanismType: 'auto.function.nestjs.interval' }, + { decoratorName: 'Timeout', fileIndex: 2, mechanismType: 'auto.function.nestjs.timeout' }, ])('$decoratorName decorator wrapping', ({ decoratorName, fileIndex, mechanismType }) => { let wrappedDecorator: any; let descriptor: PropertyDescriptor; From 6fe21d321a1e9a249b8da840fffb414f1e7a7ca7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 11 Mar 2026 08:21:03 +0100 Subject: [PATCH 11/12] update --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af27abe3117b..cfe21fe38ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ Automatically capture exceptions thrown in `@Cron`, `@Interval`, and `@Timeout` decorated methods. - The exception mechanism type for `@Cron` errors changed from `auto.cron.nestjs.async` to `auto.function.nestjs.cron`. - If you have Sentry queries or alerts that filter on the old mechanism type, update them accordingly. + Previously, exceptions in `@Cron` methods were only captured if you used the `SentryCron` decorator. Now they are + captured automatically. The exception mechanism type changed from `auto.cron.nestjs.async` to + `auto.function.nestjs.cron`. If you have Sentry queries or alerts that filter on the old mechanism type, update them + accordingly. ## 10.43.0 From 0d33b0f9f70b90374c4448a98151dba1353b502b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 11 Mar 2026 08:51:36 +0100 Subject: [PATCH 12/12] try to fix lint and support v23 --- .../integrations/sentry-nest-schedule-instrumentation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts index bb1912f3a7a9..ea0261164f9c 100644 --- a/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -8,7 +8,7 @@ import { import { captureException, isThenable, SDK_VERSION, withIsolationScope } from '@sentry/core'; import type { ScheduleDecoratorTarget } from './types'; -const supportedVersions = ['>=4.0.0']; +const supportedVersions = ['>=2.0.0']; const COMPONENT = '@nestjs/schedule'; /** @@ -117,8 +117,8 @@ export class SentryNestScheduleInstrumentation extends InstrumentationBase { return decoratorResult(target, propertyKey, descriptor); } - const originalHandler = descriptor.value; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const originalHandler: (...handlerArgs: unknown[]) => unknown = descriptor.value; const handlerName = originalHandler.name || propertyKey; // Not using async/await here to avoid changing the return type of sync handlers.