diff --git a/dev-packages/e2e-tests/test-applications/nestjs/package.json b/dev-packages/e2e-tests/test-applications/nestjs/package.json index 6ad2576fc3cc..94c4e445bfe0 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs/package.json @@ -17,6 +17,7 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/schedule": "^4.1.0", "@nestjs/platform-express": "^10.0.0", "@sentry/nestjs": "latest || *", "@sentry/types": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts index 5ba6bcb2a68e..7fda9eef768e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs/src/app.controller.ts @@ -79,6 +79,11 @@ export class AppController1 { async testSpanDecoratorSync() { return { result: await this.appService.testSpanDecoratorSync() }; } + + @Get('kill-test-cron') + async killTestCron() { + this.appService.killTestCron(); + } } @Controller() diff --git a/dev-packages/e2e-tests/test-applications/nestjs/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs/src/app.module.ts index 5fda2f1e209f..b4f9d5588dda 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/src/app.module.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; import { AppController1, AppController2 } from './app.controller'; import { AppService1, AppService2 } from './app.service'; @Module({ - imports: [], + imports: [ScheduleModule.forRoot()], controllers: [AppController1], providers: [AppService1], }) diff --git a/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts index 7e0df6b7e1c8..b6fd70769e1f 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs/src/app.service.ts @@ -1,10 +1,21 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; import * as Sentry from '@sentry/nestjs'; -import { SentryTraced } from '@sentry/nestjs'; +import { SentryCron, SentryTraced } from '@sentry/nestjs'; +import type { MonitorConfig } from '@sentry/types'; import { makeHttpRequest } from './utils'; +const monitorConfig: MonitorConfig = { + schedule: { + type: 'crontab', + value: '* * * * *', + }, +}; + @Injectable() export class AppService1 { + constructor(private schedulerRegistry: SchedulerRegistry) {} + testSuccess() { return { version: 'v1' }; } @@ -95,6 +106,21 @@ export class AppService1 { async testSpanDecoratorSync() { return this.getString(); } + + /* + Actual cron schedule differs from schedule defined in config because Sentry + only supports minute granularity, but we don't want to wait (worst case) a + full minute for the tests to finish. + */ + @Cron('*/5 * * * * *', { name: 'test-cron-job' }) + @SentryCron('test-cron-slug', monitorConfig) + async testCron() { + console.log('Test cron!'); + } + + async killTestCron() { + this.schedulerRegistry.deleteCronJob('test-cron-job'); + } } @Injectable() diff --git a/dev-packages/e2e-tests/test-applications/nestjs/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs/tests/cron-decorator.test.ts new file mode 100644 index 000000000000..c13623337343 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs/tests/cron-decorator.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; + +test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => { + const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs', envelope => { + return envelope[0].type === 'check_in'; + }); + + const inProgressEnvelope = await inProgressEnvelopePromise; + + expect(inProgressEnvelope[1]).toEqual( + expect.objectContaining({ + check_in_id: expect.any(String), + monitor_slug: 'test-cron-slug', + status: 'in_progress', + environment: 'qa', + monitor_config: { + schedule: { + type: 'crontab', + value: '* * * * *', + }, + }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }), + ); + + // kill cron so tests don't get stuck + await fetch(`${baseURL}/kill-test-cron`); +}); diff --git a/packages/nestjs/src/cron-decorator.ts b/packages/nestjs/src/cron-decorator.ts new file mode 100644 index 000000000000..8cb86c6d66cc --- /dev/null +++ b/packages/nestjs/src/cron-decorator.ts @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node'; +import type { MonitorConfig } from '@sentry/types'; + +/** + * A decorator wrapping the native nest Cron decorator, sending check-ins to Sentry. + */ +export const SentryCron = (monitorSlug: string, monitorConfig?: MonitorConfig): MethodDecorator => { + return (target: unknown, propertyKey, descriptor: PropertyDescriptor) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalMethod = descriptor.value as (...args: any[]) => Promise; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + descriptor.value = function (...args: any[]) { + return Sentry.withMonitor( + monitorSlug, + () => { + return originalMethod.apply(this, args); + }, + monitorConfig, + ); + }; + return descriptor; + }; +}; diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index 668187a21e29..00519cf49b9e 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -3,3 +3,4 @@ export * from '@sentry/node'; export { init } from './sdk'; export { SentryTraced } from './span-decorator'; +export { SentryCron } from './cron-decorator';