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);