Added enriched metrics (#2432)

This commit is contained in:
sunghyunkang1111
2026-03-25 10:50:57 -05:00
committed by GitHub
parent 8e1b041e3b
commit c42e35f97a
6 changed files with 186 additions and 81 deletions

View File

@@ -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;
} }

View File

@@ -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");
}); });
}); });

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}); });
}); });
}); });

View File

@@ -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);