diff --git a/src/Metrics/Constants.ts b/src/Metrics/Constants.ts index 9592618a9..f6221e1d4 100644 --- a/src/Metrics/Constants.ts +++ b/src/Metrics/Constants.ts @@ -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; + + readonly startTimeISO?: string; + readonly endTimeISO?: string; + + readonly vitals?: WebVitals; } diff --git a/src/Metrics/MetricEvents.test.ts b/src/Metrics/MetricEvents.test.ts index 43c22864b..0c5480b82 100644 --- a/src/Metrics/MetricEvents.test.ts +++ b/src/Metrics/MetricEvents.test.ts @@ -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"); }); }); diff --git a/src/Metrics/MetricEvents.ts b/src/Metrics/MetricEvents.ts index 84faad1e8..0e4910565 100644 --- a/src/Metrics/MetricEvents.ts +++ b/src/Metrics/MetricEvents.ts @@ -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 => - send({ platform, api, scenario, healthy: true }); - -export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, api: ApiType): Promise => - send({ platform, api, scenario, healthy: false }); +/** Send a full enriched MetricEvent to the backend. */ +export const reportMetric = (event: MetricEvent): Promise => send(event); const send = async (event: MetricEvent): Promise => { // Skip metrics emission during local development diff --git a/src/Metrics/ScenarioConfig.ts b/src/Metrics/ScenarioConfig.ts index c8629f573..6f91d46ec 100644 --- a/src/Metrics/ScenarioConfig.ts +++ b/src/Metrics/ScenarioConfig.ts @@ -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 { requiredPhases: TPhase[]; timeoutMs: number; validate?: (ctx: ScenarioContextSnapshot) => 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 { scenario: MetricScenario; startTimeISO: string; // Human-readable ISO timestamp diff --git a/src/Metrics/ScenarioMonitor.test.ts b/src/Metrics/ScenarioMonitor.test.ts index dfd0db17e..7c4c67e5b 100644 --- a/src/Metrics/ScenarioMonitor.test.ts +++ b/src/Metrics/ScenarioMonitor.test.ts @@ -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); }); }); }); diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts index c68509866..9782a6cb0 100644 --- a/src/Metrics/ScenarioMonitor.ts +++ b/src/Metrics/ScenarioMonitor.ts @@ -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);