diff --git a/web_src/src/lib/errors.spec.ts b/web_src/src/lib/errors.spec.ts index cc814e60c1..0fcd05329b 100644 --- a/web_src/src/lib/errors.spec.ts +++ b/web_src/src/lib/errors.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getApiErrorMessage, getResponseErrorMessage } from "@/lib/errors"; +import { getApiErrorMessage, getResponseErrorMessage, isTransientHttpError, summarizeError } from "@/lib/errors"; describe("errors", () => { it("extracts nested api error messages", () => { @@ -84,3 +84,72 @@ describe("errors", () => { await expect(getResponseErrorMessage(response, "fallback")).resolves.toBe("fallback"); }); }); + +describe("isTransientHttpError", () => { + it("treats HTML response bodies as transient", () => { + const htmlError = ` + + superplane.com | 502: Bad gateway + Bad gateway +`; + + expect(isTransientHttpError(htmlError)).toBe(true); + expect(isTransientHttpError("hi")).toBe(true); + }); + + it("treats browser network failures as transient", () => { + expect(isTransientHttpError(new TypeError("Failed to fetch"))).toBe(true); + expect(isTransientHttpError(new TypeError("Load failed"))).toBe(true); + expect(isTransientHttpError({ name: "AbortError" })).toBe(true); + }); + + it("treats transient HTTP statuses as transient", () => { + for (const status of [0, 401, 403, 408, 429, 502, 503, 504]) { + expect(isTransientHttpError({ status })).toBe(true); + expect(isTransientHttpError({ response: { status } })).toBe(true); + } + }); + + it("does not treat real application errors as transient", () => { + expect(isTransientHttpError({ status: 400 })).toBe(false); + expect(isTransientHttpError({ response: { status: 422 } })).toBe(false); + expect(isTransientHttpError(new Error("validation failed"))).toBe(false); + expect(isTransientHttpError("conflict resolving canvas changes")).toBe(false); + expect(isTransientHttpError(undefined)).toBe(false); + expect(isTransientHttpError(null)).toBe(false); + }); +}); + +describe("summarizeError", () => { + it("collapses HTML response bodies to a short label", () => { + const htmlError = ` + + superplane.com | 502: Bad gateway + Bad gateway +`; + + expect(summarizeError(htmlError)).toBe("HTML response body"); + }); + + it("includes the HTTP status when available", () => { + expect(summarizeError({ status: 503 })).toBe("HTTP 503"); + expect(summarizeError({ response: { status: 504 } })).toBe("HTTP 504"); + }); + + it("returns the error message for Error instances", () => { + expect(summarizeError(new Error("validation failed"))).toBe("validation failed"); + }); + + it("truncates very long messages", () => { + const longString = "x".repeat(500); + const summary = summarizeError(longString, 50); + expect(summary.length).toBeLessThanOrEqual(50); + expect(summary.endsWith("…")).toBe(true); + }); + + it("handles non-string, non-error values", () => { + expect(summarizeError(undefined)).toBe("Unknown error"); + expect(summarizeError(null)).toBe("Unknown error"); + expect(summarizeError({ foo: "bar" })).toBe('{"foo":"bar"}'); + }); +}); diff --git a/web_src/src/lib/errors.ts b/web_src/src/lib/errors.ts index 3992f296af..fabb4d3470 100644 --- a/web_src/src/lib/errors.ts +++ b/web_src/src/lib/errors.ts @@ -1,5 +1,7 @@ import type { GooglerpcStatus } from "@/api-client/types.gen"; +const TRANSIENT_HTTP_STATUSES = new Set([0, 401, 403, 408, 429, 502, 503, 504]); + /** * Extract error message from API error response * Handles the structure returned by @hey-api/client-fetch @@ -93,3 +95,117 @@ function looksLikeBrowserNetworkError(value: string): boolean { normalized.includes("networkerror when attempting to fetch resource") ); } + +function extractHttpStatus(error: unknown): number | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + + const candidate = error as { status?: unknown; response?: { status?: unknown } }; + if (typeof candidate.status === "number") { + return candidate.status; + } + + if (candidate.response && typeof candidate.response === "object" && typeof candidate.response.status === "number") { + return candidate.response.status; + } + + return undefined; +} + +function looksLikeNetworkFailureError(error: unknown): boolean { + if (error instanceof TypeError) { + const message = error.message ?? ""; + if (!message) { + return true; + } + return looksLikeBrowserNetworkError(message); + } + + if (error && typeof error === "object") { + const name = (error as { name?: unknown }).name; + if (name === "AbortError" || name === "NetworkError") { + return true; + } + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && looksLikeBrowserNetworkError(message)) { + return true; + } + } + + return false; +} + +/** + * Returns true when the error looks like a transient infrastructure or + * connectivity failure rather than a real application bug. These should not + * be reported as actionable errors for background/silent operations. + * + * Treated as transient: + * - HTML payloads thrown by `@hey-api/client-fetch` when the response body is + * an HTML error page from a proxy / load balancer / auth redirect. + * - `TypeError("Failed to fetch")` and other generic browser network errors. + * - HTTP statuses {0, 401, 403, 408, 429, 502, 503, 504}. + */ +export function isTransientHttpError(error: unknown): boolean { + if (typeof error === "string" && looksLikeHtmlDocument(error)) { + return true; + } + + if (looksLikeNetworkFailureError(error)) { + return true; + } + + const status = extractHttpStatus(error); + if (status !== undefined && TRANSIENT_HTTP_STATUSES.has(status)) { + return true; + } + + return false; +} + +/** + * Produce a short, telemetry-safe summary of an arbitrary error value. The + * goal is to avoid emitting full HTML response bodies (or other huge payloads) + * as log/error titles, which causes noisy and ungrouped Sentry issues. + */ +export function summarizeError(error: unknown, maxLength = 200): string { + if (error instanceof Error) { + const status = extractHttpStatus(error); + const base = error.message?.trim() || error.name || "Error"; + if (looksLikeHtmlDocument(base)) { + return status !== undefined ? `HTTP ${status} (HTML response)` : "HTML response body"; + } + const prefix = status !== undefined ? `HTTP ${status}: ` : ""; + return truncate(`${prefix}${base}`, maxLength); + } + + if (typeof error === "string") { + if (looksLikeHtmlDocument(error)) { + return "HTML response body"; + } + return truncate(error.trim(), maxLength); + } + + const status = extractHttpStatus(error); + if (status !== undefined) { + return `HTTP ${status}`; + } + + if (error && typeof error === "object") { + try { + return truncate(JSON.stringify(error), maxLength); + } catch { + return "Unknown error"; + } + } + + return "Unknown error"; +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, Math.max(0, maxLength - 1))}…`; +} diff --git a/web_src/src/pages/workflowv2/index.tsx b/web_src/src/pages/workflowv2/index.tsx index ead41835b6..11b085a704 100644 --- a/web_src/src/pages/workflowv2/index.tsx +++ b/web_src/src/pages/workflowv2/index.tsx @@ -67,7 +67,8 @@ import { useQueueHistory } from "@/hooks/useQueueHistory"; import { analytics } from "@/lib/analytics"; // getColorClass moved to workflowPageHelpers import { filterVisibleConfiguration } from "@/lib/components"; -import { getApiErrorMessage } from "@/lib/errors"; +import { getApiErrorMessage, isTransientHttpError, summarizeError } from "@/lib/errors"; +import { Sentry } from "@/sentry"; import { getIntegrationWebhookUrl } from "@/lib/integrationUtils"; import { DefaultLayoutEngine } from "@/lib/layout"; import { withOrganizationHeader } from "@/lib/withOrganizationHeader"; @@ -169,6 +170,36 @@ type QueuedCanvasSaveRequest = { reject: (error: unknown) => void; }; type CanvasEchoRelease = () => void; + +/** + * Report a canvas auto-save failure without polluting telemetry. + * + * Auto-save runs silently in the background after position drags. Transient + * failures (HTML error pages from a proxy, auth redirects, offline blips, + * 5xx gateway errors) are expected and will be retried on the next tick, so + * they are dropped here. Unexpected failures are forwarded to Sentry with a + * stable fingerprint so they group together instead of producing one issue + * per unique response body. + */ +function reportAutoSaveFailure(error: unknown): void { + // Transient failures (HTML error pages from a proxy / auth redirect, offline + // blips, 5xx gateways) are expected for a silent background save and will + // retry on the next tick — drop them so they do not flood Sentry. + if (isTransientHttpError(error)) { + return; + } + + const summary = summarizeError(error); + + Sentry.withScope((scope) => { + scope.setTag("auto_save.scope", "canvas-auto-save"); + scope.setExtra("errorSummary", summary); + scope.setFingerprint(["canvas-auto-save-failure"]); + const reportable = error instanceof Error ? error : new Error(`Canvas auto-save failed: ${summary}`); + Sentry.captureException(reportable); + }); +} + export function WorkflowPageV2() { const { organizationId, canvasId } = useParams<{ organizationId: string; @@ -1636,7 +1667,7 @@ export function WorkflowPageV2() { // Auto-save completed silently (no toast or state changes) } catch (error) { - console.error("Failed to auto-save", error); + reportAutoSaveFailure(error); } finally { if (focusedNoteId) { requestAnimationFrame(() => {