From 3ea6858591ac165d327fe542424fa1f38a4df9b7 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Thu, 25 Jun 2026 13:32:40 +0200 Subject: [PATCH] fix(ui): address sentry review feedback --- ui/lib/server-actions-helper.test.ts | 48 ++++++++++++++++++++++++++++ ui/lib/server-actions-helper.ts | 19 +++++++++-- ui/sentry/event-policy.test.ts | 17 +++++++--- ui/sentry/event-policy.ts | 6 ++-- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts index 89c2914665..000e2e3c77 100644 --- a/ui/lib/server-actions-helper.test.ts +++ b/ui/lib/server-actions-helper.test.ts @@ -123,6 +123,53 @@ describe("server-actions-helper", () => { .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", () => { @@ -159,6 +206,7 @@ describe("server-actions-helper", () => { }), }), ); + expect(isErrorAlreadyReported(error)).toBe(true); expect(result).toEqual({ error: "Request failed unexpectedly" }); }); diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts index 263ab51fe9..1b5b40f8be 100644 --- a/ui/lib/server-actions-helper.ts +++ b/ui/lib/server-actions-helper.ts @@ -20,6 +20,7 @@ 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 @@ -89,7 +90,7 @@ export const handleApiResponse = async ( fingerprint: [ "api-server-error", response.status.toString(), - response.url, + getSentryFingerprintUrlPath(response.url), ], }); markErrorAsReported(serverError); @@ -170,6 +171,7 @@ export const handleApiError = (error: unknown): { error: string } => { }, }, }); + markErrorAsReported(error); } else { // Capture non-Error objects Sentry.captureMessage( @@ -185,6 +187,7 @@ export const handleApiError = (error: unknown): { error: string } => { }, }, ); + markErrorAsReported(error); } } @@ -238,11 +241,23 @@ function captureUnexpectedApiClientFailure( fingerprint: [ "api-client-contract-error", response.status.toString(), - response.url, + 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; } diff --git a/ui/sentry/event-policy.test.ts b/ui/sentry/event-policy.test.ts index 2c7f5281ae..bb3cd57bf3 100644 --- a/ui/sentry/event-policy.test.ts +++ b/ui/sentry/event-policy.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; +import type { + SentryEventHint, + SentryEventPolicyOptions, + SentryPolicyEvent, +} from "./event-policy"; import { applySentryEventPolicy, isErrorAlreadyReported, @@ -10,12 +15,14 @@ describe("applySentryEventPolicy", () => { describe("when events are actionable", () => { it("should keep error events", () => { // Given - const event = { level: "error", message: "Unexpected failure" }; + const event = { + level: "error", + message: "Unexpected failure", + } satisfies SentryPolicyEvent & { level: string; message: string }; + const options: SentryEventPolicyOptions = { source: "client" }; // When - const result = applySentryEventPolicy(event, undefined, { - source: "client", - }); + const result = applySentryEventPolicy(event, undefined, options); // Then expect(result).toBe(event); @@ -206,7 +213,7 @@ describe("applySentryEventPolicy", () => { it("should drop AbortError events", () => { // Given const event = { level: "error", message: "The operation was aborted" }; - const hint = { + const hint: SentryEventHint = { originalException: new DOMException("Aborted", "AbortError"), }; diff --git a/ui/sentry/event-policy.ts b/ui/sentry/event-policy.ts index fce2666d56..ee6b573603 100644 --- a/ui/sentry/event-policy.ts +++ b/ui/sentry/event-policy.ts @@ -41,15 +41,15 @@ type SentryEventSource = (typeof SENTRY_EVENT_SOURCE)[keyof typeof SENTRY_EVENT_SOURCE]; type SentryPolicyTagValue = string | number | boolean | null | undefined; -interface SentryEventPolicyOptions { +export interface SentryEventPolicyOptions { source?: SentryEventSource; } -interface SentryEventHint { +export interface SentryEventHint { originalException?: unknown; } -interface SentryPolicyEvent { +export interface SentryPolicyEvent { tags?: Record; }