Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}/),
Expand Down
22 changes: 2 additions & 20 deletions packages/nestjs/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
);
Expand Down
6 changes: 6 additions & 0 deletions packages/nestjs/src/integrations/nest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 },
);
Expand Down
Loading
Loading