diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 97f0dc98305..cb3c3a5a108 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512) - Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) - Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures [(#11665)](https://github.com/prowler-cloud/prowler/pull/11665) ### 🔐 Security diff --git a/ui/instrumentation-client.ts b/ui/instrumentation-client.ts index abced8232e7..f5866a65d78 100644 --- a/ui/instrumentation-client.ts +++ b/ui/instrumentation-client.ts @@ -21,6 +21,10 @@ import { startProgress, } from "@/components/ui/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, + }); }, }); diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts index bfb9fb5cedb..000e2e3c773 100644 --- a/ui/lib/server-actions-helper.test.ts +++ b/ui/lib/server-actions-helper.test.ts @@ -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,211 @@ vi.mock("./helper", () => ({ / { +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( - "502 Bad Gateway

502 Bad Gateway

", - { - 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( + "502 Bad Gateway

502 Bad Gateway

", + { + 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 capture and mark server errors before throwing", 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(true); + }); + + it("should fingerprint server errors by pathname without query string", 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?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"], + }), + ); + }); + + it("should fingerprint unexpected client failures by pathname without query string", 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?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"], + }), + ); + }); + }); + + 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 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(true); + 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", + }); + }); }); }); diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts index ae677f2ed2e..1b5b40f8be3 100644 --- a/ui/lib/server-actions-helper.ts +++ b/ui/lib/server-actions-helper.ts @@ -2,6 +2,10 @@ import * as Sentry from "@sentry/nextjs"; import { revalidatePath } from "next/cache"; import { SentryErrorSource, SentryErrorType } from "@/sentry"; +import { + isErrorAlreadyReported, + markErrorAsReported, +} 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,18 @@ export const handleApiResponse = async ( fingerprint: [ "api-server-error", response.status.toString(), - response.url, + getSentryFingerprintUrlPath(response.url), ], }); + markErrorAsReported(serverError); 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 @@ -158,34 +151,27 @@ export const handleApiError = (error: unknown): { error: string } => { console.error(error); // Check if this error was already captured by handleApiResponse - const isAlreadyCaptured = - error instanceof Error && - (error.message.includes("Server error") || - error.message.includes("Request failed")); + const isAlreadyCaptured = isErrorAlreadyReported(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, - }, - level: "error", - contexts: { - error_details: { - message: error.message, - stack: error.stack, - }, + 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, }, - }); - } + }, + }); + markErrorAsReported(error); } else { // Capture non-Error objects Sentry.captureMessage( @@ -201,6 +187,7 @@ export const handleApiError = (error: unknown): { error: string } => { }, }, ); + markErrorAsReported(error); } } @@ -208,3 +195,81 @@ 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, +) { + 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 { + return new URL(url).pathname || 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), + ); +} diff --git a/ui/sentry/event-policy.test.ts b/ui/sentry/event-policy.test.ts new file mode 100644 index 00000000000..bb3cd57bf32 --- /dev/null +++ b/ui/sentry/event-policy.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest"; + +import type { + SentryEventHint, + SentryEventPolicyOptions, + SentryPolicyEvent, +} from "./event-policy"; +import { + applySentryEventPolicy, + isErrorAlreadyReported, + 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(); + }); + }); +}); diff --git a/ui/sentry/event-policy.ts b/ui/sentry/event-policy.ts new file mode 100644 index 00000000000..ee6b5736037 --- /dev/null +++ b/ui/sentry/event-policy.ts @@ -0,0 +1,283 @@ +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 reportedErrors = new WeakSet(); + +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; +} + +type ObjectRecord = Record; +type ReportedErrorRecord = Record; + +export function applySentryEventPolicy( + 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 + ); +} + +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"; +} diff --git a/ui/sentry/index.ts b/ui/sentry/index.ts index dca76ca0e0b..9666b7002e8 100644 --- a/ui/sentry/index.ts +++ b/ui/sentry/index.ts @@ -1,2 +1,3 @@ // Re-export all Sentry utilities +export * from "./event-policy"; export * from "./utils"; diff --git a/ui/sentry/sentry-event-filter.test.ts b/ui/sentry/sentry-event-filter.test.ts new file mode 100644 index 00000000000..370af5884eb --- /dev/null +++ b/ui/sentry/sentry-event-filter.test.ts @@ -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); + }); + }); +}); diff --git a/ui/sentry/sentry-event-filter.ts b/ui/sentry/sentry-event-filter.ts new file mode 100644 index 00000000000..6396d50e661 --- /dev/null +++ b/ui/sentry/sentry-event-filter.ts @@ -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(event: TEvent) { + return applySentryEventPolicy(event); +} diff --git a/ui/sentry/sentry.edge.config.ts b/ui/sentry/sentry.edge.config.ts index 933c97822a5..fbee8a42022 100644 --- a/ui/sentry/sentry.edge.config.ts +++ b/ui/sentry/sentry.edge.config.ts @@ -2,6 +2,8 @@ import * as Sentry from "@sentry/nextjs"; import { readEnv } from "@/lib/runtime-env"; +import { applySentryEventPolicy, SENTRY_EVENT_SOURCE } from "./event-policy"; + const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN"); const sentryEnvironment = readEnv( "UI_SENTRY_ENVIRONMENT", @@ -42,15 +44,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) { @@ -60,20 +60,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, + }); }, }); } diff --git a/ui/sentry/sentry.server.config.ts b/ui/sentry/sentry.server.config.ts index 361365f2ecd..3de45e6a45c 100644 --- a/ui/sentry/sentry.server.config.ts +++ b/ui/sentry/sentry.server.config.ts @@ -2,6 +2,8 @@ import * as Sentry from "@sentry/nextjs"; import { readEnv } from "@/lib/runtime-env"; +import { applySentryEventPolicy, SENTRY_EVENT_SOURCE } from "./event-policy"; + const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN"); const sentryEnvironment = readEnv( "UI_SENTRY_ENVIRONMENT", @@ -47,15 +49,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) { @@ -82,15 +82,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, + }); }, }); }