diff --git a/web/src/composables/useAiChat.spec.ts b/web/src/composables/useAiChat.spec.ts index 8a16cf84fd..a3e2fd0bdb 100644 --- a/web/src/composables/useAiChat.spec.ts +++ b/web/src/composables/useAiChat.spec.ts @@ -486,6 +486,22 @@ describe("useAiChat", () => { expect(result).toBe(mockResponse); }); + it("should log a dropped connection as a warning, not an error", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = new TypeError("Failed to fetch"); + mockFetch.mockRejectedValue(error); + + const result = await aiChatComposable.fetchAiChat(mockMessages, "gpt-4", "test-org"); + + expect(result).toBeNull(); + expect(errorSpy).not.toHaveBeenCalledWith("Error fetching AI chat:", expect.anything()); + expect(warnSpy).toHaveBeenCalledWith("Error fetching AI chat:", error); + + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + it("should return null when fetch throws an error", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); mockFetch.mockRejectedValue(new Error("Network error")); diff --git a/web/src/composables/useAiChat.ts b/web/src/composables/useAiChat.ts index f7bb566ed2..55cceeba76 100644 --- a/web/src/composables/useAiChat.ts +++ b/web/src/composables/useAiChat.ts @@ -3,6 +3,7 @@ import { contextRegistry, createDefaultContextProvider } from "@/composables/con import { generateTraceContext, getUUIDv7 } from "@/utils/zincutils"; import type { ImageAttachment } from "@/ts/interfaces/chat"; import analytics from "@/services/product_analytics"; +import { isNetworkFailure } from "@/utils/networkFailure"; let contextHandler: any; @@ -140,7 +141,9 @@ const useAiChat = () => { // Return a special response to indicate cancellation return { cancelled: true, error }; } else { - console.error("Error fetching AI chat:", error); + // A dropped connection is not an app defect, and RUM records console.error. + if (isNetworkFailure(error)) console.warn("Error fetching AI chat:", error); + else console.error("Error fetching AI chat:", error); return null; } } diff --git a/web/src/composables/useChatStream.spec.ts b/web/src/composables/useChatStream.spec.ts index 13dd22ad21..bffdbeee69 100644 --- a/web/src/composables/useChatStream.spec.ts +++ b/web/src/composables/useChatStream.spec.ts @@ -183,6 +183,44 @@ describe("useChatStream", () => { await turn; }); + describe("dropped stream", () => { + const droppingResponse = (error: Error) => ({ + ok: true, + body: { + getReader: () => ({ + read: async () => { + throw error; + }, + }), + }, + }); + + it("logs a dropped connection as a warning, not an error", async () => { + const error = new TypeError("network error"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + mockFetchAiChat.mockResolvedValue(droppingResponse(error)); + const { stream } = makeStream(); + + await stream.runTurn(false, []); + + expect(errorSpy).not.toHaveBeenCalledWith("Error reading stream:", expect.anything()); + expect(warnSpy).toHaveBeenCalledWith("Error reading stream:", error); + expect(trackedEvents()).toContain("ai_assistant_answer_failed"); + }); + + it("still logs an unexpected read failure as an error", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const error = new Error("boom"); + mockFetchAiChat.mockResolvedValue(droppingResponse(error)); + const { stream } = makeStream(); + + await stream.runTurn(false, []); + + expect(errorSpy).toHaveBeenCalledWith("Error reading stream:", error); + }); + }); + it("runs teardown when the fetch throws, clearing loading and the controller", async () => { mockFetchAiChat.mockRejectedValue(new Error("network")); const { stream } = makeStream(); diff --git a/web/src/composables/useChatStream.ts b/web/src/composables/useChatStream.ts index 4c03db89ab..acd75c61b8 100644 --- a/web/src/composables/useChatStream.ts +++ b/web/src/composables/useChatStream.ts @@ -34,6 +34,7 @@ import { } from "@/components/O2AIChat.reducer"; import { isChatPersistenceEnabled } from "@/components/ai-assistant/share/chatShare"; import useAiChat from "@/composables/useAiChat"; +import { isNetworkFailure } from "@/utils/networkFailure"; import { isPaidOverageConsentError, usePaidOverageConsent, @@ -572,7 +573,9 @@ export function useChatStream(options: UseChatStreamOptions) { } return; } else { - console.error("Error reading stream:", error); + // A dropped connection is not an app defect, and RUM records console.error. + if (isNetworkFailure(error)) console.warn("Error reading stream:", error); + else console.error("Error reading stream:", error); trackAnswerOutcome(turn, "ai_assistant_answer_failed", { stage: "stream" }); } } diff --git a/web/src/composables/useStreamingSearch.ts b/web/src/composables/useStreamingSearch.ts index 42967fd077..613e564b6f 100644 --- a/web/src/composables/useStreamingSearch.ts +++ b/web/src/composables/useStreamingSearch.ts @@ -22,6 +22,7 @@ import { getUUID } from "@/utils/zincutils"; import { expireSession } from "@/utils/sessionServices"; import { attemptTokenRefresh } from "@/services/http"; import { patchLargeNumbersInJson } from "@/utils/nsFieldsPatch"; +import { isNetworkFailure } from "@/utils/networkFailure"; // Create and manage stream workers let streamWorker: Worker | null = null; @@ -34,11 +35,6 @@ const createStreamWorker = () => { return streamWorker; }; -// Chrome, Safari and Firefox wordings of a fetch body read that lost its connection. -const NETWORK_FAILURE_PATTERN = /network error|Load failed|Failed to fetch|NetworkError/i; -const isNetworkFailure = (error: unknown): boolean => - error instanceof TypeError && NETWORK_FAILURE_PATTERN.test(error.message); - // Status and message sit on prototypes of a Response or Error, which the error spread would drop. const withErrorFields = (error: unknown): unknown => error instanceof Response diff --git a/web/src/utils/networkFailure.spec.ts b/web/src/utils/networkFailure.spec.ts new file mode 100644 index 0000000000..e845325419 --- /dev/null +++ b/web/src/utils/networkFailure.spec.ts @@ -0,0 +1,30 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import { isNetworkFailure } from "@/utils/networkFailure"; + +describe("isNetworkFailure", () => { + it.each(["network error", "Load failed", "Failed to fetch", "NetworkError when attempting"])( + "matches a TypeError saying %s", + (message) => expect(isNetworkFailure(new TypeError(message))).toBe(true), + ); + + it("rejects other errors", () => { + expect(isNetworkFailure(new Error("network error"))).toBe(false); + expect(isNetworkFailure(new TypeError("x is undefined"))).toBe(false); + expect(isNetworkFailure("Failed to fetch")).toBe(false); + }); +}); diff --git a/web/src/utils/networkFailure.ts b/web/src/utils/networkFailure.ts new file mode 100644 index 0000000000..bd7d187aa8 --- /dev/null +++ b/web/src/utils/networkFailure.ts @@ -0,0 +1,20 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Chrome, Safari and Firefox wordings of a fetch body read that lost its connection. +const NETWORK_FAILURE_PATTERN = /network error|Load failed|Failed to fetch|NetworkError/i; + +export const isNetworkFailure = (error: unknown): boolean => + error instanceof TypeError && NETWORK_FAILURE_PATTERN.test(error.message);