Frontend performance metrics (#2439)

* Added enriched metrics

* Add more traces for observability
This commit is contained in:
sunghyunkang1111
2026-04-01 09:42:32 -05:00
committed by GitHub
parent eac5842176
commit 2ba58cd1a5
14 changed files with 363 additions and 103 deletions

View File

@@ -3,15 +3,28 @@ import { ApplicationMetricPhase, CommonMetricPhase, ScenarioConfig } from "./Sce
export const scenarioConfigs: Record<MetricScenario, ScenarioConfig> = {
[MetricScenario.ApplicationLoad]: {
requiredPhases: [ApplicationMetricPhase.ExplorerInitialized, CommonMetricPhase.Interactive],
requiredPhases: [
ApplicationMetricPhase.PlatformConfigured,
ApplicationMetricPhase.CopilotConfigured,
ApplicationMetricPhase.SampleDataLoaded,
ApplicationMetricPhase.ExplorerInitialized,
CommonMetricPhase.Interactive,
],
deferredPhases: [
ApplicationMetricPhase.CopilotConfigured,
ApplicationMetricPhase.SampleDataLoaded,
ApplicationMetricPhase.ExplorerInitialized,
],
timeoutMs: 10000,
},
[MetricScenario.DatabaseLoad]: {
requiredPhases: [
ApplicationMetricPhase.DatabasesFetched,
ApplicationMetricPhase.CollectionsLoaded,
ApplicationMetricPhase.DatabaseTreeRendered,
CommonMetricPhase.Interactive,
],
deferredPhases: [ApplicationMetricPhase.CollectionsLoaded, ApplicationMetricPhase.DatabaseTreeRendered],
timeoutMs: 10000,
},
};

View File

@@ -9,7 +9,11 @@ export enum CommonMetricPhase {
// Application-specific phases
export enum ApplicationMetricPhase {
ExplorerInitialized = "ExplorerInitialized",
PlatformConfigured = "PlatformConfigured",
CopilotConfigured = "CopilotConfigured",
SampleDataLoaded = "SampleDataLoaded",
DatabasesFetched = "DatabasesFetched",
CollectionsLoaded = "CollectionsLoaded",
DatabaseTreeRendered = "DatabaseTreeRendered",
}
@@ -18,6 +22,7 @@ export type MetricPhase = CommonMetricPhase | ApplicationMetricPhase;
export interface ScenarioConfig<TPhase extends string = MetricPhase> {
requiredPhases: TPhase[];
deferredPhases?: TPhase[]; // Phases not auto-started at scenario start; started explicitly via startPhase()
timeoutMs: number;
validate?: (ctx: ScenarioContextSnapshot<TPhase>) => boolean; // Optional custom validation
}

View File

@@ -110,7 +110,13 @@ describe("ScenarioMonitor", () => {
// Start scenario
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete all phases to emit
// Complete all phases to emit (PlatformConfigured auto-started, deferred phases need start+complete)
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
@@ -161,13 +167,13 @@ describe("ScenarioMonitor", () => {
it("emits healthy even with partial phase completion and expected failure", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete one phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
// Complete one non-deferred phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured);
// Mark expected failure
scenarioMonitor.markExpectedFailure();
// Let timeout fire (Interactive phase not completed)
// Let timeout fire (deferred phases and Interactive not completed)
jest.advanceTimersByTime(10000);
expect(reportMetric).toHaveBeenCalledWith(
@@ -175,7 +181,7 @@ describe("ScenarioMonitor", () => {
healthy: true,
timedOut: true,
hasExpectedFailure: true,
completedPhases: expect.arrayContaining(["ExplorerInitialized"]),
completedPhases: expect.arrayContaining(["PlatformConfigured"]),
}),
);
});
@@ -216,7 +222,13 @@ describe("ScenarioMonitor", () => {
it("emits healthy when all phases complete", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete all required phases
// Complete all required phases (PlatformConfigured + Interactive auto-started, deferred need start)
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
@@ -225,7 +237,13 @@ describe("ScenarioMonitor", () => {
scenario: MetricScenario.ApplicationLoad,
healthy: true,
timedOut: false,
completedPhases: expect.arrayContaining(["ExplorerInitialized", "Interactive"]),
completedPhases: expect.arrayContaining([
"PlatformConfigured",
"CopilotConfigured",
"SampleDataLoaded",
"ExplorerInitialized",
"Interactive",
]),
}),
);
});
@@ -233,8 +251,8 @@ describe("ScenarioMonitor", () => {
it("does not emit until all phases complete", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete only one phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
// Complete only one non-deferred phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured);
expect(reportMetric).not.toHaveBeenCalled();
});
@@ -246,7 +264,13 @@ describe("ScenarioMonitor", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
scenarioMonitor.start(MetricScenario.DatabaseLoad);
// Complete ApplicationLoad
// Complete ApplicationLoad (all phases including deferred)
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);

View File

@@ -87,8 +87,12 @@ class ScenarioMonitor {
hasExpectedFailure: false,
};
// Start all required phases at scenario start time
// Start all required phases at scenario start time, except deferred ones
const deferredSet = new Set(config.deferredPhases ?? []);
config.requiredPhases.forEach((phase) => {
if (deferredSet.has(phase)) {
return; // Deferred phases are started explicitly via startPhase()
}
const phaseStartMarkName = `scenario_${scenario}_${phase}_start`;
performance.mark(phaseStartMarkName);
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
@@ -135,6 +139,11 @@ class ScenarioMonitor {
if (!ctx || ctx.emitted || !ctx.config.requiredPhases.includes(phase) || ctx.phases.has(phase)) {
return;
}
// Only deferred phases can be started via startPhase(); non-deferred are auto-started in start()
const isDeferredPhase = ctx.config.deferredPhases?.includes(phase) ?? false;
if (!isDeferredPhase) {
return;
}
const startMarkName = `scenario_${scenario}_${phase}_start`;
performance.mark(startMarkName);
@@ -147,10 +156,37 @@ class ScenarioMonitor {
});
}
/**
* Marks a phase as skipped (e.g. copilot disabled). Removes the phase from
* requiredPhases so it no longer blocks scenario completion.
*/
skipPhase(scenario: MetricScenario, phase: MetricPhase) {
const ctx = this.contexts.get(scenario);
if (!ctx || ctx.emitted) {
return;
}
// Remove from requiredPhases so it doesn't block completion
ctx.config = {
...ctx.config,
requiredPhases: ctx.config.requiredPhases.filter((p) => p !== phase),
deferredPhases: ctx.config.deferredPhases?.filter((p) => p !== phase),
};
this.devLog(`phase_skip: ${scenario}.${phase}`);
traceMark(Action.MetricsScenario, {
event: "phase_skip",
scenario,
phase,
});
this.tryEmitIfReady(ctx);
}
completePhase(scenario: MetricScenario, phase: MetricPhase) {
const ctx = this.contexts.get(scenario);
const phaseCtx = ctx?.phases.get(phase);
if (!ctx || ctx.emitted || !ctx.config.requiredPhases.includes(phase) || !phaseCtx) {
if (!ctx || ctx.emitted || ctx.completed.has(phase) || !ctx.config.requiredPhases.includes(phase) || !phaseCtx) {
return;
}
@@ -356,8 +392,7 @@ class ScenarioMonitor {
private cleanupPerformanceEntries(ctx: InternalScenarioContext) {
performance.clearMarks(ctx.startMarkName);
ctx.config.requiredPhases.forEach((phase) => {
const phaseCtx = ctx.phases.get(phase);
ctx.phases.forEach((phaseCtx, phase) => {
if (phaseCtx?.startMarkName) {
performance.clearMarks(phaseCtx.startMarkName);
}