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(() => {