Compare commits

..

1 Commits

Author SHA1 Message Date
Sung-Hyun Kang
6473096409 Added enriched metrics 2026-03-22 18:26:01 -05:00
6 changed files with 186 additions and 81 deletions

View File

@@ -7,10 +7,39 @@ export enum MetricScenario {
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.
export interface MetricEvent {
// === Existing required fields (unchanged) ===
readonly platform: Platform;
readonly api: ApiType;
readonly scenario: MetricScenario;
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;
}

View File

@@ -1,7 +1,8 @@
import { configContext, Platform } from "../ConfigContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
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
const { Response } = require("node-fetch");
@@ -21,11 +22,16 @@ describe("MetricEvents", () => {
jest.clearAllMocks();
});
test("reportHealthy success includes auth header", async () => {
test("reportMetric success includes auth header", async () => {
const mockResponse = new Response(null, { status: 200 });
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.ok).toBe(true);
@@ -47,39 +53,77 @@ describe("MetricEvents", () => {
expect(getAuthorizationHeader).toHaveBeenCalled();
});
test("reportUnhealthy failure status", async () => {
const mockResponse = new Response("Failure", { status: 500 });
test("reportMetric sends full enriched payload", async () => {
const mockResponse = new Response(null, { status: 200 });
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);
expect(result.ok).toBe(false);
expect(result.status).toBe(500);
const result = await reportMetric(event);
expect(result.status).toBe(200);
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 body = JSON.parse(callArgs[1]?.body as string);
expect(body.healthy).toBe(false);
});
test("helpers healthy/unhealthy", async () => {
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);
expect(body.durationMs).toBeUndefined();
expect(body.vitals).toBeUndefined();
expect(body.completedPhases).toBeUndefined();
});
test("throws when backend endpoint missing", async () => {
const original = configContext.PORTAL_BACKEND_ENDPOINT;
(configContext as { PORTAL_BACKEND_ENDPOINT: string }).PORTAL_BACKEND_ENDPOINT = "";
await expect(reportHealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL")).rejects.toThrow(
"baseUri is null or empty",
);
await expect(
reportMetric({ platform: Platform.Portal, api: "SQL", scenario: MetricScenario.ApplicationLoad, healthy: true }),
).rejects.toThrow("baseUri is null or empty");
expect(mockFetchWithTimeout).not.toHaveBeenCalled();
(configContext as { PORTAL_BACKEND_ENDPOINT: string }).PORTAL_BACKEND_ENDPOINT = original;
@@ -88,17 +132,17 @@ describe("MetricEvents", () => {
test("propagates fetch errors", async () => {
mockFetchWithTimeout.mockRejectedValue(new Error("Network error"));
await expect(reportHealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL")).rejects.toThrow(
"Network error",
);
await expect(
reportMetric({ platform: Platform.Portal, api: "SQL", scenario: MetricScenario.ApplicationLoad, healthy: true }),
).rejects.toThrow("Network error");
});
test("propagates timeout errors", async () => {
const abortError = new DOMException("The operation was aborted", "AbortError");
mockFetchWithTimeout.mockRejectedValue(abortError);
await expect(reportUnhealthy(MetricScenario.ApplicationLoad, Platform.Portal, "SQL")).rejects.toThrow(
"The operation was aborted",
);
await expect(
reportMetric({ platform: Platform.Portal, api: "SQL", scenario: MetricScenario.ApplicationLoad, healthy: false }),
).rejects.toThrow("The operation was aborted");
});
});

View File

@@ -1,18 +1,14 @@
// Metrics module: scenario metric emission logic.
import { MetricEvent, MetricScenario } from "Metrics/Constants";
import { createUri } from "../Common/UrlUtility";
import { configContext, Platform } from "../ConfigContext";
import { ApiType } from "../UserContext";
import { configContext } from "../ConfigContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { fetchWithTimeout } from "../Utils/FetchWithTimeout";
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({ platform, api, scenario, healthy: true });
export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, api: ApiType): Promise<Response> =>
send({ platform, api, scenario, healthy: false });
/** Send a full enriched MetricEvent to the backend. */
export const reportMetric = (event: MetricEvent): Promise<Response> => send(event);
const send = async (event: MetricEvent): Promise<Response> => {
// Skip metrics emission during local development

View File

@@ -1,3 +1,4 @@
import type { PhaseTimings, WebVitals } from "Metrics/Constants";
import MetricScenario from "./MetricEvents";
// Common phases shared across all scenarios
@@ -15,25 +16,12 @@ export enum ApplicationMetricPhase {
// Combined type for all metric phases
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> {
requiredPhases: TPhase[];
timeoutMs: number;
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> {
scenario: MetricScenario;
startTimeISO: string; // Human-readable ISO timestamp

View File

@@ -2,9 +2,8 @@
* @jest-environment jsdom
*/
import { configContext } from "../ConfigContext";
import { updateUserContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import MetricScenario, { reportMetric } from "./MetricEvents";
import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
import { scenarioMonitor } from "./ScenarioMonitor";
@@ -15,8 +14,7 @@ jest.mock("./MetricEvents", () => ({
ApplicationLoad: "ApplicationLoad",
DatabaseLoad: "DatabaseLoad",
},
reportHealthy: jest.fn().mockResolvedValue({ ok: true }),
reportUnhealthy: jest.fn().mockResolvedValue({ ok: true }),
reportMetric: jest.fn().mockResolvedValue({ ok: true }),
}));
// Mock configContext
@@ -83,8 +81,14 @@ describe("ScenarioMonitor", () => {
// 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();
expect(reportMetric).toHaveBeenCalledWith(
expect.objectContaining({
scenario: MetricScenario.ApplicationLoad,
healthy: true,
hasExpectedFailure: true,
timedOut: true,
}),
);
});
it("sets flag on multiple active scenarios", () => {
@@ -98,8 +102,8 @@ describe("ScenarioMonitor", () => {
// Let timeouts fire
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalledTimes(2);
expect(reportUnhealthy).not.toHaveBeenCalled();
expect(reportMetric).toHaveBeenCalledTimes(2);
expect(reportMetric).toHaveBeenCalledWith(expect.objectContaining({ healthy: true, hasExpectedFailure: true }));
});
it("does not affect already emitted scenarios", () => {
@@ -113,8 +117,9 @@ describe("ScenarioMonitor", () => {
// Now mark expected failure - should not change anything
scenarioMonitor.markExpectedFailure();
// Healthy was called when phases completed
expect(reportHealthy).toHaveBeenCalledTimes(1);
// reportMetric was called when phases completed
expect(reportMetric).toHaveBeenCalledTimes(1);
expect(reportMetric).toHaveBeenCalledWith(expect.objectContaining({ healthy: true }));
});
});
@@ -125,8 +130,13 @@ describe("ScenarioMonitor", () => {
// Let timeout fire without marking expected failure
jest.advanceTimersByTime(10000);
expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
expect(reportHealthy).not.toHaveBeenCalled();
expect(reportMetric).toHaveBeenCalledWith(
expect.objectContaining({
scenario: MetricScenario.ApplicationLoad,
healthy: false,
timedOut: true,
}),
);
});
it("emits healthy on timeout with expected failure", () => {
@@ -138,8 +148,14 @@ describe("ScenarioMonitor", () => {
// Let timeout fire
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
expect(reportUnhealthy).not.toHaveBeenCalled();
expect(reportMetric).toHaveBeenCalledWith(
expect.objectContaining({
scenario: MetricScenario.ApplicationLoad,
healthy: true,
timedOut: true,
hasExpectedFailure: true,
}),
);
});
it("emits healthy even with partial phase completion and expected failure", () => {
@@ -154,8 +170,14 @@ describe("ScenarioMonitor", () => {
// Let timeout fire (Interactive phase not completed)
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalled();
expect(reportUnhealthy).not.toHaveBeenCalled();
expect(reportMetric).toHaveBeenCalledWith(
expect.objectContaining({
healthy: true,
timedOut: true,
hasExpectedFailure: true,
completedPhases: expect.arrayContaining(["ExplorerInitialized"]),
}),
);
});
});
@@ -167,7 +189,13 @@ describe("ScenarioMonitor", () => {
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
// 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", () => {
@@ -180,7 +208,7 @@ describe("ScenarioMonitor", () => {
jest.advanceTimersByTime(10000);
// 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, 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", () => {
@@ -201,8 +236,7 @@ describe("ScenarioMonitor", () => {
// Complete only one phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
expect(reportHealthy).not.toHaveBeenCalled();
expect(reportUnhealthy).not.toHaveBeenCalled();
expect(reportMetric).not.toHaveBeenCalled();
});
});
@@ -224,8 +258,11 @@ describe("ScenarioMonitor", () => {
// ApplicationLoad emitted healthy on completion
// DatabaseLoad emits healthy on timeout (expected failure)
expect(reportHealthy).toHaveBeenCalledTimes(2);
expect(reportUnhealthy).not.toHaveBeenCalled();
expect(reportMetric).toHaveBeenCalledTimes(2);
// 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);
});
});
});

View File

@@ -1,11 +1,12 @@
import type { PhaseTimings, WebVitals } from "Metrics/Constants";
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
import { configContext } from "../ConfigContext";
import { Action } from "../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import MetricScenario, { reportMetric } from "./MetricEvents";
import { scenarioConfigs } from "./MetricScenarioConfigs";
import { MetricPhase, PhaseTimings, ScenarioConfig, ScenarioContextSnapshot, WebVitals } from "./ScenarioConfig";
import { MetricPhase, ScenarioConfig, ScenarioContextSnapshot } from "./ScenarioConfig";
interface PhaseContext {
startMarkName: string; // Performance mark name for phase start
@@ -331,13 +332,23 @@ class ScenarioMonitor {
})}`,
);
// Call portal backend health metrics endpoint
// 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);
}
// Call portal backend health metrics endpoint with enriched payload
reportMetric({
platform,
api,
scenario: ctx.scenario,
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
this.cleanupPerformanceEntries(ctx);