Skip to content

Commit 172435b

Browse files
📝 Add docstrings to codex/extend-ci-pipeline-with-performance-thresholds
Docstrings generation was requested by @shayancoin. * #123 (comment) The following files were modified: * `frontend/tests/perf/run-perf-budget.ts` * `scripts/ci/check_canary_metrics.py`
1 parent 9ba2fc4 commit 172435b

File tree

2 files changed

+212
-2
lines changed

2 files changed

+212
-2
lines changed

‎frontend/tests/perf/run-perf-budget.ts‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ const rootDir = path.resolve(__dirname, '../../..');
101101
const configPath = path.join(rootDir, '.perf-budget.yml');
102102
const resultsDir = path.join(rootDir, 'perf-results');
103103

104+
/**
105+
* Load and validate the performance budget configuration from disk.
106+
*
107+
* @returns The parsed PerfBudgetConfig read from the configured config path
108+
* @throws Error if the configuration is missing a top-level `scenarios` array
109+
*/
104110
function readConfig(): PerfBudgetConfig {
105111
const file = fs.readFileSync(configPath, 'utf-8');
106112
const parsed = yaml.parse(file) as PerfBudgetConfig;
@@ -110,6 +116,22 @@ function readConfig(): PerfBudgetConfig {
110116
return parsed;
111117
}
112118

119+
/**
120+
* Applies CPU and network emulation to the given browser context/page according to the provided throttling settings.
121+
*
122+
* When `throttling` is omitted, no emulation is applied. If `cpu_slowdown_multiplier` is a positive number,
123+
* a CDP session is used to set the CPU throttling rate. If any of `download_throughput_kbps`, `upload_throughput_kbps`,
124+
* or `request_latency_ms` are provided, network emulation is enabled and those values are applied (throughput values
125+
* are converted from kbps to bytes/sec as required by the CDP).
126+
*
127+
* @param context - The Playwright BrowserContext to create a CDP session on.
128+
* @param page - The Playwright Page associated with the context (used to bind the CDP session).
129+
* @param throttling - Optional throttling parameters:
130+
* - `cpu_slowdown_multiplier`: CPU slowdown multiplier (greater than 0 enables CPU throttling).
131+
* - `download_throughput_kbps`: Download throughput in kilobits per second.
132+
* - `upload_throughput_kbps`: Upload throughput in kilobits per second.
133+
* - `request_latency_ms`: Additional request latency in milliseconds.
134+
*/
113135
async function applyThrottling(context: BrowserContext, page: Page, throttling?: ThrottlingConfig) {
114136
if (!throttling) {
115137
return;
@@ -132,6 +154,16 @@ async function applyThrottling(context: BrowserContext, page: Page, throttling?:
132154
}
133155
}
134156

157+
/**
158+
* Installs in-page performance observers that record LCP entries, cumulative layout shift, and total blocking time into a global store.
159+
*
160+
* Injects a global `__perfBudget` object on the target page and registers PerformanceObserver instances that populate:
161+
* - `lcpEntries`: array of LCP PerformanceEntry objects
162+
* - `cls`: cumulative layout shift value
163+
* - `tbt`: accumulated total blocking time (ms)
164+
*
165+
* @param page - The Playwright `Page` to attach the observers to
166+
*/
135167
async function setupPerformanceObservers(page: Page) {
136168
await page.addInitScript(() => {
137169
const globalAny = globalThis as any;
@@ -185,6 +217,11 @@ async function setupPerformanceObservers(page: Page) {
185217
});
186218
}
187219

220+
/**
221+
* Performs a configured wait step on the given page, supporting selector and network-idle waits.
222+
*
223+
* @param wait - Wait step configuration. If `type` is `"selector"`, waits for `selector` with a default timeout of 30000 ms unless `timeout_ms` is provided. If `type` is `"networkidle"`, waits for the page network to become idle with a default timeout of 60000 ms unless `timeout_ms` is provided; if `idle_ms` is set and greater than zero, waits an additional `idle_ms` milliseconds after network idle.
224+
*/
188225
async function performWait(page: Page, wait: WaitStep) {
189226
if (wait.type === 'selector') {
190227
await page.waitForSelector(wait.selector, { timeout: wait.timeout_ms ?? 30000 });
@@ -196,6 +233,17 @@ async function performWait(page: Page, wait: WaitStep) {
196233
}
197234
}
198235

236+
/**
237+
* Execute a single scenario step against the provided Playwright page.
238+
*
239+
* Supports three step types:
240+
* - `goto`: navigates the page to `step.url` and waits until the specified `step.wait_until` event (defaults to `load`) or navigation completes.
241+
* - `wait_for_selector`: waits for the given `step.selector` to appear (optional `step.timeout_ms` in milliseconds).
242+
* - `wait_for_timeout`: waits for `step.timeout_ms` milliseconds.
243+
*
244+
* @param page - The Playwright Page to operate on.
245+
* @param step - The step configuration describing the action to perform.
246+
*/
199247
async function performScenarioStep(page: Page, step: ScenarioStep) {
200248
if (step.type === 'goto') {
201249
await page.goto(step.url, { waitUntil: step.wait_until ?? 'load', timeout: 60000 });
@@ -206,6 +254,15 @@ async function performScenarioStep(page: Page, step: ScenarioStep) {
206254
}
207255
}
208256

257+
/**
258+
* Runs a single performance scenario in a new browser context and returns collected runtime metrics.
259+
*
260+
* @param scenario - The scenario configuration to execute (URL or ordered steps, waits, and scenario id).
261+
* @param browser - Playwright browser instance used to create an isolated context for the run.
262+
* @param defaults - Default perf budget settings (e.g., throttling) applied to the run when present.
263+
* @returns A `RunMetrics` map of metric identifier to numeric value or `null` when a measurement is not available.
264+
* @throws Error if the provided scenario contains neither `url` nor `steps`.
265+
*/
209266
async function executeScenarioRun(
210267
scenario: ScenarioConfig,
211268
browser: Browser,
@@ -260,6 +317,12 @@ async function executeScenarioRun(
260317
return metrics;
261318
}
262319

320+
/**
321+
* Aggregate an array of per-run metric objects into buckets of numeric samples keyed by metric identifier.
322+
*
323+
* @param metrics - Array of run metric maps produced by individual scenario executions
324+
* @returns A mapping from each metric id to an array of finite numeric samples collected across runs
325+
*/
263326
function collectScenarioMetrics(metrics: RunMetrics[]): ScenarioMetrics {
264327
const result: ScenarioMetrics = {};
265328
for (const run of metrics) {
@@ -275,6 +338,13 @@ function collectScenarioMetrics(metrics: RunMetrics[]): ScenarioMetrics {
275338
return result;
276339
}
277340

341+
/**
342+
* Computes the requested percentile from a sorted numeric array.
343+
*
344+
* @param sorted - Array of numbers sorted in ascending order.
345+
* @param percentileValue - Percentile to compute, between 0 and 1 inclusive (for example, `0.5` for the median).
346+
* @returns The interpolated percentile value for `percentileValue`; `NaN` if `sorted` is empty.
347+
*/
278348
function percentile(sorted: number[], percentileValue: number): number {
279349
if (sorted.length === 0) {
280350
return NaN;
@@ -288,6 +358,16 @@ function percentile(sorted: number[], percentileValue: number): number {
288358
return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower);
289359
}
290360

361+
/**
362+
* Compute an aggregate statistic from a list of numeric samples.
363+
*
364+
* Supported aggregations: 'mean', 'median' (alias 'p50'), 'p75', 'p90', 'p95'.
365+
*
366+
* @param values - The numeric samples to aggregate
367+
* @param aggregation - The aggregation method to apply
368+
* @returns The aggregated numeric value; `NaN` if `values` is empty
369+
* @throws Error if `aggregation` is not one of the supported methods
370+
*/
291371
function aggregate(values: number[], aggregation: Aggregation | string): number {
292372
if (values.length === 0) {
293373
return NaN;
@@ -310,6 +390,12 @@ function aggregate(values: number[], aggregation: Aggregation | string): number
310390
}
311391
}
312392

393+
/**
394+
* Builds a JUnit-compatible report representing scenario metric results and budget violations.
395+
*
396+
* @param results - Array of scenario results to include in the report
397+
* @returns A JUnitReport containing one test suite per scenario; each metric is a test case and metrics that exceeded their thresholds are represented as failures
398+
*/
313399
function createJUnitReport(results: ScenarioResult[]): JUnitReport {
314400
const suites: JUnitSuite[] = [];
315401
let totalTests = 0;
@@ -351,6 +437,13 @@ function createJUnitReport(results: ScenarioResult[]): JUnitReport {
351437
};
352438
}
353439

440+
/**
441+
* Serialize a JUnitReport to XML and write it to the perf-results/perf-budget-junit.xml file.
442+
*
443+
* Creates the results directory if it does not exist before writing the file.
444+
*
445+
* @param report - The JUnit report object to serialize and persist
446+
*/
354447
function writeJUnitReport(report: JUnitReport) {
355448
const xmlLines: string[] = [];
356449
xmlLines.push('<?xml version="1.0" encoding="UTF-8"?>');
@@ -380,6 +473,12 @@ function writeJUnitReport(report: JUnitReport) {
380473
fs.writeFileSync(path.join(resultsDir, 'perf-budget-junit.xml'), xmlLines.join('\n'), 'utf-8');
381474
}
382475

476+
/**
477+
* Escape XML special characters in a string for safe inclusion in XML.
478+
*
479+
* @param value - The string to escape
480+
* @returns The input string with `&`, `"` , `<`, and `>` replaced by their XML entities (`&amp;`, `&quot;`, `&lt;`, `&gt;`)
481+
*/
383482
function escapeXml(value: string): string {
384483
return value
385484
.replace(/&/g, '&amp;')
@@ -388,11 +487,23 @@ function escapeXml(value: string): string {
388487
.replace(/>/g, '&gt;');
389488
}
390489

490+
/**
491+
* Persist the provided performance summary to perf-results/perf-budget-summary.json.
492+
*
493+
* Ensures the results directory exists and writes `summary` as pretty-printed JSON to the file.
494+
*
495+
* @param summary - The aggregated performance summary to save
496+
*/
391497
function writeSummary(summary: Summary) {
392498
fs.mkdirSync(resultsDir, { recursive: true });
393499
fs.writeFileSync(path.join(resultsDir, 'perf-budget-summary.json'), JSON.stringify(summary, null, 2), 'utf-8');
394500
}
395501

502+
/**
503+
* Executes all configured performance scenarios, aggregates their metrics, and produces reports.
504+
*
505+
* Reads the performance budget configuration, runs each scenario the configured number of times, aggregates metric samples according to each metric's aggregation strategy, evaluates them against thresholds, writes a JSON summary and a JUnit XML report to the results directory, and sets a non-zero process exit code when any metric violates its threshold.
506+
*/
396507
async function run() {
397508
const config = readConfig();
398509
const defaults = config.defaults ?? {};
@@ -463,4 +574,4 @@ async function run() {
463574
run().catch((error) => {
464575
console.error('Failed to execute performance budgets', error);
465576
process.exitCode = 1;
466-
});
577+
});

‎scripts/ci/check_canary_metrics.py‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,45 @@ class CanaryCheckError(RuntimeError):
3030

3131

3232
def _float_or_none(value: Any) -> Optional[float]:
33+
"""
34+
Convert the given value to a float, returning None if the value cannot be converted.
35+
36+
Returns:
37+
float_value (Optional[float]): The value converted to a `float`, or `None` if conversion raises `TypeError` or `ValueError`.
38+
"""
3339
try:
3440
return float(value)
3541
except (TypeError, ValueError):
3642
return None
3743

3844

3945
def _http_get_json(url: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
46+
"""
47+
Fetches JSON from the given URL, optionally adding URL-encoded query parameters, and returns the parsed payload.
48+
49+
Parameters:
50+
url (str): The request URL or base endpoint.
51+
params (Optional[Dict[str, str]]): Query parameters to URL-encode and append to the URL.
52+
53+
Returns:
54+
Dict[str, Any]: The parsed JSON response as a Python dictionary.
55+
"""
4056
query = f"{url}?{urllib.parse.urlencode(params)}" if params else url
4157
with urllib.request.urlopen(query, timeout=10) as response:
4258
return json.loads(response.read().decode("utf-8"))
4359

4460

4561
def _extract_prom_value(payload: Dict[str, Any]) -> Optional[float]:
62+
"""
63+
Extracts a numeric sample value from a Prometheus-style query response.
64+
65+
Parameters:
66+
payload (dict): The JSON-decoded response from Prometheus' HTTP API.
67+
68+
Returns:
69+
float: The extracted numeric value when present.
70+
None: If the response status is not "success", contains no results, or a numeric sample cannot be determined.
71+
"""
4672
if payload.get("status") != "success":
4773
return None
4874
data = payload.get("data", {})
@@ -61,6 +87,12 @@ def _extract_prom_value(payload: Dict[str, Any]) -> Optional[float]:
6187

6288

6389
def _query_prometheus(base_url: str, query: str) -> Optional[float]:
90+
"""
91+
Query a Prometheus instant query endpoint and return the numeric result if present.
92+
93+
Returns:
94+
float: Numeric value extracted from the Prometheus response, or `None` if the HTTP request failed or the response did not contain a usable numeric result.
95+
"""
6496
endpoint = f"{base_url.rstrip('/')}/api/v1/query"
6597
try:
6698
payload = _http_get_json(endpoint, {"query": query})
@@ -71,6 +103,21 @@ def _query_prometheus(base_url: str, query: str) -> Optional[float]:
71103

72104

73105
def _load_fixture(path: str) -> CanaryMetrics:
106+
"""
107+
Load canary metrics from a JSON fixture file.
108+
109+
Parameters:
110+
path (str): Filesystem path to a JSON fixture containing top-level keys
111+
"current", "previous", "tempo", and "metadata". Missing numeric fields
112+
default to 0.0 or None as appropriate.
113+
114+
Returns:
115+
CanaryMetrics: Instance populated from the fixture:
116+
- latency_p95_ms and error_rate taken from `current`.
117+
- trace_latency_p95_ms taken from `tempo`.
118+
- previous_latency_p95_ms and previous_error_rate taken from `previous`.
119+
- build, previous_build, and generated_at taken from `metadata`.
120+
"""
74121
with open(path, "r", encoding="utf-8") as handle:
75122
payload = json.load(handle)
76123
current = payload.get("current", {})
@@ -90,6 +137,20 @@ def _load_fixture(path: str) -> CanaryMetrics:
90137

91138

92139
def _collect_metrics_from_services() -> Optional[CanaryMetrics]:
140+
"""
141+
Collect canary metrics from Prometheus and Tempo based on environment configuration.
142+
143+
Attempts to query Prometheus for current P95 latency and error rate and, when configured, previous-period metrics and Tempo trace P95 latency. Required environment variables for live collection are PROMETHEUS_URL, PROMETHEUS_LATENCY_QUERY, and PROMETHEUS_ERROR_QUERY. Optional environment variables:
144+
- PROMETHEUS_PREVIOUS_LATENCY_QUERY, PROMETHEUS_PREVIOUS_ERROR_QUERY: queries for previous metrics.
145+
- TEMPO_URL, TEMPO_TRACE_QUERY: Tempo search API and query for trace latency.
146+
- BUILD_TAG or GITHUB_SHA: current build identifier.
147+
- PREVIOUS_BUILD_TAG: previous build identifier.
148+
149+
If Prometheus is unreachable, missing required configuration, or returns no data for the primary latency or error queries, the function returns None to signal that callers should fall back to fixture data. On success, returns a CanaryMetrics instance populated with collected values (including trace_latency_p95_ms when available), previous values when provided, build metadata, and a UTC ISO-like generated_at timestamp.
150+
151+
Returns:
152+
Optional[CanaryMetrics]: A populated CanaryMetrics object when live collection succeeds, or `None` when live data is unavailable and a fixture should be used.
153+
"""
93154
prom_url = os.environ.get("PROMETHEUS_URL")
94155
latency_query = os.environ.get("PROMETHEUS_LATENCY_QUERY")
95156
error_query = os.environ.get("PROMETHEUS_ERROR_QUERY")
@@ -148,6 +209,14 @@ def _collect_metrics_from_services() -> Optional[CanaryMetrics]:
148209

149210

150211
def _load_metrics() -> CanaryMetrics:
212+
"""
213+
Load canary metrics from configured services, falling back to a JSON fixture when live collection is unavailable.
214+
215+
If environment and service queries provide metrics, those are returned; otherwise the fixture path from CANARY_METRICS_FIXTURE (or the default "tests/perf/canary-metrics.fixture.json") is used and its contents are returned. The chosen fixture path is printed when the fallback is used.
216+
217+
Returns:
218+
CanaryMetrics: Collected canary metrics and metadata, sourced from live services when available or from the fixture otherwise.
219+
"""
151220
metrics = _collect_metrics_from_services()
152221
if metrics:
153222
return metrics
@@ -158,6 +227,13 @@ def _load_metrics() -> CanaryMetrics:
158227

159228

160229
def _write_summary(metrics: CanaryMetrics, passed: bool) -> None:
230+
"""
231+
Write a JSON summary of the provided canary metrics and pass/fail result to perf-results/canary-metrics-summary.json.
232+
233+
Parameters:
234+
metrics (CanaryMetrics): Collected canary metrics and metadata to include in the summary.
235+
passed (bool): Whether the canary checks passed; written as the `passed` field in the summary.
236+
"""
161237
summary_path = os.path.join("perf-results", "canary-metrics-summary.json")
162238
os.makedirs(os.path.dirname(summary_path), exist_ok=True)
163239
payload = {
@@ -176,6 +252,21 @@ def _write_summary(metrics: CanaryMetrics, passed: bool) -> None:
176252

177253

178254
def _evaluate(metrics: CanaryMetrics) -> None:
255+
"""
256+
Validate the provided canary metrics against configured thresholds and raise on any violations.
257+
258+
Checks performed:
259+
- P95 latency exceeds P95_THRESHOLD_MS (default 3000).
260+
- Error rate exceeds ERROR_RATE_THRESHOLD (default 0.02).
261+
- Latency regression relative to previous_latency_p95_ms exceeds REGRESSION_TOLERANCE_PCT (default 0.1).
262+
- Error rate regression relative to previous_error_rate exceeds REGRESSION_TOLERANCE_PCT.
263+
264+
Parameters:
265+
metrics (CanaryMetrics): Current canary metrics; when present, previous_latency_p95_ms and previous_error_rate are used for regression checks.
266+
267+
Raises:
268+
CanaryCheckError: If any threshold or regression check fails. The exception message contains a semicolon-separated list of failure descriptions.
269+
"""
179270
latency_budget = float(os.environ.get("P95_THRESHOLD_MS", 3000))
180271
error_budget = float(os.environ.get("ERROR_RATE_THRESHOLD", 0.02))
181272
regression_tolerance_pct = float(os.environ.get("REGRESSION_TOLERANCE_PCT", 0.1))
@@ -216,6 +307,14 @@ def _evaluate(metrics: CanaryMetrics) -> None:
216307

217308

218309
def main() -> int:
310+
"""
311+
Run the canary metric validation flow, emit a human-readable summary, write a pass/fail summary file, and return an exit code.
312+
313+
The function loads metrics, evaluates them against configured thresholds, prints status and comparisons to stdout/stderr, writes a JSON summary file indicating pass or fail, and exits with a code appropriate to the result.
314+
315+
Returns:
316+
int: `0` if all checks pass, `1` if any check fails.
317+
"""
219318
metrics = _load_metrics()
220319
try:
221320
_evaluate(metrics)
@@ -245,4 +344,4 @@ def main() -> int:
245344

246345

247346
if __name__ == "__main__":
248-
sys.exit(main())
347+
sys.exit(main())

0 commit comments

Comments
 (0)