mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-02-14 14:34:29 +00:00
Compare commits
5 Commits
users/sind
...
HealthMoni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10846e3d67 | ||
|
|
b0594f7546 | ||
|
|
81ad13508b | ||
|
|
3f959e2235 | ||
|
|
67250f0f6b |
4609
package-lock.json
generated
4609
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -14,7 +14,7 @@
|
||||
"@fluentui/react": "8.119.0",
|
||||
"@fluentui/react-components": "9.54.2",
|
||||
"@jupyterlab/services": "6.0.2",
|
||||
"@jupyterlab/terminal": "3.6.1",
|
||||
"@jupyterlab/terminal": "3.6.8",
|
||||
"@microsoft/applicationinsights-web": "2.6.1",
|
||||
"@nteract/commutable": "7.5.1",
|
||||
"@nteract/connected-components": "6.8.2",
|
||||
@@ -39,12 +39,13 @@
|
||||
"@nteract/transform-plotly": "6.1.6",
|
||||
"@nteract/transform-vdom": "4.0.11",
|
||||
"@nteract/transform-vega": "7.0.6",
|
||||
"@octokit/request": "8.4.1",
|
||||
"@octokit/rest": "17.9.2",
|
||||
"@phosphor/widgets": "1.9.3",
|
||||
"@testing-library/jest-dom": "6.4.6",
|
||||
"@types/lodash": "4.14.171",
|
||||
"@types/mkdirp": "1.0.1",
|
||||
"@types/node-fetch": "2.5.7",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@xmldom/xmldom": "0.7.13",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
@@ -81,11 +82,13 @@
|
||||
"knockout": "3.5.1",
|
||||
"lodash": "4.17.23",
|
||||
"lodash-es": "4.17.23",
|
||||
"min-document": "2.19.0",
|
||||
"mkdirp": "1.0.4",
|
||||
"monaco-editor": "0.44.0",
|
||||
"ms": "2.1.3",
|
||||
"nanoid": "3.3.8",
|
||||
"p-retry": "6.2.1",
|
||||
"patch-package": "8.0.0",
|
||||
"patch-package": "8.0.1",
|
||||
"plotly.js-cartesian-dist-min": "1.52.3",
|
||||
"post-robot": "10.0.42",
|
||||
"q": "1.5.1",
|
||||
@@ -119,14 +122,21 @@
|
||||
},
|
||||
"overrides": {
|
||||
"d3-color": "3.1.0",
|
||||
"cross-spawn": "7.0.6"
|
||||
"cross-spawn": "7.0.6",
|
||||
"less-vars-loader": {
|
||||
"json5": "1.0.2"
|
||||
},
|
||||
"trim": "0.0.3",
|
||||
"@octokit/plugin-paginate-rest": "9.2.2",
|
||||
"@octokit/request-error": "5.1.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/preset-env": "7.24.7",
|
||||
"@babel/preset-react": "7.24.7",
|
||||
"@babel/preset-typescript": "7.24.7",
|
||||
"@playwright/test": "1.49.1",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@testing-library/react": "11.2.3",
|
||||
"@types/applicationinsights-js": "1.0.7",
|
||||
"@types/codemirror": "0.0.56",
|
||||
@@ -151,7 +161,7 @@
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/sanitize-html": "1.27.2",
|
||||
"@types/sinon": "2.3.3",
|
||||
"@types/styled-components": "5.1.1",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"@types/underscore": "1.7.36",
|
||||
"@types/youtube-player": "5.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.7.4",
|
||||
@@ -159,6 +169,7 @@
|
||||
"@webpack-cli/serve": "2.0.5",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "8.1.0",
|
||||
"brace-expansion": "1.1.12",
|
||||
"buffer": "5.1.0",
|
||||
"case-sensitive-paths-webpack-plugin": "2.4.0",
|
||||
"create-file-webpack": "1.0.2",
|
||||
@@ -183,6 +194,7 @@
|
||||
"jest-html-loader": "1.0.0",
|
||||
"jest-react-hooks-shallow": "1.5.1",
|
||||
"jest-trx-results-processor": "3.0.2",
|
||||
"js-yaml": "3.14.2",
|
||||
"less": "4.5.1",
|
||||
"less-loader": "11.1.3",
|
||||
"less-vars-loader": "1.1.0",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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, !!config);
|
||||
|
||||
if (!explorer) {
|
||||
return <LoadingExplorer />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
182
src/Metrics/ErrorClassification.test.ts
Normal file
182
src/Metrics/ErrorClassification.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { ARMError } from "../Utils/arm/request";
|
||||
import { isExpectedError } from "./ErrorClassification";
|
||||
|
||||
describe("ErrorClassification", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/Metrics/ErrorClassification.ts
Normal file
109
src/Metrics/ErrorClassification.ts
Normal file
@@ -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<string> = new Set([
|
||||
"AuthorizationFailed",
|
||||
"Forbidden",
|
||||
"Unauthorized",
|
||||
"AuthenticationFailed",
|
||||
"InvalidAuthenticationToken",
|
||||
"ExpiredAuthenticationToken",
|
||||
"AuthorizationPermissionMismatch",
|
||||
]);
|
||||
|
||||
// HTTP status codes that indicate expected failures
|
||||
const EXPECTED_HTTP_STATUS_CODES: Set<number> = new Set([
|
||||
401, // Unauthorized
|
||||
403, // Forbidden
|
||||
]);
|
||||
|
||||
// MSAL error codes (string)
|
||||
const EXPECTED_MSAL_ERROR_CODES: Set<string> = 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;
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, ap
|
||||
send({ platform, api, scenario, healthy: false });
|
||||
|
||||
const send = async (event: MetricEvent): Promise<Response> => {
|
||||
// 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();
|
||||
|
||||
|
||||
231
src/Metrics/ScenarioMonitor.test.ts
Normal file
231
src/Metrics/ScenarioMonitor.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ interface InternalScenarioContext {
|
||||
phases: Map<MetricPhase, PhaseContext>; // Track start/end for each phase
|
||||
timeoutId?: number;
|
||||
emitted: boolean;
|
||||
hasExpectedFailure: boolean; // Flag for expected failures (auth, firewall, etc.)
|
||||
}
|
||||
|
||||
class ScenarioMonitor {
|
||||
@@ -55,6 +56,13 @@ class ScenarioMonitor {
|
||||
});
|
||||
}
|
||||
|
||||
private devLog(msg: string) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[Metrics] ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
start(scenario: MetricScenario) {
|
||||
if (this.contexts.has(scenario)) {
|
||||
return;
|
||||
@@ -75,6 +83,7 @@ class ScenarioMonitor {
|
||||
failed: new Set<MetricPhase>(),
|
||||
phases: new Map<MetricPhase, PhaseContext>(),
|
||||
emitted: false,
|
||||
hasExpectedFailure: false,
|
||||
};
|
||||
|
||||
// Start all required phases at scenario start time
|
||||
@@ -84,6 +93,10 @@ class ScenarioMonitor {
|
||||
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
|
||||
});
|
||||
|
||||
this.devLog(
|
||||
`scenario_start: ${scenario} | phases=${config.requiredPhases.join(", ")} | timeout=${config.timeoutMs}ms`,
|
||||
);
|
||||
|
||||
traceMark(Action.MetricsScenario, {
|
||||
event: "scenario_start",
|
||||
scenario,
|
||||
@@ -91,7 +104,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);
|
||||
}
|
||||
|
||||
@@ -130,6 +147,12 @@ class ScenarioMonitor {
|
||||
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
|
||||
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
|
||||
|
||||
this.devLog(
|
||||
`phase_complete: ${scenario}.${phase} | ${
|
||||
durationMs !== null && durationMs !== undefined ? `${Math.round(durationMs)}ms` : "?"
|
||||
} | ${ctx.completed.size}/${ctx.config.requiredPhases.length} phases`,
|
||||
);
|
||||
|
||||
traceSuccess(Action.MetricsScenario, {
|
||||
event: "phase_complete",
|
||||
scenario,
|
||||
@@ -149,6 +172,13 @@ class ScenarioMonitor {
|
||||
return;
|
||||
}
|
||||
|
||||
// If an expected failure was flagged (auth, firewall, etc.), treat as success.
|
||||
if (ctx.hasExpectedFailure) {
|
||||
this.devLog(`phase_fail: ${scenario}.${phase} — expected failure, completing as healthy`);
|
||||
this.completePhase(scenario, phase);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the explicitly failed phase
|
||||
performance.mark(`scenario_${scenario}_${phase}_failed`);
|
||||
ctx.failed.add(phase);
|
||||
@@ -163,6 +193,12 @@ class ScenarioMonitor {
|
||||
// Build a snapshot with failure info
|
||||
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
|
||||
|
||||
this.devLog(
|
||||
`phase_fail: ${scenario}.${phase} | failed=[${Array.from(ctx.failed).join(", ")}] | completed=[${Array.from(
|
||||
ctx.completed,
|
||||
).join(", ")}]`,
|
||||
);
|
||||
|
||||
traceFailure(Action.MetricsScenario, {
|
||||
event: "phase_fail",
|
||||
scenario,
|
||||
@@ -171,10 +207,28 @@ class ScenarioMonitor {
|
||||
completedPhases: Array.from(ctx.completed).join(","),
|
||||
});
|
||||
|
||||
// Emit unhealthy immediately
|
||||
// Emit unhealthy immediately for unexpected failures
|
||||
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) {
|
||||
@@ -246,8 +300,22 @@ class ScenarioMonitor {
|
||||
ttfb: finalSnapshot.vitals?.ttfb,
|
||||
});
|
||||
|
||||
this.devLog(
|
||||
`scenario_end: ${ctx.scenario} | ${healthy ? "healthy" : "unhealthy"} | ${
|
||||
timedOut ? "timed out" : `${Math.round(finalSnapshot.durationMs)}ms`
|
||||
} | ${JSON.stringify({
|
||||
completedPhases: finalSnapshot.completed.join(", "),
|
||||
failedPhases: finalSnapshot.failedPhases?.join(", ") || "none",
|
||||
platform,
|
||||
api,
|
||||
phaseTimings: finalSnapshot.phaseTimings,
|
||||
vitals: finalSnapshot.vitals,
|
||||
})}`,
|
||||
);
|
||||
|
||||
// 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 +370,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();
|
||||
|
||||
@@ -7,14 +7,18 @@ import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
|
||||
* Hook to automatically complete the Interactive phase when the component becomes interactive.
|
||||
* Uses requestAnimationFrame to complete after the browser has painted.
|
||||
*/
|
||||
export function useInteractive(scenario: MetricScenario) {
|
||||
export function useInteractive(scenario: MetricScenario, enabled = true) {
|
||||
const { completePhase } = useMetricScenario();
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const id = requestAnimationFrame(() => {
|
||||
completePhase(scenario, CommonMetricPhase.Interactive);
|
||||
});
|
||||
}, [scenario, completePhase]);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [scenario, completePhase, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ class TreeNode {
|
||||
// Try three times to wait for the node to expand.
|
||||
for (let i = 0; i < RETRY_COUNT; i++) {
|
||||
try {
|
||||
await tree.waitFor({ state: "visible" });
|
||||
await tree.waitFor({ state: "visible", timeout: 30000 });
|
||||
// The tree has expanded, let's get out of here
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
89
utils/deployment-status/package-lock.json
generated
89
utils/deployment-status/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"node-fetch": "^2.6.1"
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/color-name": {
|
||||
@@ -65,6 +65,47 @@
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -81,12 +122,40 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
@@ -99,6 +168,14 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"node-fetch": "^2.6.1"
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user