diff --git a/ui/changelog.d/ui-sentry-actionability.fixed.md b/ui/changelog.d/ui-sentry-actionability.fixed.md
new file mode 100644
index 0000000000..7a4b8ce2ca
--- /dev/null
+++ b/ui/changelog.d/ui-sentry-actionability.fixed.md
@@ -0,0 +1 @@
+UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures
diff --git a/ui/instrumentation-client.ts b/ui/instrumentation-client.ts
index 910af90010..34dcde2f64 100644
--- a/ui/instrumentation-client.ts
+++ b/ui/instrumentation-client.ts
@@ -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,
+ });
},
});
diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts
index bfb9fb5ced..a3b1c96154 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,230 @@ 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 Gateway502 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 Gateway502 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 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",
+ });
+ });
});
});
diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts
index ae677f2ed2..dd9d5c291e 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,
+ 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,
+) {
+ 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),
+ );
+}
diff --git a/ui/sentry/event-policy.test.ts b/ui/sentry/event-policy.test.ts
new file mode 100644
index 0000000000..3f3c13d7fa
--- /dev/null
+++ b/ui/sentry/event-policy.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/ui/sentry/event-policy.ts b/ui/sentry/event-policy.ts
new file mode 100644
index 0000000000..c8312abfb6
--- /dev/null
+++ b/ui/sentry/event-policy.ts
@@ -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