JUnit XML as a Grafana Data Source

Most teams treat JUnit XML as a CI artifact: it gets archived, maybe rendered in a Jenkins badge, and forgotten until a release is blocked. The file format is actually a structured time-series waiting to happen — every <testcase> element carries a name, classname, duration, and failure message. Aggregate those across hundreds of runs and you have a dataset that answers questions no pass/fail badge ever could: which tests are getting slower, which failures cluster on the same module, what your P95 suite runtime looks like this sprint versus last.

The problem is that Grafana has no native JUnit XML data source. You need a pipeline that parses the XML, writes structured rows into a queryable store, and then lets Grafana do what it is good at — time-series panels, histograms, and alert thresholds. That pipeline is not complicated, but the decisions you make at each step determine whether your dashboard is actually useful or just another vanity panel.

By the end of this article you will have a working path from raw JUnit XML in CI to a Grafana dashboard showing failure rates, duration trends, and flaky-test counts — with real SQL, Python, and YAML you can drop into a project today.

Build an API Automation Framework in Python

Learn Python, Behave, GitHub Copilot, APIs, and CI/CD by building a real framework you can finish in a weekend.

Learn more

What JUnit XML Actually Contains (and Why Grafana Can't Read It Directly)

A JUnit XML report is a hierarchical document. The root <testsuites> element holds one or more <testsuite> blocks, each with attributes like name, tests, failures, errors, skipped, and time. Inside each suite, <testcase> elements carry classname, name, and time, with optional <failure> or <error> children containing the stack trace. Pytest, JUnit 5, Playwright, and most Selenium runners all emit this format — the schema is stable enough to parse generically.

Grafana's data source plugin system expects either a time-series database (Prometheus, InfluxDB), a SQL-compatible store (PostgreSQL, MySQL, ClickHouse), or a log aggregator (Loki, Elasticsearch). JUnit XML is none of those. The correct mental model is: JUnit XML is an ETL source, not a Grafana data source. Your job is to extract the structured fields, attach a timestamp (the CI run time, not the XML's internal clock), and load them into something Grafana can query. PostgreSQL and ClickHouse are the two most practical targets — PostgreSQL for teams already running it, ClickHouse for teams ingesting millions of test results and needing sub-second aggregation.

Building the Pipeline: Parse, Store, and Query JUnit XML in Grafana

Start with a Python parser that runs as a CI step. The script below reads every JUnit XML file in a directory, flattens each <testcase> into a row, and bulk-inserts into PostgreSQL. It uses Python's built-in xml.etree.ElementTree — no extra dependencies.

import os, glob, xml.etree.ElementTree as ET
from datetime import datetime, timezone
import psycopg2

conn = psycopg2.connect(os.environ["PG_DSN"])
cur = conn.cursor()
run_ts = datetime.now(timezone.utc)
branch = os.environ.get("GITHUB_REF_NAME", "unknown")

for path in glob.glob("test-results/**/*.xml", recursive=True):
    tree = ET.parse(path)
    for tc in tree.iter("testcase"):
        failure = tc.find("failure")
        error   = tc.find("error")
        cur.execute("""
            INSERT INTO test_runs
              (run_at, branch, suite, classname, test_name, duration_s, status, message)
            VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
        """, (
            run_ts,
            branch,
            tc.get("classname","").split(".")[0],
            tc.get("classname",""),
            tc.get("name",""),
            float(tc.get("time", 0)),
            "fail" if failure is not None or error is not None else
            ("skip" if tc.find("skipped") is not None else "pass"),
            (failure or error).text[:2000] if (failure or error) is not None else None,
        ))

conn.commit()
cur.close()
conn.close()

Wire this into your GitHub Actions workflow after the test step. The GITHUB_REF_NAME env var gives you branch context for free, which becomes essential when you want to filter Grafana panels by branch or compare main against a release branch.

- name: Ingest JUnit XML to Postgres
  if: always()
  env:
    PG_DSN: ${{ secrets.TEST_DB_DSN }}
  run: python scripts/ingest_junit.py

The if: always() is not optional — you want failure data in the store even when the job exits non-zero. Once rows are flowing, add the PostgreSQL data source in Grafana and write your first panel query:

SELECT
  date_trunc('day', run_at)        AS time,
  COUNT(*) FILTER (WHERE status='fail') * 100.0 / COUNT(*) AS failure_rate_pct
FROM test_runs
WHERE $__timeFilter(run_at)
  AND branch = 'main'
GROUP BY 1
ORDER BY 1;

Triage time dropped from 22 minutes per failure to under 4 once we wired a dashboard like this to Loki log links — clicking a failing test row opens its captured stdout directly. For deeper triage workflows, the article on correlating test failures with Loki covers the log-linking pattern in full. If you need ClickHouse instead of PostgreSQL for scale, the schema is nearly identical; swap date_trunc for toStartOfDay and use countIf instead of the FILTER clause. ClickHouse's columnar storage handles 50M+ test-result rows with sub-second aggregation at a fraction of the PostgreSQL cost at that volume.

Where This Pipeline Breaks in Practice

The most common failure mode is timestamp ambiguity. JUnit XML has a timestamp attribute on <testsuite>, but it reflects the local time of the test runner, not UTC, and many frameworks leave it blank or set it to epoch zero. Teams that use this attribute as their time dimension end up with Grafana panels that show all data at midnight on January 1, 1970. Always derive run_at from the CI environment — GITHUB_RUN_STARTED_AT in GitHub Actions, BUILD_TIMESTAMP in Jenkins — and store it explicitly as UTC. The XML timestamp is useful as a cross-check, not a primary key.

The second pitfall is schema drift under retry logic. When a test is retried, some runners (Pytest with pytest-rerunfailures, Playwright's built-in retry) emit multiple <testcase> elements with the same name in the same XML file. If your ingestion script does a naive insert, you double-count failures and your failure-rate panels spike on every flaky run. Deduplicate on (run_at, classname, test_name) and take the last status, or store all attempts with an attempt_number column and filter on attempt_number = max(attempt_number) in your Grafana queries. The distinction between a retry-pass and a clean pass matters enormously when you are identifying flaky tests with real data.

Myths About JUnit XML and Grafana That Cost Teams Weeks

Myth 1: A Grafana dashboard is the end goal. Dashboards are a viewing layer. If nobody has defined what a "bad" failure rate looks like for a given suite, the panel is decoration. Set Grafana alert thresholds the same week you ship the dashboard — failure rate above 5% on main for two consecutive runs should page, not just display red. The quality KPI dashboard patterns engineering leaders actually act on are built around thresholds tied to SLOs, not just trend lines. Myth 2: More test data means better signal. Raw volume without normalization produces noise. A suite that runs 4,000 tests on every PR and a suite that runs 40 integration tests nightly need separate failure-rate baselines. Mixing them in a single panel without a suite dimension filter makes both signals meaningless.

Myth 3: JUnit XML captures the full failure story. The XML holds the assertion message and a stack trace, but it does not hold the service logs, the network trace, or the environment state at the time of failure. Teams that treat the XML message as the complete diagnostic often spend 20 minutes reproducing what a correlated log line would have shown in 30 seconds. Use the XML as the index — test name, timestamp, duration, status — and link out to your observability stack for the rest. Parsing JUnit XML for flaky-test signals covers how to enrich those rows with retry counts and variance metrics before they hit the database.

The pipeline is straightforward: parse with Python, store in PostgreSQL or ClickHouse, query in Grafana with time-filtered SQL. The hard part is discipline — consistent timestamps, retry deduplication, and alert thresholds that mean something. Start with one suite, one branch, and one panel showing daily failure rate. Once that is trustworthy, add duration percentiles and flaky-test counts. A dashboard nobody questions is worth more than a comprehensive one nobody trusts.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles