mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-04-18 04:19:11 +01:00
Added enriched metrics (#2432)
This commit is contained in:
@@ -7,10 +7,39 @@ export enum MetricScenario {
|
|||||||
DatabaseLoad = "DatabaseLoad",
|
DatabaseLoad = "DatabaseLoad",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebVitals {
|
||||||
|
lcp?: number; // Largest Contentful Paint
|
||||||
|
inp?: number; // Interaction to Next Paint
|
||||||
|
cls?: number; // Cumulative Layout Shift
|
||||||
|
fcp?: number; // First Contentful Paint
|
||||||
|
ttfb?: number; // Time to First Byte
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseTimings {
|
||||||
|
endTimeISO: string; // When the phase completed
|
||||||
|
durationMs: number; // Duration from scenario start to phase completion
|
||||||
|
}
|
||||||
|
|
||||||
// Generic metric emission event describing scenario outcome.
|
// Generic metric emission event describing scenario outcome.
|
||||||
export interface MetricEvent {
|
export interface MetricEvent {
|
||||||
|
// === Existing required fields (unchanged) ===
|
||||||
readonly platform: Platform;
|
readonly platform: Platform;
|
||||||
readonly api: ApiType;
|
readonly api: ApiType;
|
||||||
readonly scenario: MetricScenario;
|
readonly scenario: MetricScenario;
|
||||||
readonly healthy: boolean;
|
readonly healthy: boolean;
|
||||||
|
|
||||||
|
// === New optional fields ===
|
||||||
|
readonly durationMs?: number;
|
||||||
|
readonly timedOut?: boolean;
|
||||||
|
readonly documentHidden?: boolean;
|
||||||
|
readonly hasExpectedFailure?: boolean;
|
||||||
|
|
||||||
|
readonly completedPhases?: string[];
|
||||||
|
readonly failedPhases?: string[];
|
||||||
|
readonly phaseTimings?: Record<string, PhaseTimings>;
|
||||||
|
|
||||||
|
readonly startTimeISO?: string;
|
||||||
|
readonly endTimeISO?: string;
|
||||||
|
|
||||||
|
readonly vitals?: WebVitals;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { configContext, Platform } from "../ConfigContext";
|
import { configContext, Platform } from "../ConfigContext";
|
||||||
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
||||||
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";
|
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";
|
||||||
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
|
import { MetricScenario } from "./Constants";
|
||||||
|
import { reportMetric } from "./MetricEvents";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { Response } = require("node-fetch");
|
const { Response } = require("node-fetch");
|
||||||
@@ -21,11 +22,16 @@ describe("MetricEvents", () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reportHealthy success includes auth header", async () => {
|
test("reportMetric success includes auth header", async () => {
|
||||||
const mockResponse = new Response(null, { status: 200 });
|
const mockResponse = new Response(null, { status: 200 });
|
||||||
mockFetchWithTimeout.mockResolvedValue(mockResponse);
|
mockFetchWithTimeout.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await reportHealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL");
|
const result = await reportMetric({
|
||||||
|
platform: Platform.Portal,
|
||||||
|
api: "SQL",
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
expect(result).toBeInstanceOf(Response);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
@@ -47,39 +53,77 @@ describe("MetricEvents", () => {
|
|||||||
expect(getAuthorizationHeader).toHaveBeenCalled();
|
expect(getAuthorizationHeader).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reportUnhealthy failure status", async () => {
|
test("reportMetric sends full enriched payload", async () => {
|
||||||
const mockResponse = new Response("Failure", { status: 500 });
|
const mockResponse = new Response(null, { status: 200 });
|
||||||
mockFetchWithTimeout.mockResolvedValue(mockResponse);
|
mockFetchWithTimeout.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const result = await reportUnhealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL");
|
const event = {
|
||||||
|
platform: Platform.Portal,
|
||||||
|
api: "SQL" as const,
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: true,
|
||||||
|
durationMs: 2847,
|
||||||
|
timedOut: false,
|
||||||
|
documentHidden: false,
|
||||||
|
hasExpectedFailure: false,
|
||||||
|
completedPhases: ["ExplorerInitialized", "Interactive"],
|
||||||
|
failedPhases: [] as string[],
|
||||||
|
phaseTimings: {
|
||||||
|
ExplorerInitialized: { endTimeISO: "2026-03-13T10:00:02.500Z", durationMs: 2500 },
|
||||||
|
Interactive: { endTimeISO: "2026-03-13T10:00:02.847Z", durationMs: 2847 },
|
||||||
|
},
|
||||||
|
startTimeISO: "2026-03-13T10:00:00.000Z",
|
||||||
|
endTimeISO: "2026-03-13T10:00:02.847Z",
|
||||||
|
vitals: { lcp: 1850, inp: 120, cls: 0.05, fcp: 980, ttfb: 340 },
|
||||||
|
};
|
||||||
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
const result = await reportMetric(event);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.status).toBe(200);
|
||||||
expect(result.status).toBe(500);
|
|
||||||
|
const callArgs = mockFetchWithTimeout.mock.calls[0];
|
||||||
|
const body = JSON.parse(callArgs[1]?.body as string);
|
||||||
|
expect(body.healthy).toBe(true);
|
||||||
|
expect(body.durationMs).toBe(2847);
|
||||||
|
expect(body.timedOut).toBe(false);
|
||||||
|
expect(body.documentHidden).toBe(false);
|
||||||
|
expect(body.hasExpectedFailure).toBe(false);
|
||||||
|
expect(body.completedPhases).toEqual(["ExplorerInitialized", "Interactive"]);
|
||||||
|
expect(body.failedPhases).toEqual([]);
|
||||||
|
expect(body.phaseTimings.ExplorerInitialized.durationMs).toBe(2500);
|
||||||
|
expect(body.startTimeISO).toBe("2026-03-13T10:00:00.000Z");
|
||||||
|
expect(body.endTimeISO).toBe("2026-03-13T10:00:02.847Z");
|
||||||
|
expect(body.vitals.lcp).toBe(1850);
|
||||||
|
expect(body.vitals.cls).toBe(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reportMetric omits undefined optional fields", async () => {
|
||||||
|
const mockResponse = new Response(null, { status: 200 });
|
||||||
|
mockFetchWithTimeout.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
platform: Platform.Portal,
|
||||||
|
api: "SQL" as const,
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await reportMetric(event);
|
||||||
|
|
||||||
const callArgs = mockFetchWithTimeout.mock.calls[0];
|
const callArgs = mockFetchWithTimeout.mock.calls[0];
|
||||||
const body = JSON.parse(callArgs[1]?.body as string);
|
const body = JSON.parse(callArgs[1]?.body as string);
|
||||||
expect(body.healthy).toBe(false);
|
expect(body.healthy).toBe(false);
|
||||||
});
|
expect(body.durationMs).toBeUndefined();
|
||||||
|
expect(body.vitals).toBeUndefined();
|
||||||
test("helpers healthy/unhealthy", async () => {
|
expect(body.completedPhases).toBeUndefined();
|
||||||
mockFetchWithTimeout.mockResolvedValue(new Response(null, { status: 201 }));
|
|
||||||
|
|
||||||
const healthyResult = await reportHealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL");
|
|
||||||
const unhealthyResult = await reportUnhealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL");
|
|
||||||
|
|
||||||
expect(healthyResult.status).toBe(201);
|
|
||||||
expect(unhealthyResult.status).toBe(201);
|
|
||||||
expect(mockFetchWithTimeout).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws when backend endpoint missing", async () => {
|
test("throws when backend endpoint missing", async () => {
|
||||||
const original = configContext.PORTAL_BACKEND_ENDPOINT;
|
const original = configContext.PORTAL_BACKEND_ENDPOINT;
|
||||||
(configContext as { PORTAL_BACKEND_ENDPOINT: string }).PORTAL_BACKEND_ENDPOINT = "";
|
(configContext as { PORTAL_BACKEND_ENDPOINT: string }).PORTAL_BACKEND_ENDPOINT = "";
|
||||||
|
|
||||||
await expect(reportHealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL")).rejects.toThrow(
|
await expect(
|
||||||
"baseUri is null or empty",
|
reportMetric({ platform: Platform.Portal, api: "SQL", scenario: MetricScenario.ApplicationLoad, healthy: true }),
|
||||||
);
|
).rejects.toThrow("baseUri is null or empty");
|
||||||
|
|
||||||
expect(mockFetchWithTimeout).not.toHaveBeenCalled();
|
expect(mockFetchWithTimeout).not.toHaveBeenCalled();
|
||||||
(configContext as { PORTAL_BACKEND_ENDPOINT: string }).PORTAL_BACKEND_ENDPOINT = original;
|
(configContext as { PORTAL_BACKEND_ENDPOINT: string }).PORTAL_BACKEND_ENDPOINT = original;
|
||||||
@@ -88,17 +132,17 @@ describe("MetricEvents", () => {
|
|||||||
test("propagates fetch errors", async () => {
|
test("propagates fetch errors", async () => {
|
||||||
mockFetchWithTimeout.mockRejectedValue(new Error("Network error"));
|
mockFetchWithTimeout.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
await expect(reportHealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL")).rejects.toThrow(
|
await expect(
|
||||||
"Network error",
|
reportMetric({ platform: Platform.Portal, api: "SQL", scenario: MetricScenario.ApplicationLoad, healthy: true }),
|
||||||
);
|
).rejects.toThrow("Network error");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("propagates timeout errors", async () => {
|
test("propagates timeout errors", async () => {
|
||||||
const abortError = new DOMException("The operation was aborted", "AbortError");
|
const abortError = new DOMException("The operation was aborted", "AbortError");
|
||||||
mockFetchWithTimeout.mockRejectedValue(abortError);
|
mockFetchWithTimeout.mockRejectedValue(abortError);
|
||||||
|
|
||||||
await expect(reportUnhealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL")).rejects.toThrow(
|
await expect(
|
||||||
"The operation was aborted",
|
reportMetric({ platform: Platform.Portal, api: "SQL", scenario: MetricScenario.ApplicationLoad, healthy: false }),
|
||||||
);
|
).rejects.toThrow("The operation was aborted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
// Metrics module: scenario metric emission logic.
|
// Metrics module: scenario metric emission logic.
|
||||||
import { MetricEvent, MetricScenario } from "Metrics/Constants";
|
import { MetricEvent, MetricScenario } from "Metrics/Constants";
|
||||||
import { createUri } from "../Common/UrlUtility";
|
import { createUri } from "../Common/UrlUtility";
|
||||||
import { configContext, Platform } from "../ConfigContext";
|
import { configContext } from "../ConfigContext";
|
||||||
import { ApiType } from "../UserContext";
|
|
||||||
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
|
||||||
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";
|
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";
|
||||||
|
|
||||||
const RELATIVE_PATH = "/api/dataexplorer/metrics/health"; // Endpoint retains 'health' for backend compatibility.
|
const RELATIVE_PATH = "/api/dataexplorer/metrics/health"; // Endpoint retains 'health' for backend compatibility.
|
||||||
|
|
||||||
export const reportHealthy = (scenario: MetricScenario, platform: Platform, api: ApiType): Promise<Response> =>
|
/** Send a full enriched MetricEvent to the backend. */
|
||||||
send({ platform, api, scenario, healthy: true });
|
export const reportMetric = (event: MetricEvent): Promise<Response> => send(event);
|
||||||
|
|
||||||
export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, api: ApiType): Promise<Response> =>
|
|
||||||
send({ platform, api, scenario, healthy: false });
|
|
||||||
|
|
||||||
const send = async (event: MetricEvent): Promise<Response> => {
|
const send = async (event: MetricEvent): Promise<Response> => {
|
||||||
// Skip metrics emission during local development
|
// Skip metrics emission during local development
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PhaseTimings, WebVitals } from "Metrics/Constants";
|
||||||
import MetricScenario from "./MetricEvents";
|
import MetricScenario from "./MetricEvents";
|
||||||
|
|
||||||
// Common phases shared across all scenarios
|
// Common phases shared across all scenarios
|
||||||
@@ -15,25 +16,12 @@ export enum ApplicationMetricPhase {
|
|||||||
// Combined type for all metric phases
|
// Combined type for all metric phases
|
||||||
export type MetricPhase = CommonMetricPhase | ApplicationMetricPhase;
|
export type MetricPhase = CommonMetricPhase | ApplicationMetricPhase;
|
||||||
|
|
||||||
export interface WebVitals {
|
|
||||||
lcp?: number; // Largest Contentful Paint
|
|
||||||
inp?: number; // Interaction to Next Paint
|
|
||||||
cls?: number; // Cumulative Layout Shift
|
|
||||||
fcp?: number; // First Contentful Paint
|
|
||||||
ttfb?: number; // Time to First Byte
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScenarioConfig<TPhase extends string = MetricPhase> {
|
export interface ScenarioConfig<TPhase extends string = MetricPhase> {
|
||||||
requiredPhases: TPhase[];
|
requiredPhases: TPhase[];
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
validate?: (ctx: ScenarioContextSnapshot<TPhase>) => boolean; // Optional custom validation
|
validate?: (ctx: ScenarioContextSnapshot<TPhase>) => boolean; // Optional custom validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhaseTimings {
|
|
||||||
endTimeISO: string; // When the phase completed
|
|
||||||
durationMs: number; // Duration from scenario start to phase completion
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScenarioContextSnapshot<TPhase extends string = MetricPhase> {
|
export interface ScenarioContextSnapshot<TPhase extends string = MetricPhase> {
|
||||||
scenario: MetricScenario;
|
scenario: MetricScenario;
|
||||||
startTimeISO: string; // Human-readable ISO timestamp
|
startTimeISO: string; // Human-readable ISO timestamp
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
* @jest-environment jsdom
|
* @jest-environment jsdom
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { configContext } from "../ConfigContext";
|
|
||||||
import { updateUserContext } from "../UserContext";
|
import { updateUserContext } from "../UserContext";
|
||||||
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
|
import MetricScenario, { reportMetric } from "./MetricEvents";
|
||||||
import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
|
import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
|
||||||
import { scenarioMonitor } from "./ScenarioMonitor";
|
import { scenarioMonitor } from "./ScenarioMonitor";
|
||||||
|
|
||||||
@@ -15,8 +14,7 @@ jest.mock("./MetricEvents", () => ({
|
|||||||
ApplicationLoad: "ApplicationLoad",
|
ApplicationLoad: "ApplicationLoad",
|
||||||
DatabaseLoad: "DatabaseLoad",
|
DatabaseLoad: "DatabaseLoad",
|
||||||
},
|
},
|
||||||
reportHealthy: jest.fn().mockResolvedValue({ ok: true }),
|
reportMetric: jest.fn().mockResolvedValue({ ok: true }),
|
||||||
reportUnhealthy: jest.fn().mockResolvedValue({ ok: true }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock configContext
|
// Mock configContext
|
||||||
@@ -83,8 +81,14 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Let timeout fire - should emit healthy because of expected failure
|
// Let timeout fire - should emit healthy because of expected failure
|
||||||
jest.advanceTimersByTime(10000);
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
|
expect(reportMetric).toHaveBeenCalledWith(
|
||||||
expect(reportUnhealthy).not.toHaveBeenCalled();
|
expect.objectContaining({
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: true,
|
||||||
|
hasExpectedFailure: true,
|
||||||
|
timedOut: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets flag on multiple active scenarios", () => {
|
it("sets flag on multiple active scenarios", () => {
|
||||||
@@ -98,8 +102,8 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Let timeouts fire
|
// Let timeouts fire
|
||||||
jest.advanceTimersByTime(10000);
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
expect(reportHealthy).toHaveBeenCalledTimes(2);
|
expect(reportMetric).toHaveBeenCalledTimes(2);
|
||||||
expect(reportUnhealthy).not.toHaveBeenCalled();
|
expect(reportMetric).toHaveBeenCalledWith(expect.objectContaining({ healthy: true, hasExpectedFailure: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not affect already emitted scenarios", () => {
|
it("does not affect already emitted scenarios", () => {
|
||||||
@@ -113,8 +117,9 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Now mark expected failure - should not change anything
|
// Now mark expected failure - should not change anything
|
||||||
scenarioMonitor.markExpectedFailure();
|
scenarioMonitor.markExpectedFailure();
|
||||||
|
|
||||||
// Healthy was called when phases completed
|
// reportMetric was called when phases completed
|
||||||
expect(reportHealthy).toHaveBeenCalledTimes(1);
|
expect(reportMetric).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reportMetric).toHaveBeenCalledWith(expect.objectContaining({ healthy: true }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,8 +130,13 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Let timeout fire without marking expected failure
|
// Let timeout fire without marking expected failure
|
||||||
jest.advanceTimersByTime(10000);
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
|
expect(reportMetric).toHaveBeenCalledWith(
|
||||||
expect(reportHealthy).not.toHaveBeenCalled();
|
expect.objectContaining({
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: false,
|
||||||
|
timedOut: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits healthy on timeout with expected failure", () => {
|
it("emits healthy on timeout with expected failure", () => {
|
||||||
@@ -138,8 +148,14 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Let timeout fire
|
// Let timeout fire
|
||||||
jest.advanceTimersByTime(10000);
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
|
expect(reportMetric).toHaveBeenCalledWith(
|
||||||
expect(reportUnhealthy).not.toHaveBeenCalled();
|
expect.objectContaining({
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: true,
|
||||||
|
timedOut: true,
|
||||||
|
hasExpectedFailure: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits healthy even with partial phase completion and expected failure", () => {
|
it("emits healthy even with partial phase completion and expected failure", () => {
|
||||||
@@ -154,8 +170,14 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Let timeout fire (Interactive phase not completed)
|
// Let timeout fire (Interactive phase not completed)
|
||||||
jest.advanceTimersByTime(10000);
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
expect(reportHealthy).toHaveBeenCalled();
|
expect(reportMetric).toHaveBeenCalledWith(
|
||||||
expect(reportUnhealthy).not.toHaveBeenCalled();
|
expect.objectContaining({
|
||||||
|
healthy: true,
|
||||||
|
timedOut: true,
|
||||||
|
hasExpectedFailure: true,
|
||||||
|
completedPhases: expect.arrayContaining(["ExplorerInitialized"]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,7 +189,13 @@ describe("ScenarioMonitor", () => {
|
|||||||
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
|
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
|
||||||
|
|
||||||
// Should emit unhealthy immediately, not wait for timeout
|
// Should emit unhealthy immediately, not wait for timeout
|
||||||
expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.DatabaseLoad, configContext.platform, "SQL");
|
expect(reportMetric).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
scenario: MetricScenario.DatabaseLoad,
|
||||||
|
healthy: false,
|
||||||
|
timedOut: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not emit twice after failPhase and timeout", () => {
|
it("does not emit twice after failPhase and timeout", () => {
|
||||||
@@ -180,7 +208,7 @@ describe("ScenarioMonitor", () => {
|
|||||||
jest.advanceTimersByTime(10000);
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
// Should only have emitted once (from failPhase)
|
// Should only have emitted once (from failPhase)
|
||||||
expect(reportUnhealthy).toHaveBeenCalledTimes(1);
|
expect(reportMetric).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,7 +220,14 @@ describe("ScenarioMonitor", () => {
|
|||||||
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
|
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
|
||||||
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
|
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
|
||||||
|
|
||||||
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
|
expect(reportMetric).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
scenario: MetricScenario.ApplicationLoad,
|
||||||
|
healthy: true,
|
||||||
|
timedOut: false,
|
||||||
|
completedPhases: expect.arrayContaining(["ExplorerInitialized", "Interactive"]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not emit until all phases complete", () => {
|
it("does not emit until all phases complete", () => {
|
||||||
@@ -201,8 +236,7 @@ describe("ScenarioMonitor", () => {
|
|||||||
// Complete only one phase
|
// Complete only one phase
|
||||||
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
|
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
|
||||||
|
|
||||||
expect(reportHealthy).not.toHaveBeenCalled();
|
expect(reportMetric).not.toHaveBeenCalled();
|
||||||
expect(reportUnhealthy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,8 +258,11 @@ describe("ScenarioMonitor", () => {
|
|||||||
|
|
||||||
// ApplicationLoad emitted healthy on completion
|
// ApplicationLoad emitted healthy on completion
|
||||||
// DatabaseLoad emits healthy on timeout (expected failure)
|
// DatabaseLoad emits healthy on timeout (expected failure)
|
||||||
expect(reportHealthy).toHaveBeenCalledTimes(2);
|
expect(reportMetric).toHaveBeenCalledTimes(2);
|
||||||
expect(reportUnhealthy).not.toHaveBeenCalled();
|
// Both should be healthy
|
||||||
|
const calls = (reportMetric as jest.Mock).mock.calls;
|
||||||
|
expect(calls[0][0].healthy).toBe(true);
|
||||||
|
expect(calls[1][0].healthy).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import type { PhaseTimings, WebVitals } from "Metrics/Constants";
|
||||||
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
|
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
|
||||||
import { configContext } from "../ConfigContext";
|
import { configContext } from "../ConfigContext";
|
||||||
import { Action } from "../Shared/Telemetry/TelemetryConstants";
|
import { Action } from "../Shared/Telemetry/TelemetryConstants";
|
||||||
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
|
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
|
||||||
import { userContext } from "../UserContext";
|
import { userContext } from "../UserContext";
|
||||||
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
|
import MetricScenario, { reportMetric } from "./MetricEvents";
|
||||||
import { scenarioConfigs } from "./MetricScenarioConfigs";
|
import { scenarioConfigs } from "./MetricScenarioConfigs";
|
||||||
import { MetricPhase, PhaseTimings, ScenarioConfig, ScenarioContextSnapshot, WebVitals } from "./ScenarioConfig";
|
import { MetricPhase, ScenarioConfig, ScenarioContextSnapshot } from "./ScenarioConfig";
|
||||||
|
|
||||||
interface PhaseContext {
|
interface PhaseContext {
|
||||||
startMarkName: string; // Performance mark name for phase start
|
startMarkName: string; // Performance mark name for phase start
|
||||||
@@ -331,13 +332,23 @@ class ScenarioMonitor {
|
|||||||
})}`,
|
})}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Call portal backend health metrics endpoint
|
// Call portal backend health metrics endpoint with enriched payload
|
||||||
// If healthy is true (either completed successfully or timeout with expected failure), report healthy
|
reportMetric({
|
||||||
if (healthy) {
|
platform,
|
||||||
reportHealthy(ctx.scenario, platform, api);
|
api,
|
||||||
} else {
|
scenario: ctx.scenario,
|
||||||
reportUnhealthy(ctx.scenario, platform, api);
|
healthy,
|
||||||
}
|
durationMs: finalSnapshot.durationMs,
|
||||||
|
timedOut,
|
||||||
|
documentHidden: document.hidden,
|
||||||
|
hasExpectedFailure: ctx.hasExpectedFailure,
|
||||||
|
completedPhases: finalSnapshot.completed,
|
||||||
|
failedPhases: finalSnapshot.failedPhases ?? [],
|
||||||
|
phaseTimings: finalSnapshot.phaseTimings,
|
||||||
|
startTimeISO: finalSnapshot.startTimeISO,
|
||||||
|
endTimeISO: finalSnapshot.endTimeISO,
|
||||||
|
vitals: finalSnapshot.vitals,
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup performance entries
|
// Cleanup performance entries
|
||||||
this.cleanupPerformanceEntries(ctx);
|
this.cleanupPerformanceEntries(ctx);
|
||||||
|
|||||||
Reference in New Issue
Block a user