mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
## Why
Two CI-observability gaps, both about **per-test/per-shard counts** that
the run-level metrics couldn't show:
1. **Flaky vs failed was conflated.** "Failed Shards by Module" counted
shard-*job* failures — but a shard that **failed then passed on rerun is
flaky, not failed**. A bare count told you nothing and misclassified
recovered flakes as failures.
2. **API had no test counts at all.** API `ci_test_runs` rows carried
only run health (conclusion/duration) — `tests_total` was NULL — so the
Test Counts dashboard had nothing for API.
## What
### Per-test flaky **and** failed → `ci_test_results`
- The **merge job** parses the merged `report.json` for tests with
`status="flaky"` (failed→passed on retry) or `status="unexpected"`
(failed after retries) via the new commented
**`.github/scripts/extract-flaky.js`**, emitting **title + module +
status**.
- **report_to_openobserve** posts **one row per such test** to
**`ci_test_results`**.
- Row schema: `{run_id, repo, suite, workflow, ingest_source, status
("flaky"|"failed"), module, test_title, test_file}` — powers accurate
per-module **flaky** *and* **failed** dashboards, no misclassification.
### Per-shard API test counts → `ci_test_counts` (+ run-doc total)
- Each API pytest job emits `--junitxml` and uploads an
**`api-junit__<category>__<leg>`** artifact (category = the test shard:
integration / query_agent / regression / … ENT also cli / vortex_* /
oss_in_ent / ent_top / ent_rbac).
- **report_to_openobserve** runs
**`.github/scripts/parse-api-junit.py`**, which reads the category per
artifact and **dedups config-leg reruns by test identity** (query_agent
opt true|false, regression flag combos) so the same suite isn't counted
twice.
- The deduped **total** folds into the run doc (`ci_test_runs`,
suite=api) so API shows in the same KPI/pie panels as UI; **per-category
rows** go to the new **`ci_test_counts`** stream for the per-shard
breakdown (the API analog of UI's `ci_test_modules`).
## Notes
- **Forward-only** — historical runs never stored junit / per-test names
(only counts), so no backfill; both streams accumulate from merge.
- All **non-fatal**: any parse/ingest error only warns, never fails the
workflow. `_timestamp` unset → ingest time.
- Pairs with the same-named branch on the other repo (OSS ⇄ ENT).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Aggregate API pytest junit XML into PER-CATEGORY + total test-count summaries.
|
|
|
|
The API suite is grouped into test CATEGORIES (set by the upload-artifact name
|
|
`api-junit__<category>__<leg>` in api-testing.yml):
|
|
OSS = api_integration_tests, query_agent_memtable, query_agent_parquet
|
|
ENT = api_integration_tests, query_agent_memtable, query_agent_parquet, cli, vortex
|
|
(query_agent is split by phase — see PHASE_RE below.)
|
|
Several jobs/shards fold into one category (their test sets are DISJOINT, so the
|
|
dedup below makes "within-category dedup" == sum):
|
|
api_integration_tests = the integration default-set + regression (+ ENT's
|
|
oss_in_ent / ent_top / ent_rbac shards)
|
|
vortex = tests/vortex/* + test_3_vortex.py
|
|
Other jobs re-run the SAME tests under several env-flag matrix legs (query_agent
|
|
opt true|false; regression utf8/join combos) — those legs must collapse, not
|
|
multiply, the count. So within each category we DEDUP by test identity
|
|
(classname::name) and pick the worst status across legs:
|
|
|
|
failed if it failed/errored in ANY leg (precedence 2)
|
|
passed if it passed in any leg (never failed) (precedence 1)
|
|
skipped only if skipped in EVERY leg (precedence 0)
|
|
|
|
Category is read from the per-artifact download subdir name, which the workflow
|
|
names `api-junit__<category>__<leg>` (download-artifact WITHOUT merge-multiple,
|
|
so each artifact is its own subdir). category = the part between the `__`s.
|
|
|
|
Emits one JSON object on stdout:
|
|
{"total": {tests_total,tests_passed,tests_failed,tests_flaky,tests_skipped},
|
|
"by_category": [{module, tests_total, ...}, ...]}
|
|
`total` dedups across every category (categories are disjoint test dirs, so this
|
|
also equals the sum, but global dedup guards against accidental overlap).
|
|
flaky is always 0 (pytest doesn't flag reruns without pytest-rerunfailures).
|
|
|
|
Best-effort: unreadable files are skipped; on no input it still prints zeroes.
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
RANK = {"skipped": 0, "passed": 1, "failed": 2}
|
|
|
|
# The query_agent SQL suite runs every query in two phases — memtable and parquet
|
|
# (pytest classes test_1_memtable / test_2_parquet) — and a vortex phase
|
|
# (test_3_vortex). They're genuinely separate tests (different code paths), so
|
|
# rather than one big query_agent bucket we split it BY PHASE from the classname:
|
|
# query_agent_memtable / query_agent_parquet, and route the vortex phase into the
|
|
# `vortex` category (so ENT's main query_agent run, which also executes
|
|
# test_3_vortex, merges with the dedicated vortex job). Tests whose classname
|
|
# doesn't match keep their artifact-derived category untouched.
|
|
PHASE_RE = re.compile(r"test_\d+_(memtable|parquet|vortex)")
|
|
|
|
|
|
def category_of(path: str, root: str) -> str:
|
|
rel = os.path.relpath(path, root)
|
|
top = rel.split(os.sep)[0] # per-artifact subdir name
|
|
parts = top.split("__")
|
|
return parts[1] if len(parts) >= 2 else top
|
|
|
|
|
|
def refine_category(base: str, classname: str) -> str:
|
|
m = PHASE_RE.search(classname or "")
|
|
if not m:
|
|
return base
|
|
phase = m.group(1)
|
|
return "vortex" if phase == "vortex" else f"query_agent_{phase}"
|
|
|
|
|
|
def summarize(status_map: dict) -> dict:
|
|
vals = list(status_map.values())
|
|
return {
|
|
"tests_total": len(vals),
|
|
"tests_passed": vals.count("passed"),
|
|
"tests_failed": vals.count("failed"),
|
|
"tests_flaky": 0,
|
|
"tests_skipped": vals.count("skipped"),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
root = sys.argv[1] if len(sys.argv) > 1 else "junit-xml"
|
|
cats: dict[str, dict] = {}
|
|
for path in glob.glob(f"{root}/**/*.xml", recursive=True):
|
|
base = category_of(path, root)
|
|
try:
|
|
tree = ET.parse(path)
|
|
except Exception as e: # noqa: BLE001 - skip unreadable/partial files
|
|
print(f"parse-api-junit: skipping {path}: {e}", file=sys.stderr)
|
|
continue
|
|
for tc in tree.getroot().iter("testcase"):
|
|
classname = tc.get("classname", "")
|
|
cat = refine_category(base, classname) # phase-split query_agent
|
|
seen = cats.setdefault(cat, {})
|
|
key = (classname, tc.get("name", ""))
|
|
status = "passed"
|
|
for child in tc:
|
|
tag = child.tag.lower()
|
|
if tag == "skipped":
|
|
status = "skipped"
|
|
elif tag in ("failure", "error"):
|
|
status = "failed"
|
|
prev = seen.get(key)
|
|
if prev is None or RANK[status] > RANK[prev]:
|
|
seen[key] = status
|
|
|
|
by_category = [
|
|
{"module": cat, **summarize(m)} for cat, m in sorted(cats.items())
|
|
]
|
|
global_map: dict = {}
|
|
for m in cats.values():
|
|
for key, status in m.items():
|
|
prev = global_map.get(key)
|
|
if prev is None or RANK[status] > RANK[prev]:
|
|
global_map[key] = status
|
|
|
|
print(json.dumps({"total": summarize(global_map), "by_category": by_category}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|