hermes-agent/scripts/ci/timings_report.py

1943 lines
76 KiB
Python

#!/usr/bin/env python3
"""Collect CI job/step timings from the GitHub API and generate an HTML diff report.
In CI, the script reads GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, and
GITHUB_SHA from the environment to collect timings via the REST API.
If a baseline JSON file (ci-timings-baseline.json by default) exists, the
report includes a diff with per-job and per-step deltas, plus a gantt chart
overlaying current vs baseline bars.
Usage:
# Collect from API (CI mode):
python scripts/ci/timings_report.py
# Regenerate HTML from saved JSON (testing):
python scripts/ci/timings_report.py --from-json ci-timings.json
"""
from __future__ import annotations
import argparse
import difflib
import glob
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from html import escape
API_BASE = "https://api.github.com"
# Retry policy for GitHub API calls. The repo-scoped GITHUB_TOKEN shares a
# rate-limit budget across every concurrent workflow run; when several PRs
# run CI at once, this report job (which makes dozens of paginated calls)
# regularly hits 403 rate-limit responses. Those are transient — retry with
# backoff, honoring Retry-After / X-RateLimit-Reset when present.
_RETRY_STATUSES = {403, 429, 500, 502, 503, 504}
_MAX_ATTEMPTS = 5
_MAX_RETRY_WAIT_S = 120.0
class TimingsUnavailable(Exception):
"""GitHub API data could not be collected (rate limit, outage, ...).
This is a REPORT job — never a reason to fail the PR's checks. main()
catches this and exits 0 with a degraded summary.
"""
def _retry_wait_s(headers, attempt: int) -> float:
"""Seconds to wait before the next attempt, honoring server hints."""
retry_after = (headers.get("Retry-After") or "").strip() if headers else ""
if retry_after.isdigit():
return min(float(retry_after), _MAX_RETRY_WAIT_S)
reset = (headers.get("X-RateLimit-Reset") or "").strip() if headers else ""
remaining = (headers.get("X-RateLimit-Remaining") or "").strip() if headers else ""
if remaining == "0" and reset.isdigit():
return min(max(float(reset) - time.time(), 1.0), _MAX_RETRY_WAIT_S)
return min(2.0 ** attempt * 2.0, _MAX_RETRY_WAIT_S) # 4s, 8s, 16s, 32s
def _urlopen_with_retry(req: urllib.request.Request):
"""urlopen with backoff on rate-limit/transient statuses.
Returns (parsed_json, link_header). Raises TimingsUnavailable when
attempts are exhausted — callers treat that as "no report this run",
not a job failure.
"""
last_err: Exception | None = None
for attempt in range(1, _MAX_ATTEMPTS + 1):
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read()), resp.headers.get("Link", "")
except urllib.error.HTTPError as e:
last_err = e
if e.code not in _RETRY_STATUSES or attempt == _MAX_ATTEMPTS:
break
wait = _retry_wait_s(e.headers, attempt)
print(f"GitHub API {e.code} on {req.full_url}"
f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s",
file=sys.stderr)
time.sleep(wait)
except urllib.error.URLError as e:
last_err = e
if attempt == _MAX_ATTEMPTS:
break
wait = _retry_wait_s(None, attempt)
print(f"GitHub API connection error on {req.full_url} ({e.reason}) — "
f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s",
file=sys.stderr)
time.sleep(wait)
raise TimingsUnavailable(
f"GitHub API unavailable after {_MAX_ATTEMPTS} attempts: {last_err}"
)
# ---------------------------------------------------------------------------
# GitHub API helpers
# ---------------------------------------------------------------------------
def api_get(path: str, token: str, params: dict | None = None,
list_key: str | None = None) -> list | dict:
"""Authenticated GitHub API GET with automatic pagination.
For list endpoints, pass list_key to extract items from the paginated
wrapper response (e.g. list_key='jobs' for {'total_count': N, 'jobs': [...]}).
When list_key is omitted, a non-list response is returned as-is (single object).
Transient failures (403 rate limit, 429, 5xx, connection errors) are
retried with backoff; exhausted retries raise TimingsUnavailable.
"""
url = f"{API_BASE}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
results: list = []
while url:
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ci-timings-report",
})
data, link_header = _urlopen_with_retry(req)
if list_key:
results.extend(data.get(list_key, []))
elif isinstance(data, list):
results.extend(data)
else:
return data
next_url = None
for part in link_header.split(","):
part = part.strip()
if 'rel="next"' in part:
next_url = part[part.find("<") + 1:part.find(">")]
break
url = next_url
return results
def parse_ts(ts: str | None) -> datetime | None:
if not ts:
return None
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
def dur_s(started: str | None, completed: str | None) -> float | None:
s = parse_ts(started)
e = parse_ts(completed)
if not s or not e:
return None
return (e - s).total_seconds()
def is_skipped(job: dict) -> bool:
"""A job is 'skipped' when GitHub didn't actually run it.
Skipped jobs have conclusion == 'skipped' and typically have null or
equal started_at/completed_at timestamps, yielding duration_s of None
or 0. They should be excluded from delta comparisons, gantt bars,
regression tables, and aggregate stats (wall/compute).
"""
return job.get("conclusion") == "skipped"
# ---------------------------------------------------------------------------
# Timings collection
# ---------------------------------------------------------------------------
def _normalize_job(raw: dict) -> dict:
steps = []
for step in (raw.get("steps") or []):
steps.append({
"name": step.get("name", ""),
"number": step.get("number", 0),
"status": step.get("status", ""),
"conclusion": step.get("conclusion", ""),
"started_at": step.get("started_at"),
"completed_at": step.get("completed_at"),
"duration_s": dur_s(step.get("started_at"), step.get("completed_at")),
})
return {
"name": raw.get("name", "unknown"),
"workflow_name": raw.get("_workflow_name", ""),
"job_id": raw.get("id"),
"status": raw.get("status", ""),
"conclusion": raw.get("conclusion", ""),
"started_at": raw.get("started_at"),
"completed_at": raw.get("completed_at"),
"duration_s": dur_s(raw.get("started_at"), raw.get("completed_at")),
"html_url": raw.get("html_url", ""),
"steps": steps,
}
# Step categories. "Overhead" is everything a job pays before and after its
# actual work: runner setup, checkout, dependency restore/install, teardown.
# Naming is GitHub's, not ours — a `uses:` step is reported as
# "Run <owner>/<action>@<sha>" and its cleanup as "Post Run <...>", while a
# `run:` step keeps whatever `name:` the workflow gave it. So the setup
# patterns match action refs and the well-known implicit steps, and anything
# unmatched is treated as work (better to under-report overhead than to
# silently classify a real test step as setup).
STEP_SETUP = "setup"
STEP_WORK = "work"
STEP_TEARDOWN = "teardown"
_SETUP_PATTERNS = (
"set up job",
"set up python",
"set up node",
"checkout",
"actions/checkout",
"actions/setup-",
"actions/cache",
"restore ", # "Restore uv cache", "Restore baseline cache", ...
"install ", # "Install dependencies", "Install ruff + ty", ...
"minimize uv cache",
"uv-cache",
"set up docker buildx",
"log in to",
"authenticate to",
"mint read-only cache token",
"pull ghcr.io/",
"get-app-token",
"determine base ref",
)
_TEARDOWN_PATTERNS = (
"complete job",
"stop containers",
"upload",
"export results",
)
def classify_step(name: str) -> str:
"""Bucket a step into setup / work / teardown.
Any ``Post ...`` step is teardown regardless of what it post-processes —
that's GitHub's own cleanup phase for a ``uses:`` step.
"""
low = (name or "").strip().lower()
if low.startswith("post "):
return STEP_TEARDOWN
for pat in _TEARDOWN_PATTERNS:
if pat in low:
return STEP_TEARDOWN
for pat in _SETUP_PATTERNS:
if pat in low:
return STEP_SETUP
return STEP_WORK
def display_step_name(name: str) -> str:
"""Strip the pinned SHA from an action ref for display.
GitHub names a ``uses:`` step after the full pinned ref, e.g.
``Run actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd``. The
40-char SHA crowds out the part a reader cares about and makes the
overhead table unreadable, so drop it. Only the DISPLAY changes — the
raw name stays the key for baseline comparison and aggregation, since
two different pins are genuinely two different steps.
"""
if not name:
return ""
return re.sub(r"@[0-9a-f]{7,40}\b", "", name)
def compute_overhead(timings: dict) -> dict:
"""Aggregate setup/work/teardown seconds across every non-skipped job.
Returns totals plus the worst individual setup steps, so the report can
answer "how much of CI is spent getting ready to work" with a number
instead of an impression.
"""
totals = {STEP_SETUP: 0.0, STEP_WORK: 0.0, STEP_TEARDOWN: 0.0}
by_step: dict[str, dict] = {}
jobs_with_steps = 0
for j in timings.get("jobs", []):
if is_skipped(j) or not j.get("steps"):
continue
jobs_with_steps += 1
for s in j["steps"]:
dur = s.get("duration_s")
if dur is None or dur < 0:
continue
cat = classify_step(s.get("name", ""))
totals[cat] += dur
if cat != STEP_WORK:
agg = by_step.setdefault(
s.get("name", ""), {"name": s.get("name", ""),
"total_s": 0.0, "count": 0, "category": cat}
)
agg["total_s"] += dur
agg["count"] += 1
accounted = sum(totals.values())
overhead = totals[STEP_SETUP] + totals[STEP_TEARDOWN]
return {
"setup_s": totals[STEP_SETUP],
"work_s": totals[STEP_WORK],
"teardown_s": totals[STEP_TEARDOWN],
"overhead_s": overhead,
"accounted_s": accounted,
"overhead_pct": (overhead / accounted * 100) if accounted > 0 else 0.0,
"jobs_with_steps": jobs_with_steps,
"top_overhead_steps": sorted(
by_step.values(), key=lambda x: -x["total_s"]
)[:8],
}
def _annotate_wait_times(jobs: list[dict]) -> None:
"""Annotate each job with ``wait_s`` — how long it sat idle before starting.
Wait time = ``started_at - max(completed_at of all jobs that finished
before this job started)``. Jobs with no predecessor (e.g. ``detect``)
get ``wait_s = 0``. Skipped jobs get ``wait_s = None``.
This is a timestamp heuristic, not a workflow-YAML dependency parse: it
infers dependencies from temporal ordering rather than ``needs:``
declarations. It's accurate for pipeline-shaped CI where the critical
path is linear at each stage (detect → parallel lanes → gate → report).
"""
for j in jobs:
if is_skipped(j):
j["wait_s"] = None
continue
started = parse_ts(j.get("started_at"))
if started is None:
j["wait_s"] = None
continue
latest_dep_end: datetime | None = None
for other in jobs:
if other is j or is_skipped(other):
continue
other_end = parse_ts(other.get("completed_at"))
if other_end is None or other_end > started:
continue
if latest_dep_end is None or other_end > latest_dep_end:
latest_dep_end = other_end
j["wait_s"] = (started - latest_dep_end).total_seconds() if latest_dep_end else 0.0
def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
"""Collect job/step timings from the GitHub API.
1. Get orchestrator run's direct jobs (detect, all-checks-pass, etc.).
Skip workflow-call placeholder jobs (step name starts with
"Run ./.github/workflows/").
2. Find sub-workflow runs via head_sha + event=workflow_call.
3. Get each sub-workflow run's jobs with full step timing.
"""
owner, repo_name = repo.split("/")
# Orchestrator run info
run_info = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
created_at = run_info.get("created_at", "")
# Orchestrator direct jobs
orch_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
token, list_key="jobs")
direct = []
for job in orch_jobs:
steps = job.get("steps") or []
if any(s.get("name", "").startswith("Run ./.github/workflows/") for s in steps):
continue # workflow-call placeholder
if job.get("status") in ("in_progress", "queued"):
continue # skip self / unfinished
direct.append(job)
# Sub-workflow runs
sub_runs = api_get(f"/repos/{owner}/{repo_name}/actions/runs", token, params={
"head_sha": head_sha,
"event": "workflow_call",
"per_page": 100,
}, list_key="workflow_runs")
sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at]
sub_jobs_raw = []
for sr in sub_runs:
sr_id = sr["id"]
sr_name = sr.get("name", "")
sr_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs",
token, list_key="jobs")
for j in sr_jobs:
j["_workflow_name"] = sr_name
j["_workflow_run_id"] = sr_id
sub_jobs_raw.append(j)
# Normalize + sort
all_jobs = [_normalize_job(j) for j in direct + sub_jobs_raw]
all_jobs = [j for j in all_jobs if j["status"] not in ("in_progress", "queued")]
all_jobs.sort(key=lambda j: j.get("started_at") or "")
_annotate_wait_times(all_jobs)
return {
"run_id": run_id,
"head_sha": head_sha,
"created_at": created_at,
"jobs": all_jobs,
}
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def fmt_dur(seconds: float | None) -> str:
if seconds is None:
return ""
if seconds < 60:
return f"{seconds:.1f}s"
m = int(seconds // 60)
s = seconds % 60
if s == 0:
return f"{m}m"
return f"{m}m{s:.0f}s"
def fmt_delta(current: float | None, baseline: float | None) -> tuple[str, str]:
"""Return (text, css_class) for a delta."""
if current is None or baseline is None:
return ("", "neutral")
delta = current - baseline
if baseline == 0:
pct_str = "new" if delta > 0 else "0%"
else:
pct = (delta / baseline) * 100
pct_str = f"{pct:+.1f}%"
if abs(delta) < 1.0:
cls = "neutral"
elif delta > 0:
cls = "slower"
else:
cls = "faster"
sign = "+" if delta >= 0 else ""
return (f"{sign}{delta:.1f}s ({pct_str})", cls)
def nice_ticks(max_seconds: float, num_ticks: int = 8) -> list[int]:
if max_seconds <= 0:
return [0]
raw = max_seconds / num_ticks
for nice in [5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200]:
if nice >= raw:
step = nice
break
else:
step = max(int(raw), 3600)
return list(range(0, int(max_seconds) + step + 1, step))
def fmt_tick(seconds: int) -> str:
if seconds < 60:
return f"{seconds}s"
m, s = divmod(seconds, 60)
if s == 0:
return f"{m}m"
return f"{m}m{s}s"
# ---------------------------------------------------------------------------
# Stats computation
# ---------------------------------------------------------------------------
def compute_stats(timings: dict, baseline: dict | None = None) -> dict:
jobs_all = timings.get("jobs", [])
jobs = [j for j in jobs_all if not is_skipped(j)]
bl_jobs_all = (baseline or {}).get("jobs", [])
bl_jobs = [j for j in bl_jobs_all if not is_skipped(j)]
bl_map = {j["name"]: j for j in bl_jobs}
# Wall time (skipped jobs have no real timestamps)
starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
wall = (max(ends) - min(starts)).total_seconds() if starts and ends else 0
compute = sum(j.get("duration_s") or 0 for j in jobs)
# Baseline wall/compute
bl_wall = None
bl_compute = None
if baseline:
bl_starts = [s for s in (parse_ts(j.get("started_at")) for j in bl_jobs) if s is not None]
bl_ends = [e for e in (parse_ts(j.get("completed_at")) for j in bl_jobs) if e is not None]
if bl_starts and bl_ends:
bl_wall = (max(bl_ends) - min(bl_starts)).total_seconds()
bl_compute = sum(j.get("duration_s") or 0 for j in bl_jobs)
# Per-job deltas (skipped excluded)
faster = 0
slower = 0
unchanged = 0
no_baseline = 0
for j in jobs:
bl = bl_map.get(j["name"])
if not bl:
no_baseline += 1
continue
cur_d = j.get("duration_s") or 0
bl_d = bl.get("duration_s") or 0
if abs(cur_d - bl_d) < 1.0:
unchanged += 1
elif cur_d > bl_d:
slower += 1
else:
faster += 1
skipped = sum(1 for j in jobs_all if is_skipped(j))
bl_skipped = sum(1 for j in bl_jobs_all if is_skipped(j))
total_wait = sum(j.get("wait_s") or 0 for j in jobs)
bl_total_wait = sum(j.get("wait_s") or 0 for j in bl_jobs)
return {
"wall": wall,
"compute": compute,
"bl_wall": bl_wall,
"bl_compute": bl_compute,
"faster": faster,
"slower": slower,
"unchanged": unchanged,
"no_baseline": no_baseline,
"skipped": skipped,
"bl_skipped": bl_skipped,
"total_wait": total_wait,
"bl_total_wait": bl_total_wait,
"total_jobs": len(jobs_all),
}
# ---------------------------------------------------------------------------
# Resource profile loading + bottleneck analysis
# ---------------------------------------------------------------------------
def load_resource_profiles(directory: str) -> dict[str, dict]:
"""Load all resource-profile-*/resource-profile.json artifacts.
Returns {label: profile_dict}. Labels are derived from the artifact
directory name (resource-profile-<label> → <label>).
"""
profiles: dict[str, dict] = {}
if not directory or not os.path.isdir(directory):
return profiles
for path in glob.glob(os.path.join(directory, "**", "resource-profile.json"), recursive=True):
try:
with open(path, encoding="utf-8") as f:
profile = json.load(f)
except (json.JSONDecodeError, OSError):
continue
label = profile.get("label") or os.path.basename(os.path.dirname(path))
profiles[label] = profile
return profiles
def _match_tokens(text: str) -> list[str]:
"""Lowercase alphanumeric tokens, for fuzzy label/job-name matching."""
return [t for t in re.sub(r"[^a-z0-9]+", " ", text.lower()).split() if t]
def _match_score(label: str, job_name: str) -> float:
"""Score how well a profile label matches a job name (0.0-1.0).
Profile labels are hand-written in the workflow (``tests-slice-3``)
while job names come from GitHub (``Python tests / Run tests slice
3/8``), so there is no exact key to join on. Three signals, blended:
* containment — what fraction of the label's tokens the job has.
The primary signal, since the label is the shorter string.
* Jaccard — penalises a job name that matches by being huge.
* sequence — orders otherwise-tied candidates by surface shape.
Digits are a hard gate rather than a weighted term: ``tests-slice-3``
and ``tests-slice-8`` differ in exactly one token, and without the
gate they score within noise of each other. Any digit token in the
label must appear in the job name or the pair scores 0.
"""
lt, jt = _match_tokens(label), _match_tokens(job_name)
if not lt or not jt:
return 0.0
ls, jss = set(lt), set(jt)
label_digits = {t for t in ls if t.isdigit()}
if label_digits and not label_digits <= jss:
return 0.0
containment = len(ls & jss) / len(ls)
jaccard = len(ls & jss) / len(ls | jss)
seq = difflib.SequenceMatcher(None, " ".join(lt), " ".join(jt)).ratio()
return 0.45 * containment + 0.35 * jaccard + 0.20 * seq
# Below this score a "best match" is more likely coincidence than a real
# pairing, so the profile is left unattached rather than overlaid onto an
# unrelated job's bar. Calibrated against a real run: true pairs scored
# 0.44-0.90, the best false pair 0.31.
_MATCH_THRESHOLD = 0.40
def match_profiles_to_jobs(timings: dict, profiles: dict[str, dict]) -> dict[str, dict]:
"""Return {job_name: profile} for profiles confidently matched to a job.
Greedy best-first over all (profile, job) pairs: the highest-scoring
pair is committed, then both sides are removed from contention. This
keeps one profile per job and one job per profile even when several
labels look alike (the eight ``tests-slice-N`` profiles against the
eight ``Run tests slice N/8`` jobs), which a per-profile argmax does
not guarantee.
"""
jobs = [j for j in timings.get("jobs", []) if not is_skipped(j)]
if not jobs or not profiles:
return {}
scored = []
for label, profile in profiles.items():
for job in jobs:
score = _match_score(label, job.get("name", ""))
if score >= _MATCH_THRESHOLD:
scored.append((score, label, job.get("name", ""), profile))
# Sort by score desc; ties broken on names so the result is stable
# regardless of dict iteration order.
scored.sort(key=lambda t: (-t[0], t[1], t[2]))
matched: dict[str, dict] = {}
used_labels: set[str] = set()
for _score, label, job_name, profile in scored:
if label in used_labels or job_name in matched:
continue
used_labels.add(label)
matched[job_name] = profile
return matched
def _sparkline_svg(series: list[int], color: str, klass: str) -> str:
"""Render a 0-100 series as a filled+stroked SVG sparkline that stretches.
``preserveAspectRatio="none"`` plus a viewBox in series-index space
lets one SVG scale to any bar width without recomputing points, so
the same markup serves both the 3px-tall collapsed strip and the
full-height expanded overlay.
The fill alone is too faint to read once three translucent layers are
stacked, so each series also gets a 1px stroked polyline on top. The
stroke carries ``vector-effect="non-scaling-stroke"``: without it the
non-uniform viewBox scaling (a ~200x180px box showing a 180x100
viewBox) stretches the stroke into an unreadable smear.
"""
if not series:
return ""
n = len(series)
width = max(n - 1, 1)
# y is inverted: SVG's origin is top-left, but 100% should draw at the top.
pts = " ".join(f"{i},{100 - v}" for i, v in enumerate(series))
# Close the polygon along the bottom edge so it reads as an area chart.
poly = f"0,100 {pts} {width},100"
return (
f'<svg class="res-spark {klass}" viewBox="0 0 {width} 100" '
f'preserveAspectRatio="none" aria-hidden="true">'
f'<polygon points="{poly}" fill="{color}"/>'
f'<polyline points="{pts}" fill="none" stroke="{color}" '
f'stroke-width="1" vector-effect="non-scaling-stroke"/>'
f'</svg>'
)
# Overlay series: (profile series key, color, css class).
_RES_SERIES = (
("cpu_pct", "#3fb950", "cpu"), # green
("mem_pct", "#d29922", "mem"), # amber
("disk_pct", "#f85149", "disk"), # red
)
def _resource_overlay(profile: dict | None, expanded: bool) -> str:
"""Render one job's CPU/RAM/disk overlay, or "" if there is no series.
Callers pass an unmatched job's ``None`` straight through, so this is
also the guard for "this job was never profiled". Old artifacts
predating the ``series`` field degrade the same way: table only, no
overlay. ``expanded`` selects which CSS state the layer renders in.
"""
if not profile:
return ""
series = profile.get("series") or {}
layers = []
for key, color, name in _RES_SERIES:
values = series.get(key) or []
if values:
layers.append(_sparkline_svg(values, color, name))
if not layers:
return ""
cpu = profile.get("cpu", {})
mem = profile.get("memory", {})
disk = profile.get("disk", {})
tip = (
f'CPU avg {cpu.get("avg_usage_pct", 0):.0f}% / peak {cpu.get("peak_usage_pct", 0):.0f}% · '
f'RAM peak {mem.get("peak_mb", 0):.0f} MB · '
f'disk util avg {disk.get("avg_util_pct", 0):.0f}%'
)
klass = "res-overlay expanded" if expanded else "res-overlay collapsed"
return f'<div class="{klass}" title="{escape(tip)}">{"".join(layers)}</div>'
def _profile_window_frac(profile: dict | None, job_start, job_end) -> tuple[float, float]:
"""Return (start, width) of the profiled window as fractions of the job bar.
``(0.0, 1.0)`` means "the whole bar" and is the fallback for every case
we cannot place confidently — that is exactly the old behaviour, so a
miss degrades to stretching rather than to a missing overlay.
Fractions rather than percentages because the two overlay states live in
different coordinate spaces: the collapsed strip is a child of the bar,
the expanded layer sits in the track. Each scales this once, instead of
one of them undoing the other's scaling.
Placement matters because the profiler wraps ONE step
(``.github/actions/profile``): on a job whose other steps dominate —
checkout, uv sync, post-job cleanup — the samples describe a slice in
the middle, and stretching them across the bar puts a CPU spike under a
step that never ran.
"""
if not profile or job_start is None or job_end is None:
return 0.0, 1.0
p_s = parse_ts(profile.get("started_at"))
p_e = parse_ts(profile.get("completed_at"))
if p_s is None or p_e is None:
return 0.0, 1.0 # artifact predates the timestamps
span = (job_end - job_start).total_seconds()
if span <= 0:
return 0.0, 1.0
# Clamped into the bar: a profiler signalled just after its step ends can
# outrun the job's completed_at by a second or two.
start = max(0.0, (p_s - job_start).total_seconds() / span)
end = min(1.0, (p_e - job_start).total_seconds() / span)
if end <= start:
return 0.0, 1.0 # clock skew put the window outside the job
# Floor keeps a hairline visible for a very short profile on a long job.
return start, max(end - start, 0.005)
def classify_bottleneck(timings: dict, profiles: dict[str, dict]) -> str:
"""Return a one-line bottleneck classification.
Examines wall time, compute time, wait time, and resource profiles
to identify the dominant constraint.
Possible verdicts:
- "CPU-bound: <job> at <pct>% CPU for <dur>"
- "Memory-bound: <job> peaked at <mb> MB"
- "Disk IO-bound: <job> at <ops>/s for <dur>"
- "Wait-bound: <wait>s idle waiting for dependencies"
- "Evenly distributed: no single bottleneck"
- "Insufficient data: <reason>"
"""
stats = compute_stats(timings, None)
jobs = [j for j in timings.get("jobs", []) if not is_skipped(j)]
if not jobs:
return "Insufficient data: no jobs in timings"
# --- Wait-bound: if total wait > 50% of wall time ---
wall = stats["wall"]
total_wait = stats["total_wait"]
if wall > 0 and total_wait > 0:
wait_pct = total_wait / wall * 100
if wait_pct > 50:
return (f"Wait-bound: {fmt_dur(total_wait)} idle "
f"({wait_pct:.0f}% of {fmt_dur(wall)} wall) waiting for dependencies")
# --- Resource-bound: check profiles for CPU/mem/disk extremes ---
if profiles:
cpu_hotspot = None
mem_hotspot = None
disk_hotspot = None
max_cpu = 0.0
max_mem_frac = 0.0
max_disk_ops = 0.0
for label, p in profiles.items():
cpu_info = p.get("cpu", {})
mem_info = p.get("memory", {})
disk_info = p.get("disk", {})
cpu_avg = cpu_info.get("avg_usage_pct", 0)
cpu_peak = cpu_info.get("peak_usage_pct", 0)
if cpu_avg > max_cpu:
max_cpu = cpu_avg
cpu_hotspot = (label, cpu_avg, cpu_peak, p.get("duration_s", 0))
# Memory is USED MB. Compare against the machine's total when the
# profile carries it; otherwise fall back to an absolute floor.
mem_peak = mem_info.get("peak_mb", 0)
mem_total = mem_info.get("total_mb", 0)
mem_frac = (mem_peak / mem_total) if mem_total > 0 else (mem_peak / 16000.0)
if mem_frac > max_mem_frac:
max_mem_frac = mem_frac
mem_hotspot = (label, mem_peak, mem_total)
disk_ops = disk_info.get("avg_ops_per_s", 0)
disk_mb = disk_info.get("total_mb", 0)
if disk_ops > max_disk_ops:
max_disk_ops = disk_ops
disk_hotspot = (label, disk_ops, disk_mb, p.get("duration_s", 0))
# Classify: pick the most extreme dimension.
# Each candidate's sort key is normalized to roughly 0-100 so the
# dimensions are comparable:
# CPU — avg usage pct (bound when > 80)
# Disk — avg completed IOs/s / 10 (bound when > 500 ops/s)
# Mem — peak used as pct of total (bound when > 85%)
candidates = []
if cpu_hotspot and cpu_hotspot[1] > 80:
candidates.append((
cpu_hotspot[1], # sort key
f"CPU-bound: {cpu_hotspot[0]} at {cpu_hotspot[1]:.0f}% avg CPU "
f"(peak {cpu_hotspot[2]:.0f}%) for {fmt_dur(cpu_hotspot[3])}"
))
if disk_hotspot and disk_hotspot[1] > 500:
candidates.append((
disk_hotspot[1] / 10,
f"Disk IO-bound: {disk_hotspot[0]} at {disk_hotspot[1]:.0f} ops/s "
f"({disk_hotspot[2]:.0f} MB total) for {fmt_dur(disk_hotspot[3])}"
))
if mem_hotspot and max_mem_frac > 0.85:
total_note = f" of {mem_hotspot[2]:.0f} MB" if mem_hotspot[2] else ""
candidates.append((
max_mem_frac * 100,
f"Memory-bound: {mem_hotspot[0]} peaked at "
f"{mem_hotspot[1]:.0f} MB used{total_note}"
))
if candidates:
candidates.sort(reverse=True)
return candidates[0][1]
# --- No resource profiles, but check wall vs compute ---
compute = stats["compute"]
if wall > 0 and compute > 0:
parallelism = compute / wall
if parallelism < 1.2 and len(jobs) > 2:
# Low parallelism ratio means jobs are serial
slowest = max(jobs, key=lambda j: j.get("duration_s") or 0)
slow_dur = slowest.get("duration_s") or 0
slow_pct = slow_dur / wall * 100 if wall > 0 else 0
if slow_pct > 40:
return (f"Serial bottleneck: {slowest['name']} takes "
f"{fmt_dur(slow_dur)} ({slow_pct:.0f}% of wall)")
# --- Fallback: the single slowest job ---
slowest = max(jobs, key=lambda j: j.get("duration_s") or 0)
slow_dur = slowest.get("duration_s") or 0
if slow_dur > 0 and wall > 0:
slow_pct = slow_dur / wall * 100
if slow_pct > 40 and len(jobs) > 1:
return (f"Dominated by {slowest['name']}: "
f"{fmt_dur(slow_dur)} ({slow_pct:.0f}% of wall)")
return "Evenly distributed: no single bottleneck"
# ---------------------------------------------------------------------------
# HTML generation
# ---------------------------------------------------------------------------
CSS = """
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.5; padding: 24px;
}
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
.meta { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
h2 { font-size: 18px; margin: 32px 0 12px; }
/* Stats cards */
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
.stat-card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 14px 18px; min-width: 140px;
}
.stat-label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 22px; font-weight: 600; margin: 4px 0; }
.stat-delta { font-size: 13px; }
.faster { color: #3fb950; }
.slower { color: #f85149; }
.neutral { color: #8b949e; }
/* Gantt */
.gantt-wrap { overflow-x: auto; }
.gantt { min-width: 700px; }
.gantt-row { display: flex; align-items: center; height: 28px; }
.gantt-label {
width: 220px; padding-right: 12px; text-align: right;
font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gantt-track { flex: 1; position: relative; height: 100%; border-left: 1px solid #21262d; }
.gantt-bar {
position: absolute; height: 18px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: transparent; overflow: hidden;
transition: color 0.15s;
}
.gantt-bar:hover { color: #fff; z-index: 10; }
.gantt-bar.current { background: #1f6feb; top: 5px; z-index: 2; }
.gantt-bar.wait {
background: repeating-linear-gradient(
45deg, #30363d, #30363d 3px, transparent 3px, transparent 6px
);
top: 5px; z-index: 1; opacity: 0.6;
}
.gantt-bar.baseline {
background: transparent; border: 1px dashed #8b949e; top: 2px; height: 24px; z-index: 1;
}
/* Step segmentation inside the job bar. Segments are children of
.gantt-bar.current, so their percentages are relative to the bar. The
right border is the divider between consecutive steps. */
.gantt-seg {
position: absolute; top: 0; height: 100%;
border-right: 1px solid rgba(1,4,9,0.85);
box-sizing: border-box;
}
.gantt-seg:last-child { border-right: none; }
.gantt-seg.setup { background: #d29922; }
.gantt-seg.work { background: #1f6feb; }
.gantt-seg.teardown { background: #8957e5; }
.gantt-seg:hover { filter: brightness(1.45); }
/* Expandable job rows */
.gantt-row.job.expandable { cursor: pointer; }
.gantt-row.job.expandable:hover .gantt-label { color: #58a6ff; }
.caret {
display: inline-block; margin-right: 4px; font-size: 9px; color: #8b949e;
transition: transform 0.15s;
}
.gantt-group.open .caret { transform: rotate(90deg); }
.gantt-steps { display: none; }
.gantt-group.open .gantt-steps { display: block; min-height: 26px; }
/* Resource overlay (CPU / RAM / disk sparklines over a job bar), in two
states of the same series:
.collapsed — a 3px strip inside .gantt-bar.current, clipped to the job's
extent and adding no row height. Enough to spot a saturated
job while scanning.
.expanded — the same curves over the full height of the open group, so
they read against the step rows that caused them.
.gantt-steps is the positioning context for the expanded layer, with a
min-height so a profiled job with no step detail still shows one.
pointer-events:none keeps step tooltips and click-to-expand working
through the overlay. */
.gantt-steps { position: relative; }
.res-layer {
position: absolute; inset: 0; display: flex;
pointer-events: none; z-index: 0;
}
.res-layer .gantt-label { height: 100%; }
.res-layer .gantt-track { height: 100%; border-left: none; }
.res-overlay { position: absolute; left: 0; width: 100%; pointer-events: none; }
.res-overlay.collapsed { bottom: 0; height: 3px; }
.res-overlay.expanded { top: 0; height: 100%; }
/* Positions the collapsed strip over the profiled window within the bar.
The strip itself is 100%-wide of THIS box, not of the job bar, so a job
whose profiled step is a slice in the middle shows the sparkline only
under that slice. */
.res-clip { position: absolute; bottom: 0; height: 3px; pointer-events: none; }
.res-spark { position: absolute; inset: 0; width: 100%; height: 100%; }
/* Area fills so all three read stacked; the stroke on top is what actually
makes each curve legible. At 3px the fill needs near-full opacity to
register and the stroke would just be noise. */
.res-spark polygon { fill-opacity: 0.3; }
.res-spark polyline { opacity: 0.95; }
.res-overlay.collapsed .res-spark polygon { fill-opacity: 0.9; }
.res-overlay.collapsed .res-spark polyline { display: none; }
.res-holder {
position: absolute; top: 0; bottom: 0;
background: rgba(1,4,9,0.55);
border-left: 1px solid #21262d; border-right: 1px solid #21262d;
overflow: hidden;
}
/* Step bars must stay above the overlay. */
.gantt-row.step { position: relative; z-index: 1; height: 18px; }
.gantt-label.step {
font-size: 11px; color: #8b949e; padding-left: 18px;
text-align: right; direction: rtl;
}
.gantt-bar.step { height: 10px; top: 4px; opacity: 0.85; z-index: 2; }
.gantt-bar.step.setup { background: #d29922; }
.gantt-bar.step.work { background: #1f6feb; }
.gantt-bar.step.teardown { background: #8957e5; }
/* Overhead breakdown bar */
.overhead-bar {
display: flex; height: 26px; border-radius: 4px; overflow: hidden;
margin: 8px 0 6px; border: 1px solid #30363d;
}
.overhead-seg {
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #010409; white-space: nowrap;
overflow: hidden;
}
.overhead-seg.setup { background: #d29922; }
.overhead-seg.work { background: #1f6feb; color: #fff; }
.overhead-seg.teardown { background: #8957e5; color: #fff; }
.gantt-hint { font-size: 12px; color: #8b949e; margin-bottom: 8px; }
.gantt-hint button {
background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
border-radius: 5px; padding: 2px 10px; font-size: 12px; cursor: pointer;
font-family: inherit; margin-left: 4px;
}
.gantt-hint button:hover { background: #30363d; border-color: #8b949e; }
.gantt-axis { display: flex; height: 20px; position: relative; border-top: 1px solid #30363d; margin-top: 4px; }
.gantt-tick { position: absolute; font-size: 10px; color: #8b949e; transform: translateX(-50%); top: 4px; }
.gantt-tick::before { content: ''; position: absolute; top: -4px; left: 50%; width: 1px; height: 4px; background: #30363d; }
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: #8b949e; }
.legend-swatch { display: inline-block; width: 16px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
.legend-sep { color: #30363d; }
/* Tables */
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 16px; }
th, td { border: 1px solid #30363d; padding: 6px 10px; text-align: left; }
th { background: #161b22; font-weight: 600; position: sticky; top: 0; }
tr:hover td { background: #161b22; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.job-name { font-weight: 500; }
/* Step details */
details { margin-bottom: 8px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
summary { padding: 8px 12px; cursor: pointer; font-weight: 500; font-size: 14px; user-select: none; }
summary:hover { background: #21262d; }
details[open] summary { border-bottom: 1px solid #30363d; }
details table { border: none; margin: 0; }
details td, details th { font-size: 12px; }
/* Worst regressions */
.regressions { margin-bottom: 24px; }
.regressions table { font-size: 13px; }
.tag {
display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500;
}
.tag.slow { background: rgba(248,81,73,0.15); color: #f85149; }
.tag.fast { background: rgba(63,185,80,0.15); color: #3fb950; }
"""
def _gantt_bars(timings: dict, baseline: dict | None,
profiles: dict[str, dict] | None = None) -> str:
"""Render the gantt chart HTML.
Both current and baseline timelines are normalized to start at t=0
(relative to each run's earliest job start). The axis scale spans
0..max_end across both runs so bars are directly comparable.
When resource profiles are supplied, each matched job also carries a
CPU/RAM/disk overlay: a thin strip inside the collapsed bar, and a
full-height readable version once the job is expanded.
"""
jobs = [j for j in timings.get("jobs", [])
if j.get("started_at") and j.get("completed_at") and not is_skipped(j)]
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
job_profiles = match_profiles_to_jobs(timings, profiles or {})
# Current run: relative offsets from earliest start
cur_starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
cur_ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
if not cur_starts or not cur_ends:
return '<p style="color:#8b949e">No timing data available.</p>'
cur_t0 = min(cur_starts)
cur_max = (max(cur_ends) - cur_t0).total_seconds()
# Baseline run: max duration (for axis scale only — bars are aligned
# to the current job's start, not to the baseline timeline)
bl_max_dur = 0.0
for bl_j in bl_map.values():
if is_skipped(bl_j):
continue
s = parse_ts(bl_j.get("started_at"))
e = parse_ts(bl_j.get("completed_at"))
if s is not None and e is not None:
bl_max_dur = max(bl_max_dur, (e - s).total_seconds())
total_s = max(cur_max, bl_max_dur)
if total_s <= 0:
total_s = 1
rows = []
for j in jobs:
s = parse_ts(j.get("started_at"))
e = parse_ts(j.get("completed_at"))
if s is None or e is None:
continue
left = (s - cur_t0).total_seconds() / total_s * 100
width = max((e - s).total_seconds() / total_s * 100, 0.5) # min 0.5% for visibility
dur = j.get("duration_s") or 0
bl = bl_map.get(j["name"])
bl_bar = ""
if bl and not is_skipped(bl):
bl_s = parse_ts(bl.get("started_at"))
bl_e = parse_ts(bl.get("completed_at"))
if bl_s is not None and bl_e is not None:
# Align baseline bar to the current job's start so the
# two durations are directly comparable — the baseline's
# own wait/position is irrelevant for a duration diff.
bl_left = left
bl_width = max((bl_e - bl_s).total_seconds() / total_s * 100, 0.5)
bl_dur = bl.get("duration_s") or 0
bl_bar = (
f'<div class="gantt-bar baseline" '
f'style="left:{bl_left:.2f}%;width:{bl_width:.2f}%" '
f'title="baseline: {fmt_dur(bl_dur)}"></div>'
)
name_display = escape(j["name"])
if j.get("workflow_name"):
name_display = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
delta_info = ""
if bl and not is_skipped(bl) and bl.get("duration_s") is not None:
d_text, d_cls = fmt_delta(dur, bl.get("duration_s"))
delta_info = f'{d_text}'
# Wait bar: shows idle time before the job started running
wait_bar = ""
wait_s = j.get("wait_s")
if wait_s and wait_s >= 1.0:
wait_left = (s - cur_t0).total_seconds() - wait_s
wait_left_pct = max(wait_left / total_s * 100, 0)
wait_width_pct = max(wait_s / total_s * 100, 0.3)
wait_bar = (
f'<div class="gantt-bar wait" '
f'style="left:{wait_left_pct:.2f}%;width:{wait_width_pct:.2f}%" '
f'title="{escape(j["name"])} — waited: {fmt_dur(wait_s)}"></div>'
)
# Segment the job bar by step, so the composition of a job is visible
# without expanding it. Segments are nested INSIDE the job bar, so
# their offsets are percentages of the bar's own width (not of the
# whole track) — hence job_span, not total_s, as the denominator.
# The expanded step rows sit in the track instead, so those use the
# track scale.
#
# GitHub reports step timestamps at second granularity, so a
# sub-second step has started_at == completed_at and would render
# zero-width. Every segment gets a small minimum width; the tooltip
# carries the true duration. Short steps stay hoverable at the cost
# of segments summing to slightly over 100% on very short jobs.
job_span = (e - s).total_seconds() or 1.0
segments = []
step_rows = []
for st in (j.get("steps") or []):
st_s = parse_ts(st.get("started_at"))
st_e = parse_ts(st.get("completed_at"))
if st_s is None or st_e is None:
continue
cat = classify_step(st.get("name", ""))
st_dur = st.get("duration_s") or 0
st_name = escape(display_step_name(st.get("name", "")))
tip = f'{st_name}{fmt_dur(st_dur)} [{cat}]'
# Within-bar coordinates (percent of the job bar).
in_left = max((st_s - s).total_seconds() / job_span * 100, 0)
in_width = max((st_e - st_s).total_seconds() / job_span * 100, 0.4)
segments.append(
f'<div class="gantt-seg {cat}" '
f'style="left:{in_left:.3f}%;width:{in_width:.3f}%" '
f'title="{tip}"></div>'
)
# Track coordinates (percent of the whole timeline) for the
# expanded rows.
tr_left = (st_s - cur_t0).total_seconds() / total_s * 100
tr_width = max((st_e - st_s).total_seconds() / total_s * 100, 0.15)
step_rows.append(
f'<div class="gantt-row step">'
f'<div class="gantt-label step" title="{st_name}">{st_name}</div>'
f'<div class="gantt-track">'
f'<div class="gantt-bar step {cat}" '
f'style="left:{tr_left:.3f}%;width:{tr_width:.3f}%" '
f'title="{tip}"></div>'
f'</div></div>'
)
seg_html = "".join(segments)
# Resource overlay, drawn over the PROFILED WINDOW rather than the
# whole job — see _profile_window_frac. The collapsed strip is a
# child of the bar (clipped to it, adds no row height) so it scales
# the fraction against the bar; the expanded layer is its own track
# row, so it scales against the track.
profile = job_profiles.get(j["name"])
f_left, f_width = _profile_window_frac(profile, s, e)
collapsed_overlay = _resource_overlay(profile, expanded=False)
if collapsed_overlay:
collapsed_overlay = (
f'<div class="res-clip" '
f'style="left:{f_left * 100:.2f}%;width:{f_width * 100:.2f}%">'
f'{collapsed_overlay}</div>'
)
expanded_overlay = ""
inner = _resource_overlay(profile, expanded=True)
if inner:
expanded_overlay = (
f'<div class="res-layer">'
f'<div class="gantt-label"></div>'
f'<div class="gantt-track">'
f'<div class="res-holder" '
f'style="left:{left + f_left * width:.2f}%;'
f'width:{f_width * width:.2f}%">{inner}</div>'
f'</div></div>'
)
# Only offer expansion when there is detail to show.
has_detail = bool(step_rows or expanded_overlay)
expandable = " expandable" if has_detail else ""
caret = '<span class="caret">▸</span>' if has_detail else ""
rows.append(
f'<div class="gantt-group">'
f'<div class="gantt-row job{expandable}">'
f'<div class="gantt-label" title="{escape(j["name"])}">{caret}{name_display}</div>'
f'<div class="gantt-track">'
f'{bl_bar}'
f'{wait_bar}'
f'<div class="gantt-bar current" '
f'style="left:{left:.2f}%;width:{width:.2f}%" '
f'title="{escape(j["name"])}: {fmt_dur(dur)}{delta_info}">'
f'{seg_html}{collapsed_overlay}</div>'
f'</div></div>'
f'<div class="gantt-steps">{expanded_overlay}{"".join(step_rows)}</div>'
f'</div>'
)
# Axis
ticks = nice_ticks(total_s)
tick_html = "".join(
f'<span class="gantt-tick" style="left:{(t / total_s * 100):.1f}%">{fmt_tick(t)}</span>'
for t in ticks
)
# Empty label spacer keeps the axis track aligned with the bar tracks
# (each gantt-row is [label][track]; without the spacer the axis spans
# the label column too and every tick lands left of its true position).
axis = (
f'<div class="gantt-axis">'
f'<div class="gantt-label"></div>'
f'<div class="gantt-track">{tick_html}</div>'
f'</div>'
)
legend = (
'<div class="legend">'
'<span><span class="legend-swatch" style="background:#d29922"></span>Setup</span>'
'<span><span class="legend-swatch" style="background:#1f6feb"></span>Work</span>'
'<span><span class="legend-swatch" style="background:#8957e5"></span>Teardown</span>'
'<span><span class="legend-swatch" style="background:repeating-linear-gradient(45deg,#30363d,#30363d 3px,transparent 3px,transparent 6px);opacity:0.6"></span>Wait</span>'
)
if baseline:
legend += '<span><span class="legend-swatch" style="border:1px dashed #8b949e"></span>Baseline (main)</span>'
if job_profiles:
legend += (
'<span class="legend-sep">|</span>'
'<span><span class="legend-swatch" style="background:#3fb950"></span>CPU %</span>'
'<span><span class="legend-swatch" style="background:#d29922"></span>RAM %</span>'
'<span><span class="legend-swatch" style="background:#f85149"></span>Disk %</span>'
)
legend += '</div>'
hint_extra = ""
if job_profiles:
hint_extra = (
'The thin strip along the bottom of a bar is node CPU / RAM / disk '
f'utilisation over that job ({len(job_profiles)} profiled); expand for a readable version. '
)
hint = (
'<div class="gantt-hint">'
'Bars are segmented by step — hover a segment for its name and duration. '
f'{hint_extra}'
'Click a job to expand its steps. '
'<button type="button" id="gantt-toggle-all">Expand all</button>'
'</div>'
)
# Vanilla JS, no deps: the report is a single self-contained HTML file
# served from an artifact URL.
script = """
<script>
(function () {
var groups = Array.prototype.slice.call(
document.querySelectorAll('.gantt-group')
);
groups.forEach(function (g) {
var row = g.querySelector('.gantt-row.job.expandable');
if (!row) return;
row.addEventListener('click', function () { g.classList.toggle('open'); });
});
var btn = document.getElementById('gantt-toggle-all');
if (!btn) return;
btn.addEventListener('click', function () {
var expandable = groups.filter(function (g) {
return g.querySelector('.gantt-row.job.expandable');
});
var anyClosed = expandable.some(function (g) {
return !g.classList.contains('open');
});
expandable.forEach(function (g) { g.classList.toggle('open', anyClosed); });
btn.textContent = anyClosed ? 'Collapse all' : 'Expand all';
});
})();
</script>
"""
return (
f'{hint}<div class="gantt-wrap"><div class="gantt">{"".join(rows)}{axis}</div></div>'
f'{legend}{script}'
)
def _overhead_section(timings: dict) -> str:
"""Quantify how much CI compute goes to setup/teardown rather than work.
This is the "is checkout the bottleneck?" answer: a stacked bar over all
accounted step time, plus the individual overhead steps that cost the
most summed across every job.
"""
ov = compute_overhead(timings)
total = ov["accounted_s"]
if total <= 0:
return ('<p style="color:#8b949e">No per-step timing data available '
'(GitHub returns steps only for jobs this token can read).</p>')
setup_pct = ov["setup_s"] / total * 100
work_pct = ov["work_s"] / total * 100
teardown_pct = ov["teardown_s"] / total * 100
def seg(cls, pct, label):
if pct < 0.5:
return ""
# Only label a segment wide enough to hold text.
text = label if pct >= 8 else ""
return (f'<div class="overhead-seg {cls}" style="width:{pct:.2f}%" '
f'title="{label}">{text}</div>')
bar = (
'<div class="overhead-bar">'
+ seg("setup", setup_pct, f'Setup {fmt_dur(ov["setup_s"])} ({setup_pct:.0f}%)')
+ seg("work", work_pct, f'Work {fmt_dur(ov["work_s"])} ({work_pct:.0f}%)')
+ seg("teardown", teardown_pct,
f'Teardown {fmt_dur(ov["teardown_s"])} ({teardown_pct:.0f}%)')
+ '</div>'
)
headline = (
f'<p style="margin:4px 0 12px"><strong>{ov["overhead_pct"]:.0f}%</strong> of '
f'accounted step time ({fmt_dur(ov["overhead_s"])} of {fmt_dur(total)}) is '
f'setup + teardown rather than useful work, across {ov["jobs_with_steps"]} jobs.</p>'
)
rows = []
for s in ov["top_overhead_steps"]:
pct = s["total_s"] / total * 100
rows.append(
f'<tr><td class="job-name">{escape(display_step_name(s["name"]))}</td>'
f'<td>{s["category"]}</td>'
f'<td class="num">{s["count"]}</td>'
f'<td class="num">{fmt_dur(s["total_s"])}</td>'
f'<td class="num">{pct:.1f}%</td></tr>'
)
table = (
'<table><thead><tr><th>Overhead step</th><th>Kind</th>'
'<th class="num">Jobs</th><th class="num">Total</th>'
'<th class="num">% of step time</th></tr></thead>'
f'<tbody>{"".join(rows)}</tbody></table>'
) if rows else ""
note = (
'<p style="font-size:12px;color:#8b949e">Percentages are of summed '
'step time across jobs (not wall time — jobs run in parallel). '
'Unrecognized step names count as work, so this under-reports rather '
'than inflates overhead.</p>'
)
return headline + bar + table + note
def _stats_cards(stats: dict) -> str:
wall_text = fmt_dur(stats["wall"])
wall_delta = ""
if stats["bl_wall"] is not None:
d, cls = fmt_delta(stats["wall"], stats["bl_wall"])
wall_delta = f'<span class="stat-delta {cls}">{d}</span>'
compute_text = fmt_dur(stats["compute"])
compute_delta = ""
if stats["bl_compute"] is not None:
d, cls = fmt_delta(stats["compute"], stats["bl_compute"])
compute_delta = f'<span class="stat-delta {cls}">{d}</span>'
cards = [
f'<div class="stat-card"><span class="stat-label">Wall Time</span>'
f'<div class="stat-value">{wall_text}</div>{wall_delta}</div>',
f'<div class="stat-card"><span class="stat-label">Total Compute</span>'
f'<div class="stat-value">{compute_text}</div>{compute_delta}</div>',
f'<div class="stat-card"><span class="stat-label">Jobs Faster</span>'
f'<div class="stat-value faster">{stats["faster"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">Jobs Slower</span>'
f'<div class="stat-value slower">{stats["slower"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">Unchanged</span>'
f'<div class="stat-value neutral">{stats["unchanged"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">Skipped</span>'
f'<div class="stat-value neutral">{stats["skipped"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">No Baseline</span>'
f'<div class="stat-value neutral">{stats["no_baseline"]}</div></div>',
]
return f'<div class="stats">{"".join(cards)}</div>'
def _job_table(timings: dict, baseline: dict | None) -> str:
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
rows = []
for j in timings.get("jobs", []):
name = escape(j["name"])
if j.get("workflow_name"):
name = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
concl = j.get("conclusion", "")
concl_icon = {"success": "", "failure": "", "skipped": ""}.get(concl, "?")
concl_cls = {"success": "faster", "failure": "slower", "skipped": "neutral"}.get(concl, "neutral")
if is_skipped(j):
rows.append(
f'<tr>'
f'<td class="job-name">{name}</td>'
f'<td class="num neutral">skipped</td>'
f'<td class="num neutral">—</td>'
f'<td class="num neutral">—</td>'
f'<td class="num neutral">—</td>'
f'<td class="num neutral">—</td>'
f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
f'</tr>'
)
continue
dur = j.get("duration_s")
wait = j.get("wait_s")
bl = bl_map.get(j["name"])
bl_dur = bl.get("duration_s") if bl and not is_skipped(bl) else None
bl_wait = bl.get("wait_s") if bl and not is_skipped(bl) else None
delta_text, delta_cls = fmt_delta(dur, bl_dur)
wait_delta_text, wait_delta_cls = fmt_delta(wait, bl_wait)
rows.append(
f'<tr>'
f'<td class="job-name">{name}</td>'
f'<td class="num">{fmt_dur(dur)}</td>'
f'<td class="num">{fmt_dur(wait)}</td>'
f'<td class="num">{fmt_dur(bl_dur)}</td>'
f'<td class="num {delta_cls}">{delta_text}</td>'
f'<td class="num {wait_delta_cls}">{wait_delta_text}</td>'
f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
f'</tr>'
)
return (
'<table><thead><tr>'
'<th>Job</th><th class="num">Run</th><th class="num">Wait</th>'
'<th class="num">Baseline</th><th class="num">Δ Run</th>'
'<th class="num">Δ Wait</th><th>Status</th>'
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
)
def _step_details(timings: dict, baseline: dict | None) -> str:
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
blocks = []
for j in timings.get("jobs", []):
if not j.get("steps"):
continue
if is_skipped(j):
continue
bl = bl_map.get(j["name"], {})
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
dur = j.get("duration_s") or 0
wait = j.get("wait_s")
bl_dur = bl.get("duration_s") if bl and not is_skipped(bl) else None
delta_text, delta_cls = fmt_delta(dur, bl_dur)
summary_text = f'{escape(j["name"])}{fmt_dur(dur)}'
if wait is not None and wait >= 1.0:
summary_text += f' <span class="neutral">(wait {fmt_dur(wait)})</span>'
if bl_dur is not None:
summary_text += f' <span class="{delta_cls}">({delta_text})</span>'
step_rows = []
for s in j["steps"]:
s_dur = s.get("duration_s")
bl_s = bl_steps.get(s["name"])
bl_s_dur = bl_s.get("duration_s") if bl_s else None
s_delta, s_cls = fmt_delta(s_dur, bl_s_dur)
step_rows.append(
f'<tr>'
f'<td>{escape(display_step_name(s["name"]))}</td>'
f'<td class="num">{fmt_dur(s_dur)}</td>'
f'<td class="num">{fmt_dur(bl_s_dur)}</td>'
f'<td class="num {s_cls}">{s_delta}</td>'
f'</tr>'
)
blocks.append(
f'<details><summary>{summary_text}</summary>'
f'<table><thead><tr>'
'<th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
'<th class="num">Delta</th>'
f'</tr></thead><tbody>{"".join(step_rows)}</tbody></table>'
f'</details>'
)
return "".join(blocks) if blocks else '<p style="color:#8b949e">No step data available.</p>'
def _regressions(timings: dict, baseline: dict | None) -> str:
"""Show top 10 biggest absolute regressions/improvements across all steps."""
if not baseline:
return ""
bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
deltas = [] # (abs_delta, job_name, step_name, current, baseline, is_slower)
for j in timings.get("jobs", []):
if is_skipped(j):
continue
bl = bl_map.get(j["name"])
if not bl or is_skipped(bl):
continue
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
for s in j.get("steps", []):
bl_s = bl_steps.get(s["name"])
if not bl_s:
continue
cur = s.get("duration_s") or 0
bl_d = bl_s.get("duration_s") or 0
diff = cur - bl_d
if abs(diff) < 1.0:
continue
deltas.append((abs(diff), diff, j["name"], s["name"], cur, bl_d))
deltas.sort(key=lambda x: x[0], reverse=True)
top = deltas[:10]
if not top:
return ""
rows = []
for _, diff, job, step, cur, bl_d in top:
cls = "slower" if diff > 0 else "faster"
tag = f'<span class="tag {"slow" if diff > 0 else "fast"}">{"+" if diff > 0 else ""}{diff:.1f}s</span>'
rows.append(
f'<tr>'
f'<td class="job-name">{escape(job)}</td>'
f'<td>{escape(display_step_name(step))}</td>'
f'<td class="num">{fmt_dur(cur)}</td>'
f'<td class="num">{fmt_dur(bl_d)}</td>'
f'<td>{tag}</td>'
f'</tr>'
)
return (
'<div class="regressions">'
'<table><thead><tr>'
'<th>Job</th><th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
'<th>Delta</th>'
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
'</div>'
)
def _resource_table(profiles: dict[str, dict]) -> str:
"""Render per-job resource usage as an HTML table."""
if not profiles:
return ""
rows = []
for label in sorted(profiles):
p = profiles[label]
cpu = p.get("cpu", {})
mem = p.get("memory", {})
disk = p.get("disk", {})
rows.append(
f'<tr>'
f'<td class="job-name">{escape(label)}</td>'
f'<td class="num">{fmt_dur(p.get("duration_s"))}</td>'
f'<td class="num">{cpu.get("avg_usage_pct", 0):.0f}%</td>'
f'<td class="num">{cpu.get("peak_usage_pct", 0):.0f}%</td>'
f'<td class="num">{mem.get("avg_mb", 0):.0f}</td>'
f'<td class="num">{mem.get("peak_mb", 0):.0f}</td>'
f'<td class="num">{disk.get("total_mb", 0):.0f}</td>'
f'<td class="num">{disk.get("avg_ops_per_s", 0):.0f}</td>'
f'<td class="num">{disk.get("avg_util_pct", 0):.0f}%</td>'
f'</tr>'
)
return (
'<table><thead><tr>'
'<th>Job</th><th class="num">Duration</th>'
'<th class="num">CPU avg</th><th class="num">CPU peak</th>'
'<th class="num">Mem avg (MB)</th><th class="num">Mem peak (MB)</th>'
'<th class="num">Disk (MB)</th><th class="num">Disk ops/s</th>'
'<th class="num">Disk util</th>'
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
)
def _bottleneck_box(timings: dict, profiles: dict[str, dict]) -> str:
"""Render the bottleneck analysis as a callout box."""
verdict = classify_bottleneck(timings, profiles)
return (
f'<div style="background:#161b22;border:1px solid #30363d;'
f'border-radius:8px;padding:16px;margin-bottom:24px">'
f'<div style="font-size:12px;color:#8b949e;text-transform:uppercase;'
f'letter-spacing:0.5px;margin-bottom:4px">Bottleneck Analysis</div>'
f'<div style="font-size:16px;font-weight:500">{escape(verdict)}</div>'
f'</div>'
)
def generate_html(timings: dict, baseline: dict | None = None,
profiles: dict[str, dict] | None = None) -> str:
stats = compute_stats(timings, baseline)
sha_short = (timings.get("head_sha") or "")[:7]
run_id = timings.get("run_id", "?")
created = timings.get("created_at", "")
bl_info = ""
if baseline:
bl_sha = (baseline.get("head_sha") or "")[:7]
bl_info = f' | Baseline: <code>{bl_sha}</code> (main)'
html = (
f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
f'<meta charset="utf-8">\n'
f'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f'<title>CI Timing Report — {sha_short}</title>\n'
f'<style>{CSS}</style>\n'
f'</head>\n<body>\n'
f'<h1>CI Timing Report</h1>\n'
f'<div class="meta">Run <code>{escape(run_id)}</code> | SHA <code>{sha_short}</code>'
f' | Generated {escape(created)}{bl_info}</div>\n'
)
html += '<h2>Global Stats</h2>\n'
html += _stats_cards(stats)
html += _bottleneck_box(timings, profiles or {})
html += '<h2>Setup vs Work</h2>\n'
html += _overhead_section(timings)
if profiles:
html += '<h2>Resource Usage</h2>\n'
html += _resource_table(profiles)
if baseline:
html += '<h2>Top Regressions & Improvements</h2>\n'
html += _regressions(timings, baseline)
html += '<h2>Gantt Chart</h2>\n'
html += _gantt_bars(timings, baseline, profiles)
html += '<h2>Per-Job Comparison</h2>\n'
html += _job_table(timings, baseline)
html += '<h2>Step Details</h2>\n'
html += _step_details(timings, baseline)
html += '</body>\n</html>\n'
return html
# ---------------------------------------------------------------------------
# Markdown summary for $GITHUB_STEP_SUMMARY
# ---------------------------------------------------------------------------
def generate_summary(timings: dict, baseline: dict | None = None,
profiles: dict[str, dict] | None = None) -> str:
stats = compute_stats(timings, baseline)
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
lines = ["## CI Timing Summary\n"]
# Bottleneck analysis
bottleneck = classify_bottleneck(timings, profiles or {})
lines.append(f"**Bottleneck:** {bottleneck}")
lines.append("")
# Global stats table
lines.append("| Metric | Current | Baseline | Delta |")
lines.append("|--------|---------|----------|-------|")
wall_d = ""
if stats["bl_wall"] is not None:
d, _ = fmt_delta(stats["wall"], stats["bl_wall"])
wall_d = d
lines.append(f"| Wall time | {fmt_dur(stats['wall'])} | {fmt_dur(stats['bl_wall'])} | {wall_d} |")
compute_d = ""
if stats["bl_compute"] is not None:
d, _ = fmt_delta(stats["compute"], stats["bl_compute"])
compute_d = d
lines.append(f"| Total compute | {fmt_dur(stats['compute'])} | {fmt_dur(stats['bl_compute'])} | {compute_d} |")
wait_d = ""
if stats["bl_total_wait"] is not None:
d, _ = fmt_delta(stats["total_wait"], stats["bl_total_wait"])
wait_d = d
lines.append(f"| Total wait | {fmt_dur(stats['total_wait'])} | {fmt_dur(stats['bl_total_wait'])} | {wait_d} |")
lines.append(f"| Jobs faster | {stats['faster']} | — | — |")
lines.append(f"| Jobs slower | {stats['slower']} | — | — |")
lines.append(f"| Jobs unchanged | {stats['unchanged']} | — | — |")
lines.append(f"| Jobs skipped | {stats['skipped']} | {stats['bl_skipped']} | — |")
lines.append(f"| Jobs without baseline | {stats['no_baseline']} | — | — |")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Review status JSON for the unified PR comment
# ---------------------------------------------------------------------------
# Wall-time regressions above this fraction of baseline are "warning" severity.
_TIMINGS_WARN_PCT = 0.25
def generate_review_status(
timings: dict, baseline: dict | None, report_url: str | None = None,
profiles: dict[str, dict] | None = None
) -> list[dict]:
"""Produce a review_status JSON array for the CI timings review section.
Returns a list with one ``{source, results: [...]}`` entry. The
result kind is ``"debug"`` or ``"warning"`` (timings is never error —
it's an observability job). *summary* is a single short line suitable
for the PR comment. *detail* has the per-job deltas as a markdown
fragment.
"""
stats = compute_stats(timings, baseline)
bottleneck = classify_bottleneck(timings, profiles or {})
if baseline is None:
severity = "debug"
summary = f"Wall time {fmt_dur(stats['wall'])} (no baseline yet)."
else:
wall = stats["wall"]
bl_wall = stats["bl_wall"] or 0
if bl_wall > 0:
pct = (wall - bl_wall) / bl_wall * 100
wall_str = f"Wall time {fmt_dur(wall)} vs {fmt_dur(bl_wall)} ({pct:+.1f}%)."
if pct > _TIMINGS_WARN_PCT * 100:
severity = "warning"
else:
severity = "debug"
else:
wall_str = f"Wall time {fmt_dur(wall)}."
severity = "debug"
if stats["slower"]:
wall_str += f" {stats['slower']} job(s) slower,"
if stats["faster"]:
wall_str += f" {stats['faster']} faster,"
if stats["unchanged"]:
wall_str += f" {stats['unchanged']} unchanged."
summary = wall_str
summary += f" Bottleneck: {bottleneck}"
# Per-job delta detail (top 5 by absolute change)
detail_lines: list[str] = []
if baseline:
bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
deltas: list[tuple[float, str, str]] = []
for j in timings.get("jobs", []):
if is_skipped(j):
continue
bl = bl_map.get(j["name"])
if not bl or is_skipped(bl):
continue
cur = j.get("duration_s") or 0
bl_d = bl.get("duration_s") or 0
diff = cur - bl_d
if abs(diff) < 1.0:
continue
deltas.append((abs(diff), j["name"], f"{diff:+.1f}s"))
deltas.sort(reverse=True)
for _, name, delta_str in deltas[:5]:
detail_lines.append(f"- {name}: {delta_str}")
result: dict = {
"kind": severity,
"title": "CI timings",
"summary": summary,
"detail": "\n".join(detail_lines),
}
if report_url:
result["link"] = report_url
result["link_label"] = "View report"
return [{"source": "ci timing", "results": [result]}]
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def expect_env(var: str) -> str:
val = os.environ.get(var)
if not val:
raise ValueError(f"missing environment variable {var}")
return val
def main():
parser = argparse.ArgumentParser(description="Collect CI timings and generate HTML report")
parser.add_argument("--from-json", help="Read timings from JSON instead of API")
parser.add_argument("--baseline", default="ci-timings-baseline.json",
help="Baseline JSON path (default: ci-timings-baseline.json)")
parser.add_argument("--output", default="ci-timings-report.html",
help="HTML output path (default: ci-timings-report.html)")
parser.add_argument("--json-out", default="ci-timings.json",
help="JSON output path (default: ci-timings.json)")
parser.add_argument("--summary-out", default="ci-timings-summary.md",
help="Markdown summary output path (default: ci-timings-summary.md)")
parser.add_argument("--review-status-out", default="",
help="If set, write a review-status JSON for the unified PR comment.")
parser.add_argument("--review-status-only", action="store_true",
help="Write review status from existing timings without regenerating the report.")
parser.add_argument("--profiles-dir", default="",
help="Directory of downloaded resource-profile-* artifacts.")
args = parser.parse_args()
# Load resource profiles (available in both API and --from-json modes)
profiles = load_resource_profiles(args.profiles_dir) if args.profiles_dir else {}
# Collect or load timings
if args.from_json:
with open(args.from_json, encoding="utf-8") as f:
timings = json.load(f)
else:
repo = expect_env("GITHUB_REPOSITORY")
run_id = expect_env("GITHUB_RUN_ID")
head_sha = expect_env("GITHUB_SHA")
try:
# A missing token (e.g. an empty PAT on a fork PR, where repo
# secrets are unavailable) is a degraded run, not a hard error:
# route it through the same soft-fail path so this advisory job
# never reddens the PR.
token = os.environ.get("GITHUB_TOKEN")
if not token:
raise TimingsUnavailable("GITHUB_TOKEN is empty")
timings = collect_timings(token, repo, run_id, head_sha)
except TimingsUnavailable as e:
# Observability job: a missing report must never redden the PR.
# Emit a degraded summary + placeholder artifact and exit 0.
msg = f"CI timing data unavailable this run: {e}"
print(msg, file=sys.stderr)
with open(args.summary_out, "a", encoding="utf-8") as f:
f.write(f"\n> ⚠️ {msg}\n")
with open(args.output, "w", encoding="utf-8") as f:
f.write(f"<html><body><p>{escape(msg)}</p></body></html>\n")
# No JSON on purpose: an empty timings file must never be cached
# as the main baseline.
sys.exit(0)
# Save JSON
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(timings, f, indent=2)
print(f"Saved timings to {args.json_out} ({len(timings.get('jobs', []))} jobs)")
# Load baseline
baseline = None
if os.path.exists(args.baseline):
with open(args.baseline, encoding="utf-8") as f:
baseline = json.load(f)
print(f"Loaded baseline from {args.baseline}")
else:
print(f"No baseline file at {args.baseline} — generating current-only report")
if args.review_status_only:
if not args.review_status_out:
parser.error("--review-status-only requires --review-status-out")
report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
statuses = generate_review_status(timings, baseline, report_url, profiles)
with open(args.review_status_out, "w", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(statuses)}\n")
print(f"Wrote review status to {args.review_status_out}")
return
# Generate HTML
html = generate_html(timings, baseline, profiles)
with open(args.output, "w", encoding="utf-8") as f:
f.write(html)
print(f"Generated HTML report: {args.output}")
# Write summary
summary = generate_summary(timings, baseline, profiles)
with open(args.summary_out, "a", encoding="utf-8") as f:
f.write(summary)
print(f"Wrote summary to {args.summary_out}")
# Write review status for the unified PR comment.
# The output goes to GITHUB_OUTPUT (or a file with the same key=value
# format) so the ci-timings job can expose it as a workflow_call output.
if args.review_status_out:
report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
statuses = generate_review_status(timings, baseline, report_url, profiles)
json_str = json.dumps(statuses)
with open(args.review_status_out, "a", encoding="utf-8") as f:
f.write(f"review_status={json_str}\n")
print(f"Wrote review status to {args.review_status_out}")
if __name__ == "__main__":
main()