From 3f959e223544e539022cd7356c6726ac39244e90 Mon Sep 17 00:00:00 2001
From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com>
Date: Mon, 9 Feb 2026 14:34:40 -0600
Subject: [PATCH] Added expected errort handling (#2368)
---
src/Common/ErrorHandlingUtils.ts | 8 +
src/Main.tsx | 4 +-
src/Metrics/ErrorClassification.test.ts | 182 +++++++++++++++++++
src/Metrics/ErrorClassification.ts | 109 +++++++++++
src/Metrics/MetricEvents.ts | 5 +
src/Metrics/ScenarioMonitor.test.ts | 231 ++++++++++++++++++++++++
src/Metrics/ScenarioMonitor.ts | 42 ++++-
src/Utils/AuthorizationUtils.ts | 16 +-
8 files changed, 592 insertions(+), 5 deletions(-)
create mode 100644 src/Metrics/ErrorClassification.test.ts
create mode 100644 src/Metrics/ErrorClassification.ts
create mode 100644 src/Metrics/ScenarioMonitor.test.ts
diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts
index ea25d8e34..f96780065 100644
--- a/src/Common/ErrorHandlingUtils.ts
+++ b/src/Common/ErrorHandlingUtils.ts
@@ -1,5 +1,7 @@
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";
@@ -31,6 +33,12 @@ 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 => {
diff --git a/src/Main.tsx b/src/Main.tsx
index c90f016ac..c279d6e0d 100644
--- a/src/Main.tsx
+++ b/src/Main.tsx
@@ -119,6 +119,9 @@ const App = (): JSX.Element => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [explorer]);
+ // Track interactive phase for both ContainerCopyPanel and DivExplorer paths
+ useInteractive(MetricScenario.ApplicationLoad);
+
if (!explorer) {
return ;
}
@@ -145,7 +148,6 @@ const App = (): JSX.Element => {
const DivExplorer: React.FC<{ explorer: Explorer }> = ({ explorer }) => {
const isCarouselOpen = useCarousel((state) => state.shouldOpen);
const isCopilotCarouselOpen = useCarousel((state) => state.showCopilotCarousel);
- useInteractive(MetricScenario.ApplicationLoad);
return (
{
+ describe("isExpectedError", () => {
+ describe("ARMError with expected codes", () => {
+ it("returns true for AuthorizationFailed code", () => {
+ const error = new ARMError("Authorization failed");
+ error.code = "AuthorizationFailed";
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for Forbidden code", () => {
+ const error = new ARMError("Forbidden");
+ error.code = "Forbidden";
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for Unauthorized code", () => {
+ const error = new ARMError("Unauthorized");
+ error.code = "Unauthorized";
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for InvalidAuthenticationToken code", () => {
+ const error = new ARMError("Invalid token");
+ error.code = "InvalidAuthenticationToken";
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for ExpiredAuthenticationToken code", () => {
+ const error = new ARMError("Token expired");
+ error.code = "ExpiredAuthenticationToken";
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for numeric 401 code", () => {
+ const error = new ARMError("Unauthorized");
+ error.code = 401;
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for numeric 403 code", () => {
+ const error = new ARMError("Forbidden");
+ error.code = 403;
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns false for unexpected ARM error code", () => {
+ const error = new ARMError("Internal error");
+ error.code = "InternalServerError";
+ expect(isExpectedError(error)).toBe(false);
+ });
+
+ it("returns false for numeric 500 code", () => {
+ const error = new ARMError("Server error");
+ error.code = 500;
+ expect(isExpectedError(error)).toBe(false);
+ });
+ });
+
+ describe("MSAL AuthError with expected errorCodes", () => {
+ it("returns true for popup_window_error", () => {
+ const error = { errorCode: "popup_window_error", message: "Popup blocked" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for interaction_required", () => {
+ const error = { errorCode: "interaction_required", message: "User interaction required" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for user_cancelled", () => {
+ const error = { errorCode: "user_cancelled", message: "User cancelled" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for consent_required", () => {
+ const error = { errorCode: "consent_required", message: "Consent required" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for login_required", () => {
+ const error = { errorCode: "login_required", message: "Login required" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for no_account_error", () => {
+ const error = { errorCode: "no_account_error", message: "No account" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns false for unexpected MSAL error code", () => {
+ const error = { errorCode: "unknown_error", message: "Unknown" };
+ expect(isExpectedError(error)).toBe(false);
+ });
+ });
+
+ describe("HTTP status codes", () => {
+ it("returns true for error with status 401", () => {
+ const error = { status: 401, message: "Unauthorized" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for error with status 403", () => {
+ const error = { status: 403, message: "Forbidden" };
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns false for error with status 500", () => {
+ const error = { status: 500, message: "Internal Server Error" };
+ expect(isExpectedError(error)).toBe(false);
+ });
+
+ it("returns false for error with status 404", () => {
+ const error = { status: 404, message: "Not Found" };
+ expect(isExpectedError(error)).toBe(false);
+ });
+ });
+
+ describe("Firewall error message pattern", () => {
+ it("returns true for firewall error in Error message", () => {
+ const error = new Error("Request blocked by firewall");
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for IP not allowed error", () => {
+ const error = new Error("Client IP address is not allowed");
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for ip not allowed (no 'address')", () => {
+ const error = new Error("Your ip not allowed to access this resource");
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns true for string error with firewall", () => {
+ expect(isExpectedError("firewall rules prevent access")).toBe(true);
+ });
+
+ it("returns true for case-insensitive firewall match", () => {
+ const error = new Error("FIREWALL blocked request");
+ expect(isExpectedError(error)).toBe(true);
+ });
+
+ it("returns false for unrelated error message", () => {
+ const error = new Error("Database connection failed");
+ expect(isExpectedError(error)).toBe(false);
+ });
+ });
+
+ describe("Edge cases", () => {
+ it("returns false for null", () => {
+ expect(isExpectedError(null)).toBe(false);
+ });
+
+ it("returns false for undefined", () => {
+ expect(isExpectedError(undefined)).toBe(false);
+ });
+
+ it("returns false for empty object", () => {
+ expect(isExpectedError({})).toBe(false);
+ });
+
+ it("returns false for plain Error without expected patterns", () => {
+ const error = new Error("Something went wrong");
+ expect(isExpectedError(error)).toBe(false);
+ });
+
+ it("returns false for string without firewall pattern", () => {
+ expect(isExpectedError("Generic error occurred")).toBe(false);
+ });
+
+ it("handles error with multiple matching criteria", () => {
+ // ARMError with both code and firewall message
+ const error = new ARMError("Request blocked by firewall");
+ error.code = "Forbidden";
+ expect(isExpectedError(error)).toBe(true);
+ });
+ });
+ });
+});
diff --git a/src/Metrics/ErrorClassification.ts b/src/Metrics/ErrorClassification.ts
new file mode 100644
index 000000000..93d1a8ae3
--- /dev/null
+++ b/src/Metrics/ErrorClassification.ts
@@ -0,0 +1,109 @@
+import { ARMError } from "../Utils/arm/request";
+
+/**
+ * Expected error codes that should not mark scenarios as unhealthy.
+ * These represent expected failures like auth issues, permission errors, and user actions.
+ */
+
+// ARM error codes (string)
+const EXPECTED_ARM_ERROR_CODES: Set = new Set([
+ "AuthorizationFailed",
+ "Forbidden",
+ "Unauthorized",
+ "AuthenticationFailed",
+ "InvalidAuthenticationToken",
+ "ExpiredAuthenticationToken",
+ "AuthorizationPermissionMismatch",
+]);
+
+// HTTP status codes that indicate expected failures
+const EXPECTED_HTTP_STATUS_CODES: Set = new Set([
+ 401, // Unauthorized
+ 403, // Forbidden
+]);
+
+// MSAL error codes (string)
+const EXPECTED_MSAL_ERROR_CODES: Set = new Set([
+ "popup_window_error",
+ "interaction_required",
+ "user_cancelled",
+ "consent_required",
+ "login_required",
+ "no_account_error",
+ "monitor_window_timeout",
+ "empty_window_error",
+]);
+
+// Firewall error message pattern (only case where we check message content)
+const FIREWALL_ERROR_PATTERN = /firewall|ip\s*(address)?\s*(is\s*)?not\s*allowed/i;
+
+/**
+ * Interface for MSAL AuthError-like objects
+ */
+interface MsalAuthError {
+ errorCode?: string;
+}
+
+/**
+ * Interface for errors with HTTP status
+ */
+interface HttpError {
+ status?: number;
+}
+
+/**
+ * Determines if 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
+ */
+export function isExpectedError(error: unknown): boolean {
+ if (!error) {
+ return false;
+ }
+
+ // Check ARMError code
+ if (error instanceof ARMError && error.code !== undefined) {
+ if (typeof error.code === "string" && EXPECTED_ARM_ERROR_CODES.has(error.code)) {
+ return true;
+ }
+ if (typeof error.code === "number" && EXPECTED_HTTP_STATUS_CODES.has(error.code)) {
+ return true;
+ }
+ }
+
+ // 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;
+ }
+ }
+
+ // 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;
+ }
+ }
+
+ // 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;
+ }
+ }
+
+ // Check for string errors with firewall pattern
+ if (typeof error === "string" && FIREWALL_ERROR_PATTERN.test(error)) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/src/Metrics/MetricEvents.ts b/src/Metrics/MetricEvents.ts
index bfd59363b..84faad1e8 100644
--- a/src/Metrics/MetricEvents.ts
+++ b/src/Metrics/MetricEvents.ts
@@ -15,6 +15,11 @@ export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, ap
send({ platform, api, scenario, healthy: false });
const send = async (event: MetricEvent): Promise => {
+ // Skip metrics emission during local development
+ if (process.env.NODE_ENV === "development") {
+ return Promise.resolve(new Response(null, { status: 200 }));
+ }
+
const url = createUri(configContext?.PORTAL_BACKEND_ENDPOINT, RELATIVE_PATH);
const authHeader = getAuthorizationHeader();
diff --git a/src/Metrics/ScenarioMonitor.test.ts b/src/Metrics/ScenarioMonitor.test.ts
new file mode 100644
index 000000000..dfd0db17e
--- /dev/null
+++ b/src/Metrics/ScenarioMonitor.test.ts
@@ -0,0 +1,231 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { configContext } from "../ConfigContext";
+import { updateUserContext } from "../UserContext";
+import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
+import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
+import { scenarioMonitor } from "./ScenarioMonitor";
+
+// Mock the MetricEvents module
+jest.mock("./MetricEvents", () => ({
+ __esModule: true,
+ default: {
+ ApplicationLoad: "ApplicationLoad",
+ DatabaseLoad: "DatabaseLoad",
+ },
+ reportHealthy: jest.fn().mockResolvedValue({ ok: true }),
+ reportUnhealthy: jest.fn().mockResolvedValue({ ok: true }),
+}));
+
+// Mock configContext
+jest.mock("../ConfigContext", () => ({
+ configContext: {
+ platform: "Portal",
+ PORTAL_BACKEND_ENDPOINT: "https://test.portal.azure.com",
+ },
+ Platform: {
+ Portal: "Portal",
+ Hosted: "Hosted",
+ Emulator: "Emulator",
+ Fabric: "Fabric",
+ },
+}));
+
+describe("ScenarioMonitor", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Use legacy fake timers to avoid conflicts with performance API
+ jest.useFakeTimers({ legacyFakeTimers: true });
+
+ // Ensure performance mock is available (setupTests.ts sets this but fake timers may override)
+ if (typeof performance.mark !== "function") {
+ Object.defineProperty(global, "performance", {
+ writable: true,
+ configurable: true,
+ value: {
+ mark: jest.fn(),
+ measure: jest.fn(),
+ clearMarks: jest.fn(),
+ clearMeasures: jest.fn(),
+ getEntriesByName: jest.fn().mockReturnValue([{ startTime: 0 }]),
+ getEntriesByType: jest.fn().mockReturnValue([]),
+ now: jest.fn(() => Date.now()),
+ timeOrigin: Date.now(),
+ },
+ });
+ }
+
+ // Reset userContext
+ updateUserContext({
+ apiType: "SQL",
+ });
+
+ // Reset the scenario monitor to clear any previous state
+ scenarioMonitor.reset();
+ });
+
+ afterEach(() => {
+ // Reset scenarios before switching to real timers
+ scenarioMonitor.reset();
+ jest.useRealTimers();
+ });
+
+ describe("markExpectedFailure", () => {
+ it("sets hasExpectedFailure flag on active scenarios", () => {
+ // Start a scenario
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Mark expected failure
+ scenarioMonitor.markExpectedFailure();
+
+ // Let timeout fire - should emit healthy because of expected failure
+ jest.advanceTimersByTime(10000);
+
+ expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
+ expect(reportUnhealthy).not.toHaveBeenCalled();
+ });
+
+ it("sets flag on multiple active scenarios", () => {
+ // Start two scenarios
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+ scenarioMonitor.start(MetricScenario.DatabaseLoad);
+
+ // Mark expected failure - should affect both
+ scenarioMonitor.markExpectedFailure();
+
+ // Let timeouts fire
+ jest.advanceTimersByTime(10000);
+
+ expect(reportHealthy).toHaveBeenCalledTimes(2);
+ expect(reportUnhealthy).not.toHaveBeenCalled();
+ });
+
+ it("does not affect already emitted scenarios", () => {
+ // Start scenario
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Complete all phases to emit
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
+
+ // Now mark expected failure - should not change anything
+ scenarioMonitor.markExpectedFailure();
+
+ // Healthy was called when phases completed
+ expect(reportHealthy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("timeout behavior", () => {
+ it("emits unhealthy on timeout without expected failure", () => {
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Let timeout fire without marking expected failure
+ jest.advanceTimersByTime(10000);
+
+ expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
+ expect(reportHealthy).not.toHaveBeenCalled();
+ });
+
+ it("emits healthy on timeout with expected failure", () => {
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Mark expected failure
+ scenarioMonitor.markExpectedFailure();
+
+ // Let timeout fire
+ jest.advanceTimersByTime(10000);
+
+ expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
+ expect(reportUnhealthy).not.toHaveBeenCalled();
+ });
+
+ it("emits healthy even with partial phase completion and expected failure", () => {
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Complete one phase
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
+
+ // Mark expected failure
+ scenarioMonitor.markExpectedFailure();
+
+ // Let timeout fire (Interactive phase not completed)
+ jest.advanceTimersByTime(10000);
+
+ expect(reportHealthy).toHaveBeenCalled();
+ expect(reportUnhealthy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("failPhase behavior", () => {
+ it("emits unhealthy immediately on unexpected failure", () => {
+ scenarioMonitor.start(MetricScenario.DatabaseLoad);
+
+ // Fail a phase (simulating unexpected error)
+ scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
+
+ // Should emit unhealthy immediately, not wait for timeout
+ expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.DatabaseLoad, configContext.platform, "SQL");
+ });
+
+ it("does not emit twice after failPhase and timeout", () => {
+ scenarioMonitor.start(MetricScenario.DatabaseLoad);
+
+ // Fail a phase
+ scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
+
+ // Let timeout fire
+ jest.advanceTimersByTime(10000);
+
+ // Should only have emitted once (from failPhase)
+ expect(reportUnhealthy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("completePhase behavior", () => {
+ it("emits healthy when all phases complete", () => {
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Complete all required phases
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
+
+ expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
+ });
+
+ it("does not emit until all phases complete", () => {
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+
+ // Complete only one phase
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
+
+ expect(reportHealthy).not.toHaveBeenCalled();
+ expect(reportUnhealthy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("scenario isolation", () => {
+ it("expected failure on one scenario does not affect others after completion", () => {
+ // Start both scenarios
+ scenarioMonitor.start(MetricScenario.ApplicationLoad);
+ scenarioMonitor.start(MetricScenario.DatabaseLoad);
+
+ // Complete ApplicationLoad
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
+ scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
+
+ // Now mark expected failure - should only affect DatabaseLoad
+ scenarioMonitor.markExpectedFailure();
+
+ // Let DatabaseLoad timeout
+ jest.advanceTimersByTime(10000);
+
+ // ApplicationLoad emitted healthy on completion
+ // DatabaseLoad emits healthy on timeout (expected failure)
+ expect(reportHealthy).toHaveBeenCalledTimes(2);
+ expect(reportUnhealthy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts
index 73e3fbcf7..4c4fa88c2 100644
--- a/src/Metrics/ScenarioMonitor.ts
+++ b/src/Metrics/ScenarioMonitor.ts
@@ -21,6 +21,7 @@ interface InternalScenarioContext {
phases: Map; // Track start/end for each phase
timeoutId?: number;
emitted: boolean;
+ hasExpectedFailure: boolean; // Flag for expected failures (auth, firewall, etc.)
}
class ScenarioMonitor {
@@ -75,6 +76,7 @@ class ScenarioMonitor {
failed: new Set(),
phases: new Map(),
emitted: false,
+ hasExpectedFailure: false,
};
// Start all required phases at scenario start time
@@ -91,7 +93,11 @@ class ScenarioMonitor {
timeoutMs: config.timeoutMs,
});
- ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
+ ctx.timeoutId = window.setTimeout(() => {
+ // If an expected failure occurred (auth, firewall, etc.), emit healthy instead of unhealthy
+ const healthy = ctx.hasExpectedFailure;
+ this.emit(ctx, healthy, true);
+ }, config.timeoutMs);
this.contexts.set(scenario, ctx);
}
@@ -175,6 +181,24 @@ class ScenarioMonitor {
this.emit(ctx, false, false, failureSnapshot);
}
+ /**
+ * Marks that an expected failure occurred (auth, firewall, permissions, etc.).
+ * When the scenario times out with this flag set, it will emit healthy instead of unhealthy.
+ * This is called automatically from handleError when an expected error is detected.
+ */
+ markExpectedFailure() {
+ // Set the flag on all active (non-emitted) scenarios
+ this.contexts.forEach((ctx) => {
+ if (!ctx.emitted) {
+ ctx.hasExpectedFailure = true;
+ traceMark(Action.MetricsScenario, {
+ event: "expected_failure_marked",
+ scenario: ctx.scenario,
+ });
+ }
+ });
+ }
+
private tryEmitIfReady(ctx: InternalScenarioContext) {
const allDone = ctx.config.requiredPhases.every((p) => ctx.completed.has(p));
if (!allDone) {
@@ -247,7 +271,8 @@ class ScenarioMonitor {
});
// Call portal backend health metrics endpoint
- if (healthy && !timedOut) {
+ // If healthy is true (either completed successfully or timeout with expected failure), report healthy
+ if (healthy) {
reportHealthy(ctx.scenario, platform, api);
} else {
reportUnhealthy(ctx.scenario, platform, api);
@@ -302,6 +327,19 @@ class ScenarioMonitor {
phaseTimings,
};
}
+
+ /**
+ * Reset all scenarios (for testing purposes only).
+ * Clears all active contexts and their timeouts.
+ */
+ reset() {
+ this.contexts.forEach((ctx) => {
+ if (ctx.timeoutId) {
+ clearTimeout(ctx.timeoutId);
+ }
+ });
+ this.contexts.clear();
+ }
}
export const scenarioMonitor = new ScenarioMonitor();
diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts
index 84d4cfcc4..26dcd5d5d 100644
--- a/src/Utils/AuthorizationUtils.ts
+++ b/src/Utils/AuthorizationUtils.ts
@@ -8,6 +8,8 @@ import * as Logger from "../Common/Logger";
import { configContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
+import { isExpectedError } from "../Metrics/ErrorClassification";
+import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
import { trace, traceFailure } from "../Shared/Telemetry/TelemetryProcessor";
import { UserContext, userContext } from "../UserContext";
@@ -127,6 +129,10 @@ export async function acquireMsalTokenForAccount(
acquireTokenType: silent ? "silent" : "interactive",
errorMessage: JSON.stringify(error),
});
+ // Mark expected failure for health metrics so timeout emits healthy
+ if (isExpectedError(error)) {
+ scenarioMonitor.markExpectedFailure();
+ }
throw error;
}
} else {
@@ -169,7 +175,10 @@ export async function acquireTokenWithMsal(
acquireTokenType: "interactive",
errorMessage: JSON.stringify(interactiveError),
});
-
+ // Mark expected failure for health metrics so timeout emits healthy
+ if (isExpectedError(interactiveError)) {
+ scenarioMonitor.markExpectedFailure();
+ }
throw interactiveError;
}
} else {
@@ -178,7 +187,10 @@ export async function acquireTokenWithMsal(
acquireTokenType: "silent",
errorMessage: JSON.stringify(silentError),
});
-
+ // Mark expected failure for health metrics so timeout emits healthy
+ if (isExpectedError(silentError)) {
+ scenarioMonitor.markExpectedFailure();
+ }
throw silentError;
}
}