fix(ui): reduce sentry alert noise (#11665)

This commit is contained in:
Alan Buscaglia
2026-07-20 16:34:24 +02:00
committed by GitHub
parent 4e22289a19
commit 13a9caa803
11 changed files with 1033 additions and 123 deletions
@@ -0,0 +1 @@
UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures
+7 -17
View File
@@ -21,6 +21,10 @@ import {
startProgress,
} from "@/components/shadcn/navigation-progress/use-navigation-progress";
import { getRuntimeConfigClient } from "@/lib/get-runtime-config.client";
import {
applySentryEventPolicy,
SENTRY_EVENT_SOURCE,
} from "@/sentry/event-policy";
export const NAVIGATION_TYPE = {
PUSH: "push",
@@ -96,25 +100,9 @@ if (typeof window !== "undefined" && sentryDsn) {
],
beforeSend(event, hint) {
// Filter out noise: ResizeObserver errors (common browser quirk, not real bugs)
if (event.message?.includes("ResizeObserver")) {
return null; // Don't send to Sentry
}
// Filter out non-actionable errors
if (event.exception) {
const error = hint.originalException;
// Don't send cancelled requests
if (
error &&
typeof error === "object" &&
"name" in error &&
error.name === "AbortError"
) {
return null;
}
// Add additional context for API errors
if (
error &&
@@ -130,7 +118,9 @@ if (typeof window !== "undefined" && sentryDsn) {
}
}
return event; // Send to Sentry
return applySentryEventPolicy(event, hint, {
source: SENTRY_EVENT_SOURCE.CLIENT,
});
},
});
+230 -28
View File
@@ -1,22 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { captureExceptionMock, captureMessageMock, revalidatePathMock } =
vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
import {
isErrorAlreadyReported,
markErrorAsReported,
} from "@/sentry/event-policy";
import { handleApiError, handleApiResponse } from "./server-actions-helper";
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
revalidatePath: vi.fn(),
}));
vi.mock("./helper", () => ({
vi.mock("@/lib/helper", () => ({
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
getErrorMessage: (error: unknown) =>
@@ -26,29 +27,230 @@ vi.mock("./helper", () => ({
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
}));
import { handleApiResponse } from "./server-actions-helper";
describe("server action error handling", () => {
describe("server-actions-helper", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("throws a generic server error instead of raw HTML for 5xx responses", async () => {
// Given
const response = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
afterEach(() => {
vi.restoreAllMocks();
});
describe("handleApiResponse", () => {
it("should throw a generic server error instead of raw HTML for 5xx responses", async () => {
// Given
const response = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
);
// When / Then
await expect(handleApiResponse(response)).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});
it("should not capture controlled 4xx API validation responses", async () => {
// Given
const response = new Response(
JSON.stringify({ errors: [{ detail: "Provider already exists" }] }),
{ status: 409, statusText: "Conflict" },
);
// When
const result = await handleApiResponse(response);
// Then
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(result).toMatchObject({
error: "Provider already exists",
status: 409,
});
});
it.each([418, 429])(
"should capture unexpected %s API client failures before returning them",
async (status) => {
// Given
const response = new Response(
JSON.stringify({ message: "Unexpected API contract failure" }),
{ status, statusText: "Unexpected Client Failure" },
);
// When
const result = await handleApiResponse(response);
// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
api_error: true,
error_source: "handleApiResponse",
error_type: "client_error",
status_code: status.toString(),
}),
}),
);
expect(result).toMatchObject({
error: "Unexpected API contract failure",
status,
});
},
);
// When / Then
const result = handleApiResponse(response);
await expect(result).rejects.toThrow(
"Server is temporarily unavailable. Please try again in a few minutes.",
);
it("should not mark server errors before Sentry processes the event", async () => {
// Given
const response = new Response(
JSON.stringify({ message: "backend down" }),
{
status: 500,
statusText: "Internal Server Error",
},
);
// When
await expect(handleApiResponse(response)).rejects.toThrow("backend down");
// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
const capturedError = vi.mocked(Sentry.captureException).mock
.calls[0]?.[0];
expect(isErrorAlreadyReported(capturedError)).toBe(false);
});
it("should fingerprint server errors by a normalized pathname", async () => {
// Given
const response = new Response(
JSON.stringify({ message: "backend down" }),
{
status: 500,
statusText: "Internal Server Error",
},
);
Object.defineProperty(response, "url", {
value:
"https://api.prowler.test/api/v1/providers/550e8400-e29b-41d4-a716-446655440000?tenant=123",
});
// When
await expect(handleApiResponse(response)).rejects.toThrow("backend down");
// Then
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
fingerprint: ["api-server-error", "500", "/api/v1/providers/:id"],
}),
);
});
it("should fingerprint unexpected client failures by a normalized pathname", async () => {
// Given
const response = new Response(
JSON.stringify({ message: "Unexpected API contract failure" }),
{ status: 429, statusText: "Too Many Requests" },
);
Object.defineProperty(response, "url", {
value: "https://api.prowler.test/api/v1/scans/12345?page=2",
});
// When
await handleApiResponse(response);
// Then
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
fingerprint: [
"api-client-contract-error",
"429",
"/api/v1/scans/:id",
],
}),
);
});
});
describe("handleApiError", () => {
it("should not recapture errors that were already reported", () => {
// Given
const error = new Error("Already reported failure");
vi.spyOn(console, "error").mockImplementation(() => undefined);
markErrorAsReported(error);
// When
const result = handleApiError(error);
// Then
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(result).toEqual({ error: "Already reported failure" });
});
it("should not recapture errors already marked by Sentry", () => {
// Given
const error = new Error("Already captured failure");
Object.defineProperty(error, "__sentry_captured__", { value: true });
vi.spyOn(console, "error").mockImplementation(() => undefined);
// When
const result = handleApiError(error);
// Then
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(result).toEqual({ error: "Already captured failure" });
});
it("should capture unmarked request failure errors", () => {
// Given
const error = new Error("Request failed unexpectedly");
vi.spyOn(console, "error").mockImplementation(() => undefined);
// When
const result = handleApiError(error);
// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
error_source: "handleApiError",
error_type: "unexpected_error",
}),
}),
);
expect(isErrorAlreadyReported(error)).toBe(false);
expect(result).toEqual({ error: "Request failed unexpectedly" });
});
it("should capture unmarked runtime errors that include expected HTTP status numbers", () => {
// Given
const error = new Error("Runtime worker 401 crashed unexpectedly");
vi.spyOn(console, "error").mockImplementation(() => undefined);
// When
const result = handleApiError(error);
// Then
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
error_source: "handleApiError",
error_type: "unexpected_error",
}),
}),
);
expect(result).toEqual({
error: "Runtime worker 401 crashed unexpectedly",
});
});
});
});
+120 -48
View File
@@ -2,6 +2,10 @@ import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import {
isErrorAlreadyReported,
isErrorCapturedBySentry,
} from "@/sentry/event-policy";
import {
GENERIC_SERVER_ERROR_MESSAGE,
@@ -10,6 +14,14 @@ import {
sanitizeErrorMessage,
} from "./helper";
const EXPECTED_HTTP_CLIENT_STATUS_CODES = new Set([401, 403, 404]);
const CONTROLLED_CLIENT_STATUS_CODES = new Set([400, 409, 422]);
const KNOWN_NON_ACTIONABLE_CLIENT_ERROR_MESSAGES = [
"already exists",
"duplicate",
] as const;
const UNKNOWN_URL_PATH_FINGERPRINT = "unknown-url-path";
/**
* Helper function to handle API responses consistently
* Includes Sentry error tracking for debugging
@@ -78,37 +90,17 @@ export const handleApiResponse = async (
fingerprint: [
"api-server-error",
response.status.toString(),
response.url,
getSentryFingerprintUrlPath(response.url),
],
});
throw serverError;
}
// Client errors (4xx) - Only capture unexpected ones
if (![401, 403, 404].includes(response.status)) {
const clientError = new Error(
errorDetail ||
`Request failed (${response.status}): ${response.statusText}`,
);
Sentry.captureException(clientError, {
tags: {
api_error: true,
status_code: response.status.toString(),
error_type: SentryErrorType.CLIENT_ERROR,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
},
level: "warning",
contexts: {
api_response: errorContext,
},
fingerprint: [
"api-client-error",
response.status.toString(),
response.url,
],
});
if (
!shouldSuppressApiClientFailure(response.status, errorDetail, errorsArray)
) {
captureUnexpectedApiClientFailure(response, errorDetail, errorContext);
}
return errorsArray
@@ -159,33 +151,26 @@ export const handleApiError = (error: unknown): { error: string } => {
// Check if this error was already captured by handleApiResponse
const isAlreadyCaptured =
error instanceof Error &&
(error.message.includes("Server error") ||
error.message.includes("Request failed"));
isErrorAlreadyReported(error) || isErrorCapturedBySentry(error);
// Only capture if not already captured by handleApiResponse
// Only capture if not already captured by handleApiResponse.
// HTTP status-based suppression belongs in the structured Sentry event policy,
// not in string-only Error message matching.
if (!isAlreadyCaptured) {
if (error instanceof Error) {
// Don't capture expected errors
if (
!error.message.includes("401") &&
!error.message.includes("403") &&
!error.message.includes("404")
) {
Sentry.captureException(error, {
tags: {
error_source: SentryErrorSource.HANDLE_API_ERROR,
error_type: SentryErrorType.UNEXPECTED_ERROR,
Sentry.captureException(error, {
tags: {
error_source: SentryErrorSource.HANDLE_API_ERROR,
error_type: SentryErrorType.UNEXPECTED_ERROR,
},
level: "error",
contexts: {
error_details: {
message: error.message,
stack: error.stack,
},
level: "error",
contexts: {
error_details: {
message: error.message,
stack: error.stack,
},
},
});
}
},
});
} else {
// Capture non-Error objects
Sentry.captureMessage(
@@ -208,3 +193,90 @@ export const handleApiError = (error: unknown): { error: string } => {
error: getErrorMessage(error),
};
};
function shouldSuppressApiClientFailure(
status: number,
errorDetail: unknown,
errorsArray: unknown[] | undefined,
) {
if (status < 400 || status >= 500) {
return true;
}
if (EXPECTED_HTTP_CLIENT_STATUS_CODES.has(status)) {
return true;
}
return (
CONTROLLED_CLIENT_STATUS_CODES.has(status) &&
(hasStructuredApiErrors(errorsArray) ||
hasKnownNonActionableClientErrorMessage(errorDetail))
);
}
function captureUnexpectedApiClientFailure(
response: Response,
errorDetail: unknown,
errorContext: Record<string, unknown>,
) {
const clientError = new Error(
typeof errorDetail === "string"
? errorDetail
: `Unexpected API client failure (${response.status})`,
);
Sentry.captureException(clientError, {
tags: {
api_error: true,
status_code: response.status.toString(),
error_type: SentryErrorType.CLIENT_ERROR,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
},
level: "error",
contexts: {
api_response: errorContext,
},
fingerprint: [
"api-client-contract-error",
response.status.toString(),
getSentryFingerprintUrlPath(response.url),
],
});
}
function getSentryFingerprintUrlPath(url: string) {
if (url.trim() === "") {
return UNKNOWN_URL_PATH_FINGERPRINT;
}
try {
const pathname = new URL(url).pathname;
return (
pathname
.replace(
/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi,
"/:id",
)
.replace(/\/\d+(?=\/|$)/g, "/:id") || UNKNOWN_URL_PATH_FINGERPRINT
);
} catch {
return UNKNOWN_URL_PATH_FINGERPRINT;
}
}
function hasStructuredApiErrors(errorsArray: unknown[] | undefined) {
return Array.isArray(errorsArray) && errorsArray.length > 0;
}
function hasKnownNonActionableClientErrorMessage(errorDetail: unknown) {
if (typeof errorDetail !== "string") {
return false;
}
const normalizedErrorDetail = errorDetail.toLowerCase();
return KNOWN_NON_ACTIONABLE_CLIENT_ERROR_MESSAGES.some((message) =>
normalizedErrorDetail.includes(message),
);
}
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it } from "vitest";
import type {
SentryEventHint,
SentryEventPolicyOptions,
SentryPolicyEvent,
} from "./event-policy";
import {
applySentryEventPolicy,
isErrorAlreadyReported,
isErrorCapturedBySentry,
markErrorAsReported,
} from "./event-policy";
describe("applySentryEventPolicy", () => {
describe("when events are actionable", () => {
it("should keep error events", () => {
// Given
const event = {
level: "error",
message: "Unexpected failure",
} satisfies SentryPolicyEvent & { level: string; message: string };
const options: SentryEventPolicyOptions = { source: "client" };
// When
const result = applySentryEventPolicy(event, undefined, options);
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
kind: "runtime",
source: "client",
},
});
});
it("should keep fatal events", () => {
// Given
const event = { level: "fatal", message: "Runtime crashed" };
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
});
it("should keep events without a level", () => {
// Given
const event = { message: "Default Sentry event" };
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
});
it("should keep fatal runtime messages that contain an expected HTTP status number", () => {
// Given
const event = {
level: "fatal",
message: "Runtime worker 401 crashed unexpectedly",
};
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
kind: "runtime",
},
});
});
it("should keep runtime error messages that contain an expected HTTP status number", () => {
// Given
const event = {
level: "error",
message: "Background import 404 failed after a null dereference",
};
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
kind: "runtime",
},
});
});
it("should keep fatal runtime messages that mention status without HTTP context", () => {
// Given
const event = {
level: "fatal",
message: "State transition status 403 caused worker crash",
};
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
kind: "runtime",
},
});
});
it("should keep fatal runtime messages that mention status code without HTTP context", () => {
// Given
const event = {
level: "fatal",
message: "State transition status code 403 caused worker crash",
};
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
kind: "runtime",
},
});
});
it("should keep fatal runtime messages that mention response without HTTP context", () => {
// Given
const event = {
level: "fatal",
message: "Worker response 404 triggered fatal cache corruption",
};
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
kind: "runtime",
},
});
});
it("should preserve existing tags on actionable API events", () => {
// Given
const event = {
level: "error",
message: "API response failed with status 500",
tags: {
feature: "providers",
status_code: "500",
},
};
// When
const result = applySentryEventPolicy(event, undefined, {
source: "server_action",
});
// Then
expect(result).toBe(event);
expect(result).toMatchObject({
tags: {
actionability: "actionable",
feature: "providers",
kind: "api",
source: "server_action",
status_code: "500",
},
});
});
});
describe("when events are non-actionable", () => {
it("should drop warning events", () => {
// Given
const event = { level: "warning", message: "Provider already exists" };
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBeNull();
});
it("should drop Next.js redirect control-flow events", () => {
// Given
const event = { level: "error", message: "NEXT_REDIRECT" };
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBeNull();
});
it("should drop AbortError events", () => {
// Given
const event = { level: "error", message: "The operation was aborted" };
const hint: SentryEventHint = {
originalException: new DOMException("Aborted", "AbortError"),
};
// When
const result = applySentryEventPolicy(event, hint);
// Then
expect(result).toBeNull();
});
it("should drop expected HTTP 401 events", () => {
// Given
const event = { level: "error", tags: { status_code: "401" } };
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBeNull();
});
it("should drop expected HTTP 403 events", () => {
// Given
const event = { level: "error", message: "Request failed with 403" };
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBeNull();
});
it("should drop expected HTTP 404 events", () => {
// Given
const event = { level: "error" };
const hint = { originalException: { status: 404 } };
// When
const result = applySentryEventPolicy(event, hint);
// Then
expect(result).toBeNull();
});
it("should drop clear HTTP messages with expected status codes", () => {
// Given
const event = {
level: "error",
message: "HTTP response status code 404",
};
// When
const result = applySentryEventPolicy(event);
// Then
expect(result).toBeNull();
});
it("should drop already-reported errors", () => {
// Given
const error = new Error("Already reported failure");
const event = { level: "error", message: error.message };
markErrorAsReported(error);
// When
const result = applySentryEventPolicy(event, {
originalException: error,
});
// Then
expect(isErrorAlreadyReported(error)).toBe(true);
expect(result).toBeNull();
});
it("should keep the first event for an error marked by Sentry", () => {
// Given
const error = new Error("First capture");
Object.defineProperty(error, "__sentry_captured__", { value: true });
const event = { level: "error", message: error.message };
// When
const result = applySentryEventPolicy(event, {
originalException: error,
});
// Then
expect(isErrorCapturedBySentry(error)).toBe(true);
expect(result).toBe(event);
});
});
});
+290
View File
@@ -0,0 +1,290 @@
const SENTRY_EVENT_LEVEL = {
WARNING: "warning",
} as const;
export const SENTRY_EVENT_SOURCE = {
CLIENT: "client",
EDGE: "edge",
SERVER: "server",
SERVER_ACTION: "server_action",
} as const;
const SENTRY_EVENT_KIND = {
API: "api",
RUNTIME: "runtime",
} as const;
const SENTRY_ACTIONABILITY = {
ACTIONABLE: "actionable",
} as const;
const EXPECTED_CONTROL_FLOW_MESSAGES = [
"NEXT_REDIRECT",
"NEXT_NOT_FOUND",
"AbortError",
"ResizeObserver",
] as const;
const HTTP_CONTEXT_MESSAGES = [
/\bapi\b/i,
/\bfetch\b/i,
/\bhttp\b/i,
/\brequest failed\b/i,
] as const;
const EXPECTED_HTTP_STATUS_CODES = new Set([401, 403, 404]);
const EXPECTED_HTTP_STATUS_PATTERN = /(^|\D)(401|403|404)(\D|$)/;
const REPORTED_ERROR_MARKER = Symbol.for("prowler.sentry.reported_error");
const SENTRY_CAPTURED_MARKER = "__sentry_captured__";
const reportedErrors = new WeakSet<object>();
type SentryEventSource =
(typeof SENTRY_EVENT_SOURCE)[keyof typeof SENTRY_EVENT_SOURCE];
type SentryPolicyTagValue = string | number | boolean | null | undefined;
export interface SentryEventPolicyOptions {
source?: SentryEventSource;
}
export interface SentryEventHint {
originalException?: unknown;
}
export interface SentryPolicyEvent {
tags?: Record<string, SentryPolicyTagValue>;
}
type ObjectRecord = Record<PropertyKey, unknown>;
type ReportedErrorRecord = Record<symbol, unknown>;
export function applySentryEventPolicy<TEvent extends object>(
event: TEvent,
hint?: SentryEventHint,
options: SentryEventPolicyOptions = {},
) {
if (shouldDropSentryEvent(event, hint)) {
return null;
}
tagActionableEvent(event, options);
return event;
}
export function markErrorAsReported(error: unknown) {
if (!isObjectLike(error)) {
return;
}
reportedErrors.add(error);
try {
Object.defineProperty(error, REPORTED_ERROR_MARKER, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
// WeakSet fallback still suppresses duplicates for non-extensible objects.
}
}
export function isErrorAlreadyReported(error: unknown) {
if (!isObjectLike(error)) {
return false;
}
return (
reportedErrors.has(error) ||
(error as ReportedErrorRecord)[REPORTED_ERROR_MARKER] === true
);
}
export function isErrorCapturedBySentry(error: unknown) {
return (
isObjectLike(error) && getProperty(error, SENTRY_CAPTURED_MARKER) === true
);
}
function shouldDropSentryEvent(event: object, hint?: SentryEventHint) {
if (getStringProperty(event, "level") === SENTRY_EVENT_LEVEL.WARNING) {
return true;
}
if (isErrorAlreadyReported(hint?.originalException)) {
return true;
}
const messages = getEventMessages(event, hint);
if (hasExpectedControlFlowMessage(messages)) {
return true;
}
return hasExpectedHttpStatus(event, hint?.originalException, messages);
}
function tagActionableEvent(event: object, options: SentryEventPolicyOptions) {
const mutableEvent = event as SentryPolicyEvent;
mutableEvent.tags = {
...mutableEvent.tags,
actionability: SENTRY_ACTIONABILITY.ACTIONABLE,
kind: inferEventKind(event),
...(options.source ? { source: options.source } : {}),
};
}
function inferEventKind(event: object) {
const tags = getRecordProperty(event, "tags");
const errorType = getStringProperty(tags, "error_type");
const apiError = getProperty(tags, "api_error");
if (
errorType === "api_error" ||
apiError === true ||
getStatusFromTags(tags)
) {
return SENTRY_EVENT_KIND.API;
}
return SENTRY_EVENT_KIND.RUNTIME;
}
function hasExpectedControlFlowMessage(messages: string[]) {
return EXPECTED_CONTROL_FLOW_MESSAGES.some((expectedMessage) =>
messages.some((message) => message.includes(expectedMessage)),
);
}
function hasExpectedHttpStatus(
event: object,
originalException: unknown,
messages: string[],
) {
const tags = getRecordProperty(event, "tags");
const statusFromTags = getStatusFromTags(tags);
const statusFromError = getStatusFromError(originalException);
if (
(statusFromTags && EXPECTED_HTTP_STATUS_CODES.has(statusFromTags)) ||
(statusFromError && EXPECTED_HTTP_STATUS_CODES.has(statusFromError))
) {
return true;
}
return (
hasExpectedHttpStatusMessage(messages) &&
hasApiOrHttpContext(event, messages)
);
}
function hasExpectedHttpStatusMessage(messages: string[]) {
return messages.some((message) => EXPECTED_HTTP_STATUS_PATTERN.test(message));
}
function hasApiOrHttpContext(event: object, messages: string[]) {
const tags = getRecordProperty(event, "tags");
return hasApiTags(tags) || hasHttpContextMessage(messages);
}
function hasApiTags(tags: unknown) {
return (
getStringProperty(tags, "error_type") === "api_error" ||
getProperty(tags, "api_error") === true
);
}
function hasHttpContextMessage(messages: string[]) {
return messages.some((message) =>
HTTP_CONTEXT_MESSAGES.some((pattern) => pattern.test(message)),
);
}
function getStatusFromTags(tags: unknown) {
return (
normalizeStatusCode(getProperty(tags, "status_code")) ??
normalizeStatusCode(getProperty(tags, "status")) ??
normalizeStatusCode(getProperty(tags, "http.status_code"))
);
}
function getStatusFromError(error: unknown): number | undefined {
const response = getRecordProperty(error, "response");
return (
normalizeStatusCode(getProperty(error, "status")) ??
normalizeStatusCode(getProperty(error, "statusCode")) ??
normalizeStatusCode(getProperty(error, "status_code")) ??
normalizeStatusCode(getProperty(response, "status"))
);
}
function getEventMessages(event: object, hint?: SentryEventHint) {
return [
getStringProperty(event, "message"),
...getExceptionMessages(event),
getStringProperty(hint?.originalException, "name"),
getStringProperty(hint?.originalException, "message"),
typeof hint?.originalException === "string"
? hint.originalException
: undefined,
].filter(isString);
}
function getExceptionMessages(event: object) {
const exception = getRecordProperty(event, "exception");
const values = getProperty(exception, "values");
if (!Array.isArray(values)) {
return [];
}
return values.flatMap((value) => [
getStringProperty(value, "type"),
getStringProperty(value, "value"),
]);
}
function normalizeStatusCode(value: unknown) {
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && /^\d{3}$/.test(value)) {
return Number(value);
}
return undefined;
}
function getStringProperty(value: unknown, property: string) {
const propertyValue = getProperty(value, property);
return typeof propertyValue === "string" ? propertyValue : undefined;
}
function getRecordProperty(value: unknown, property: string) {
const propertyValue = getProperty(value, property);
return isRecord(propertyValue) ? propertyValue : undefined;
}
function getProperty(value: unknown, property: string) {
return isRecord(value) ? value[property] : undefined;
}
function isRecord(value: unknown): value is ObjectRecord {
return typeof value === "object" && value !== null;
}
function isObjectLike(value: unknown): value is object {
return (
(typeof value === "object" && value !== null) || typeof value === "function"
);
}
function isString(value: unknown): value is string {
return typeof value === "string";
}
+1
View File
@@ -1,2 +1,3 @@
// Re-export all Sentry utilities
export * from "./event-policy";
export * from "./utils";
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { filterWarningSentryEvent } from "./sentry-event-filter";
describe("filterWarningSentryEvent", () => {
describe("when the event level is warning", () => {
it("should drop the event", () => {
// Given
const event = { level: "warning", message: "Provider already exists" };
// When
const result = filterWarningSentryEvent(event);
// Then
expect(result).toBeNull();
});
});
describe("when the event level is actionable", () => {
it("should keep error events", () => {
// Given
const event = { level: "error", message: "Unexpected failure" };
// When
const result = filterWarningSentryEvent(event);
// Then
expect(result).toBe(event);
});
it("should keep fatal events", () => {
// Given
const event = { level: "fatal", message: "Runtime crashed" };
// When
const result = filterWarningSentryEvent(event);
// Then
expect(result).toBe(event);
});
it("should keep events without a level", () => {
// Given
const event = { message: "Default Sentry event" };
// When
const result = filterWarningSentryEvent(event);
// Then
expect(result).toBe(event);
});
});
});
+7
View File
@@ -0,0 +1,7 @@
import { applySentryEventPolicy } from "./event-policy";
// Backward-compatible export name: the implementation now applies the full
// actionability policy, including warning drops and expected API noise filters.
export function filterWarningSentryEvent<TEvent extends object>(event: TEvent) {
return applySentryEventPolicy(event);
}
+8 -19
View File
@@ -2,6 +2,8 @@ import * as Sentry from "@sentry/nextjs";
import { readGatedEnv } from "@/lib/integrations";
import { applySentryEventPolicy, SENTRY_EVENT_SOURCE } from "./event-policy";
const sentryDsn = readGatedEnv(
"UI_SENTRY_ENABLED",
"UI_SENTRY_DSN",
@@ -47,15 +49,13 @@ if (sentryDsn) {
// 🔌 Integrations - Edge runtime doesn't support all integrations
integrations: [],
// 🎣 Filter expected errors - Don't send noise to Sentry
// 🎣 Filter expected framework control-flow - Don't send noise to Sentry.
// HTTP status-based suppression belongs in applySentryEventPolicy, where
// structured event context prevents broad numeric matches from hiding crashes.
ignoreErrors: [
// NextAuth redirect errors - Expected behavior in auth flow
"NEXT_REDIRECT",
"NEXT_NOT_FOUND",
// Expected HTTP errors - Expected when users lack permissions
"401", // Unauthorized - expected when token expires
"403", // Forbidden - expected when no permissions
"404", // Not Found - expected for missing resources
],
beforeSend(event, hint) {
@@ -65,20 +65,9 @@ if (sentryDsn) {
runtime: "edge",
};
const error = hint.originalException;
// Don't send NextAuth expected errors
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string" &&
error.message.includes("NEXT_REDIRECT")
) {
return null;
}
return event;
return applySentryEventPolicy(event, hint, {
source: SENTRY_EVENT_SOURCE.EDGE,
});
},
});
}
+8 -11
View File
@@ -2,6 +2,8 @@ import * as Sentry from "@sentry/nextjs";
import { readGatedEnv } from "@/lib/integrations";
import { applySentryEventPolicy, SENTRY_EVENT_SOURCE } from "./event-policy";
const sentryDsn = readGatedEnv(
"UI_SENTRY_ENABLED",
"UI_SENTRY_DSN",
@@ -52,15 +54,13 @@ if (sentryDsn) {
}),
],
// 🎣 Filter expected errors - Don't send noise to Sentry
// 🎣 Filter expected framework control-flow - Don't send noise to Sentry.
// HTTP status-based suppression belongs in applySentryEventPolicy, where
// structured event context prevents broad numeric matches from hiding crashes.
ignoreErrors: [
// NextAuth redirect errors - Expected behavior
"NEXT_REDIRECT",
"NEXT_NOT_FOUND",
// Expected HTTP errors - Expected when users lack permissions
"401", // Unauthorized
"403", // Forbidden
"404", // Not Found
],
beforeSend(event, hint) {
@@ -87,15 +87,12 @@ if (sentryDsn) {
error_type: "api_error",
};
}
// Don't send NextAuth expected errors
if (error.message.includes("NEXT_REDIRECT")) {
return null;
}
}
}
return event;
return applySentryEventPolicy(event, hint, {
source: SENTRY_EVENT_SOURCE.SERVER,
});
},
});
}