Skip to content
Open
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
19 changes: 19 additions & 0 deletions packages/nestjs/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,25 @@ class SentryGlobalFilter extends BaseExceptionFilter {
return;
}

// Custom context types (necord, ...) run through ExternalContextCreator and have no HTTP adapter.
// BaseExceptionFilter expects an HTTP adapter and cannot reply on those hosts.
if (contextType !== 'http') {
if (!isExpectedError(exception)) {
captureException(exception, {
mechanism: {
handled: false,
type: `auto.${contextType}.nestjs.global_filter`,
},
});
}

if (exception instanceof Error) {
this._logger.error(exception.message, exception.stack);
}

return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

did you test the behavior for this? usually we rethrow exceptions to not interfere with user application behavior. however, there are exceptions for instance if you look at the rpc branch in this file. so not entirely sure this is correct, depends a bit on how the framework behaves here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, tested it against real Nest (10.4.15): registered SentryGlobalFilter as APP_FILTER and ran a throwing handler through ExternalContextCreator with a necord context type, the same way necord wires its handlers.

The return doesn't swallow the error. ExternalExceptionsHandler.next() only uses the filter result if it's truthy, otherwise it falls back to ExternalExceptionFilter, which rethrows. So the caller still gets the original error, same as without the Sentry filter, and we capture it once. The rpc branch works the same way: returning there falls through to BaseRpcExceptionFilter, which still sends the error back to the client (checked with @nestjs/microservices 10.4.15). Nest master has the same fallback.

One side effect: the error gets logged twice (our _logger.error plus Nest's ExternalExceptionFilter). I can drop our log line in this branch if you'd like, and I can also add the framework-level test to the PR.

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Feat missing integration or E2E test

Medium Severity

This feat only adds unit tests for the new necord path in SentryGlobalFilter. The review rules require feat PRs to include at least one integration or E2E test, so the new context handling is not covered at that level. An integration test can drive a NestJS host with getType() returning necord and assert the captured event without adding necord or discord.js.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 00688ae. Configure here.


// HTTP exceptions
if (!isExpectedError(exception)) {
captureException(exception, {
Expand Down
49 changes: 49 additions & 0 deletions packages/nestjs/test/sentry-global-filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { ArgumentsHost } from '@nestjs/common';
import { HttpException, HttpStatus, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import * as SentryCore from '@sentry/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as Helpers from '../src/helpers';
Expand Down Expand Up @@ -322,4 +323,52 @@ describe('SentryGlobalFilter', () => {
expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack);
});
});

describe('non-HTTP custom context', () => {
it.each(['necord', 'custom'])(
'captures unexpected errors for context type %s without delegating to HTTP',
contextType => {
vi.mocked(mockArgumentsHost.getType).mockReturnValue(contextType);
const superCatchSpy = vi.spyOn(BaseExceptionFilter.prototype, 'catch').mockImplementation(() => undefined);
const error = new Error('Custom context failed');

filter.catch(error, mockArgumentsHost);

expect(mockCaptureException).toHaveBeenCalledWith(error, {
mechanism: {
handled: false,
type: `auto.${contextType}.nestjs.global_filter`,
},
});
expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack);
expect(superCatchSpy).not.toHaveBeenCalled();
},
);

it('does not capture expected exceptions for non-HTTP contexts', () => {
vi.mocked(mockArgumentsHost.getType).mockReturnValue('necord');
isExpectedErrorMock.mockReturnValueOnce(true);
const exception = new HttpException('Unknown interaction', HttpStatus.BAD_REQUEST);

filter.catch(exception, mockArgumentsHost);

expect(mockCaptureException).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(exception.message, exception.stack);
});

it('captures unexpected non-Error values for non-HTTP contexts', () => {
vi.mocked(mockArgumentsHost.getType).mockReturnValue('custom');
const nonErrorObject = { message: 'interaction failed' };

filter.catch(nonErrorObject, mockArgumentsHost);

expect(mockCaptureException).toHaveBeenCalledWith(nonErrorObject, {
mechanism: {
handled: false,
type: 'auto.custom.nestjs.global_filter',
},
});
expect(mockLoggerError).not.toHaveBeenCalled();
});
});
});