Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/Common/ErrorHandlingUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { stringifyError } from "Common/stringifyError";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { SubscriptionType } from "../Contracts/SubscriptionType";
import { isExpectedError } from "../Metrics/ErrorClassification";
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
import { userContext } from "../UserContext";
import { ARMError } from "../Utils/arm/request";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
Expand Down Expand Up @@ -34,12 +32,6 @@ export const handleError = (

// checks for errors caused by firewall and sends them to portal to handle
sendNotificationForError(errorMessage, errorCode);

// Mark expected failures for health metrics (auth, firewall, permissions, etc.)
// This ensures timeouts with expected failures emit healthy instead of unhealthy
if (isExpectedError(error)) {
scenarioMonitor.markExpectedFailure();
}
};

export const getErrorMessage = (error: string | Error = ""): string => {
Expand Down
30 changes: 24 additions & 6 deletions src/Explorer/Explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import * as DataModels from "../Contracts/DataModels";
import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import { UploadDetailsRecord } from "../Contracts/ViewModels";
import { classifyError } from "../Metrics/ErrorClassification";
import MetricScenario from "../Metrics/MetricEvents";
import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig";
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
Expand Down Expand Up @@ -367,10 +368,19 @@ export default class Explorer {
return;
}

const collection: DataModels.Collection = await readCollection(databaseId, collectionId);
const resourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection);
useDatabases.setState({ resourceTokenCollection });
useSelectedNode.getState().setSelectedNode(resourceTokenCollection);
try {
const collection: DataModels.Collection = await readCollection(databaseId, collectionId);
const resourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection);
useDatabases.setState({ resourceTokenCollection });
useSelectedNode.getState().setSelectedNode(resourceTokenCollection);
} catch (error) {
scenarioMonitor.failPhase(
MetricScenario.DatabaseLoad,
ApplicationMetricPhase.DatabasesFetched,
classifyError(error),
);
throw error;
}
}

public async refreshAllDatabases(): Promise<void> {
Expand Down Expand Up @@ -412,7 +422,11 @@ export default class Explorer {
);
logConsoleError(`Error while refreshing databases: ${errorMessage}`);
useDatabases.setState({ databasesFetchedSuccessfully: false });
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
scenarioMonitor.failPhase(
MetricScenario.DatabaseLoad,
ApplicationMetricPhase.DatabasesFetched,
classifyError(error),
);
}
}

Expand Down Expand Up @@ -625,7 +639,11 @@ export default class Explorer {
},
startKey,
);
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.CollectionsLoaded);
scenarioMonitor.failPhase(
MetricScenario.DatabaseLoad,
ApplicationMetricPhase.CollectionsLoaded,
classifyError(error),
);
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/Metrics/ErrorClassification.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { ARMError } from "../Utils/arm/request";
import { isExpectedError } from "./ErrorClassification";
import { classifyError, ErrorCategory, isExpectedError } from "./ErrorClassification";

describe("ErrorClassification", () => {
describe("classifyError", () => {
it("returns a typed expected category", () => {
expect(classifyError({ status: 403 })).toBe(ErrorCategory.Expected);
});

it("returns a typed unexpected category", () => {
expect(classifyError({ status: 500 })).toBe(ErrorCategory.Unexpected);
});
});

describe("isExpectedError", () => {
describe("ARMError with expected codes", () => {
it("returns true for AuthorizationFailed code", () => {
Expand Down
34 changes: 23 additions & 11 deletions src/Metrics/ErrorClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,59 +51,71 @@ interface HttpError {
status?: number;
}

export enum ErrorCategory {
Expected = "Expected",
Unexpected = "Unexpected",
}

/**
* Determines if an error is an expected failure that should not mark the scenario as unhealthy.
* Classifies whether an error is an expected failure that should not mark the scenario as unhealthy.
*
* Expected failures include:
* - Authentication/authorization errors (user not logged in, permissions)
* - Firewall blocking errors
* - User-cancelled operations
*
* @param error - The error to classify
* @returns true if the error is expected and should not affect health metrics
* @returns the health category for the error
*/
export function isExpectedError(error: unknown): boolean {
export function classifyError(error: unknown): ErrorCategory {
if (!error) {
return false;
return ErrorCategory.Unexpected;
}

// Check ARMError code
if (error instanceof ARMError && error.code !== undefined) {
if (typeof error.code === "string" && EXPECTED_ARM_ERROR_CODES.has(error.code)) {
return true;
return ErrorCategory.Expected;
}
if (typeof error.code === "number" && EXPECTED_HTTP_STATUS_CODES.has(error.code)) {
return true;
return ErrorCategory.Expected;
}
}

// Check for MSAL AuthError (has errorCode property)
const msalError = error as MsalAuthError;
if (msalError.errorCode && typeof msalError.errorCode === "string") {
if (EXPECTED_MSAL_ERROR_CODES.has(msalError.errorCode)) {
return true;
return ErrorCategory.Expected;
}
}

// Check HTTP status on generic errors
const httpError = error as HttpError;
if (httpError.status && typeof httpError.status === "number") {
if (EXPECTED_HTTP_STATUS_CODES.has(httpError.status)) {
return true;
return ErrorCategory.Expected;
}
}

// Check for firewall error in message (the only message-based check)
if (error instanceof Error && error.message) {
if (FIREWALL_ERROR_PATTERN.test(error.message)) {
return true;
return ErrorCategory.Expected;
}
}

// Check for string errors with firewall pattern
if (typeof error === "string" && FIREWALL_ERROR_PATTERN.test(error)) {
return true;
return ErrorCategory.Expected;
}

return false;
return ErrorCategory.Unexpected;
}

/**
* Determines if an error is an expected failure that should not mark the scenario as unhealthy.
*/
export function isExpectedError(error: unknown): boolean {
return classifyError(error) === ErrorCategory.Expected;
}
6 changes: 5 additions & 1 deletion src/Metrics/MetricEvents.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { HttpHeaders } from "../Common/Constants";
import { configContext, Platform } from "../ConfigContext";
import { userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";
import { MetricScenario } from "./Constants";
Expand Down Expand Up @@ -40,11 +42,13 @@ describe("MetricEvents", () => {

const callArgs = mockFetchWithTimeout.mock.calls[0];
expect(callArgs[0]).toContain("/api/dataexplorer/metrics/health");
expect(callArgs[1]?.headers).toEqual({
expect(callArgs[1]?.headers).toMatchObject({
"Content-Type": "application/json",
authorization: "Bearer test-token",
});

expect((callArgs[1]?.headers as Record<string, string>)[HttpHeaders.sessionId]).toBe(userContext.sessionId);

const body = JSON.parse(callArgs[1]?.body as string);
expect(body.scenario).toBe(MetricScenario.ApplicationLoad);
expect(body.platform).toBe(Platform.Portal);
Expand Down
8 changes: 7 additions & 1 deletion src/Metrics/MetricEvents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Metrics module: scenario metric emission logic.
import { MetricEvent, MetricScenario } from "Metrics/Constants";
import { HttpHeaders } from "../Common/Constants";
import { createUri } from "../Common/UrlUtility";
import { configContext } from "../ConfigContext";
import { userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";

Expand All @@ -21,7 +23,11 @@ const send = async (event: MetricEvent): Promise<Response> => {

return await fetchWithTimeout(url, {
method: "POST",
headers: { "Content-Type": "application/json", [authHeader.header]: authHeader.token },
headers: {
"Content-Type": "application/json",
[authHeader.header]: authHeader.token,
[HttpHeaders.sessionId]: userContext.sessionId,
},
body: JSON.stringify(event),
});
};
Expand Down
Loading
Loading