Add run meta stats to artifacts#191
Conversation
azchin
left a comment
There was a problem hiding this comment.
Thanks, could change the meta.json format to something like the snippet below? There's additional implementation change requests commented.
Also please sign your commits and use Conventional Commits for commit messages.
{
"totals": {
"artifacts": {
"povs": 2,
"seeds": 4,
"patches": 1,
"bug_candidates": 2
},
"llm": {
"credits_used": 1.65
},
"sidecar": {
"patch_builds": 4,
"patch_tests": 2,
"pov_runs": 8
}
},
"crs": {
"crs_a": {
"artifacts": {
"povs": 2,
"seeds": 3,
"patches": 1,
"bug_candidates": 0
},
"llm": {
"credits_used": 1.25
},
"sidecar": {
"patch_builds": 4,
"patch_tests": 2,
"pov_runs": 7
}
}
}
}| continue | ||
| return round(total, 6) | ||
|
|
||
| def _count_build_requests_from_service_logs(self, services_dir: Path) -> int: |
There was a problem hiding this comment.
I also don't like the scrape of the logs for counting build requests. Please modify libCRS itself to log more systematically to a file, similar to my feedback for counting LiteLLM requests.
Also, please count the number of requests for run-pov and apply-patch-test as well. And do it per-CRS.
|
schema update to nested totals + per-CRS (patches, bug_candidates), structured JSONL metrics replacing log scraping, LiteLLM spend polling via /global/spend/report, and artifact counting centralized in WorkDir are all clean and cohesive enough to ship as one PR. CI green, no split needed. |
azchin
left a comment
There was a problem hiding this comment.
Added suggestions to get LiteLLM reporting working. Tracking number of artifacts and libCRS calls look good.
Again, please sign-off your commits https://github.com/ossf/oss-crs/pull/191/checks?check_run_id=75833728185.
The easiest way to do this is probably interactive rebase, and selectively reword your commits to manually add the Signed-off-by: Author Name <authoremail@example.com> for each of your changes.
| depends_on: | ||
| oss-crs-litellm-key-gen: | ||
| condition: service_completed_successfully | ||
| condition: service_healthy |
There was a problem hiding this comment.
Wrong indentation, this breaks the compose.
| condition: service_healthy | |
| condition: service_healthy |
| def get_global_spend_report() -> dict | list | None: | ||
| """Fetch global spend report from LiteLLM.""" | ||
| url = f"{LITELLM_API_URL}/global/spend/report" | ||
| headers = { | ||
| "Authorization": f"Bearer {LITELLM_MASTER_KEY}", | ||
| } | ||
| try: | ||
| response = requests.get(url, headers=headers, timeout=30) | ||
| response.raise_for_status() | ||
| return response.json() | ||
| except requests.exceptions.RequestException as e: | ||
| print(f"Error fetching global spend report: {e}") | ||
| return None | ||
|
|
||
|
|
||
| def _iter_dicts(obj): | ||
| if isinstance(obj, dict): | ||
| yield obj | ||
| for v in obj.values(): | ||
| yield from _iter_dicts(v) | ||
| elif isinstance(obj, list): | ||
| for item in obj: | ||
| yield from _iter_dicts(item) | ||
|
|
||
|
|
||
| def _as_float(value) -> float | None: | ||
| if isinstance(value, (int, float)): | ||
| return float(value) | ||
| if isinstance(value, str): | ||
| try: | ||
| return float(value) | ||
| except ValueError: | ||
| return None | ||
| return None | ||
|
|
||
|
|
||
| def summarize_spend_report(report, key_requests: dict[str, dict]) -> dict: | ||
| """Build a stable summary with totals and per-CRS credits used.""" | ||
| per_key: dict[str, float] = {} | ||
|
|
||
| if report is not None: | ||
| for entry in _iter_dicts(report): | ||
| key = None | ||
| for key_field in ("api_key", "key", "user_api_key", "token"): | ||
| raw_key = entry.get(key_field) | ||
| if isinstance(raw_key, str) and raw_key: | ||
| key = raw_key | ||
| break | ||
| if key is None: | ||
| continue | ||
|
|
||
| spend = None | ||
| for spend_field in ("spend", "total_spend", "credits_used", "cost"): | ||
| spend = _as_float(entry.get(spend_field)) | ||
| if spend is not None: | ||
| break | ||
| if spend is None: | ||
| continue | ||
| per_key[key] = per_key.get(key, 0.0) + spend | ||
|
|
||
| crs_summary: dict[str, dict[str, float]] = {} | ||
| for crs_name, info in key_requests.items(): | ||
| api_key = str(info.get("api_key", "")) | ||
| crs_summary[crs_name] = {"credits_used": round(per_key.get(api_key, 0.0), 6)} | ||
|
|
||
| total = round(sum(v["credits_used"] for v in crs_summary.values()), 6) | ||
| return { | ||
| "totals": {"credits_used": total}, | ||
| "crs": crs_summary, | ||
| "updated_at": int(time.time()), | ||
| } | ||
|
|
||
|
|
||
| def write_spend_summary(summary: dict) -> None: | ||
| dst = SPEND_REPORT_PATH | ||
| tmp = f"{dst}.tmp" | ||
| with open(tmp, "w") as f: | ||
| json.dump(summary, f, indent=2, sort_keys=True) | ||
| f.write("\n") | ||
| os.replace(tmp, dst) |
There was a problem hiding this comment.
write_spend_summary atomic rename doesn't work because of different filesystems.
/global/spend/report uses key hashes, so using /key/info instead because we have the plaintext keys. Old impl was returning 0 for spending.
| def get_global_spend_report() -> dict | list | None: | |
| """Fetch global spend report from LiteLLM.""" | |
| url = f"{LITELLM_API_URL}/global/spend/report" | |
| headers = { | |
| "Authorization": f"Bearer {LITELLM_MASTER_KEY}", | |
| } | |
| try: | |
| response = requests.get(url, headers=headers, timeout=30) | |
| response.raise_for_status() | |
| return response.json() | |
| except requests.exceptions.RequestException as e: | |
| print(f"Error fetching global spend report: {e}") | |
| return None | |
| def _iter_dicts(obj): | |
| if isinstance(obj, dict): | |
| yield obj | |
| for v in obj.values(): | |
| yield from _iter_dicts(v) | |
| elif isinstance(obj, list): | |
| for item in obj: | |
| yield from _iter_dicts(item) | |
| def _as_float(value) -> float | None: | |
| if isinstance(value, (int, float)): | |
| return float(value) | |
| if isinstance(value, str): | |
| try: | |
| return float(value) | |
| except ValueError: | |
| return None | |
| return None | |
| def summarize_spend_report(report, key_requests: dict[str, dict]) -> dict: | |
| """Build a stable summary with totals and per-CRS credits used.""" | |
| per_key: dict[str, float] = {} | |
| if report is not None: | |
| for entry in _iter_dicts(report): | |
| key = None | |
| for key_field in ("api_key", "key", "user_api_key", "token"): | |
| raw_key = entry.get(key_field) | |
| if isinstance(raw_key, str) and raw_key: | |
| key = raw_key | |
| break | |
| if key is None: | |
| continue | |
| spend = None | |
| for spend_field in ("spend", "total_spend", "credits_used", "cost"): | |
| spend = _as_float(entry.get(spend_field)) | |
| if spend is not None: | |
| break | |
| if spend is None: | |
| continue | |
| per_key[key] = per_key.get(key, 0.0) + spend | |
| crs_summary: dict[str, dict[str, float]] = {} | |
| for crs_name, info in key_requests.items(): | |
| api_key = str(info.get("api_key", "")) | |
| crs_summary[crs_name] = {"credits_used": round(per_key.get(api_key, 0.0), 6)} | |
| total = round(sum(v["credits_used"] for v in crs_summary.values()), 6) | |
| return { | |
| "totals": {"credits_used": total}, | |
| "crs": crs_summary, | |
| "updated_at": int(time.time()), | |
| } | |
| def write_spend_summary(summary: dict) -> None: | |
| dst = SPEND_REPORT_PATH | |
| tmp = f"{dst}.tmp" | |
| with open(tmp, "w") as f: | |
| json.dump(summary, f, indent=2, sort_keys=True) | |
| f.write("\n") | |
| os.replace(tmp, dst) | |
| def get_key_spend(api_key: str) -> float: | |
| """Fetch cumulative spend for a single key via /key/info.""" | |
| url = f"{LITELLM_API_URL}/key/info" | |
| headers = { | |
| "Authorization": f"Bearer {LITELLM_MASTER_KEY}", | |
| } | |
| try: | |
| response = requests.get( | |
| url, headers=headers, params={"key": api_key}, timeout=30 | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| info = data.get("info") or data | |
| spend = info.get("spend") | |
| if isinstance(spend, (int, float)): | |
| return float(spend) | |
| return 0.0 | |
| except requests.exceptions.RequestException as e: | |
| print(f"Error fetching spend for key: {e}") | |
| return 0.0 | |
| def collect_spend_summary(key_requests: dict[str, dict]) -> dict: | |
| """Build spend summary by querying per-key spend from LiteLLM.""" | |
| crs_summary: dict[str, dict[str, float]] = {} | |
| total = 0.0 | |
| for crs_name, info in key_requests.items(): | |
| api_key = str(info.get("api_key", "")) | |
| spend = get_key_spend(api_key) if api_key else 0.0 | |
| crs_summary[crs_name] = {"credits_used": round(spend, 6)} | |
| total += spend | |
| return { | |
| "totals": {"credits_used": round(total, 6)}, | |
| "crs": crs_summary, | |
| "updated_at": int(time.time()), | |
| } | |
| def write_spend_summary(summary: dict) -> None: | |
| with open(SPEND_REPORT_PATH, "w") as f: | |
| json.dump(summary, f, indent=2, sort_keys=True) | |
| f.write("\n") |
| # Poll LiteLLM spend report and keep writing a host-recoverable summary file. | ||
| while not _SHUTDOWN: | ||
| report = get_global_spend_report() | ||
| summary = summarize_spend_report(report, key_requests) |
There was a problem hiding this comment.
Update main to use the new spend report function
| # Poll LiteLLM spend report and keep writing a host-recoverable summary file. | |
| while not _SHUTDOWN: | |
| report = get_global_spend_report() | |
| summary = summarize_spend_report(report, key_requests) | |
| # Poll LiteLLM spend and keep writing a host-recoverable summary file. | |
| while not _SHUTDOWN: | |
| summary = collect_spend_summary(key_requests) |
Summary
Added run-level statistics capture and persistence so each run writes a meta.json under its run directory, then exposed that metadata through the artifacts command output.
This was done to satisfy the issue requirement to track and retrieve:
User Impact
CLI/API/config/docs impact and migration:
Release Note / Changelog
Deprecation or breaking behavior:
Validation
Checklist
Closes #80