diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1aa175f9bc..cfe21fe38ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ - "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. + + 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 ### 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 bf5e29004066..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 @@ -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.function.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.function.nestjs.cron', }); expect(errorEvent.contexts?.trace).toEqual({ 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..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 @@ -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,30 @@ 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('trigger-schedule-timeout-error') + 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' }; + } + @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..38b56136ab20 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/schedule.service.ts @@ -0,0 +1,45 @@ +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' }) + 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 + // 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'); + } + + // 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/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/cron-decorator.test.ts index e0610f36c676..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 @@ -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.function.nestjs.cron' + ); }); const errorEvent = await errorEventPromise; @@ -77,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.function.nestjs.cron', }); // kill cron so tests don't get stuck @@ -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.function.nestjs.cron' + ); }); const errorEvent = await errorEventPromise; @@ -99,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.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 new file mode 100644 index 000000000000..cffca36be97e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/schedule-instrumentation.test.ts @@ -0,0 +1,93 @@ +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' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.cron' + ); + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.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' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.interval' + ); + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.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 ({ baseURL }) => { + const errorEventPromise = waitForError('nestjs-basic', event => { + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from schedule timeout' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.function.nestjs.timeout' + ); + }); + + // Trigger the @Timeout-decorated method via HTTP endpoint since @Timeout + // fires once and timing is unreliable across test runs. + await fetch(`${baseURL}/trigger-schedule-timeout-error`).catch(() => { + // Expected to fail since the handler throws + }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.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`); +}); 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..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 @@ -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.function.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.function.nestjs.cron', }); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.stringMatching(/[a-f0-9]{32}/), 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, ); 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..ea0261164f9c --- /dev/null +++ b/packages/nestjs/src/integrations/sentry-nest-schedule-instrumentation.ts @@ -0,0 +1,176 @@ +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 = ['>=2.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.function.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.function.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.function.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); + } + + // 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. + // 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, { + mechanism: { + handled: false, + type: mechanismType, + }, + }); + throw error; + } + + // Catches errors from async handlers (rejected promises bypass try/catch) + 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, + enumerable: true, + writable: 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..3694499c1919 --- /dev/null +++ b/packages/nestjs/test/integrations/schedule.test.ts @@ -0,0 +1,113 @@ +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.each([ + { 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; + let originalHandler: vi.Mock; + let mockDecorator: vi.Mock; + + beforeEach(() => { + originalHandler = vi.fn(function testHandler() { + return '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); + }); + }); +});