fix(web): log dropped AI chat connections as warnings so RUM does not record them as errors

This commit is contained in:
Bhargav
2026-11-03 20:00:01 +05:30
parent 1d2a6b70d0
commit 9240196913
7 changed files with 113 additions and 7 deletions
+16
View File
@@ -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"));
+4 -1
View File
@@ -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;
}
}
+38
View File
@@ -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();
+4 -1
View File
@@ -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" });
}
}
+1 -5
View File
@@ -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
+30
View File
@@ -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 <http://www.gnu.org/licenses/>.
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);
});
});
+20
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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);