Files
rippled/.github/workflows/reusable-telemetry-validation.yml
Pratik Mankawde 3c05bdfdd8 ci: call the telemetry validation from on-pr.yml
The validation owned its own push trigger and paths filter, which made it the one
build-and-test workflow outside the on-pr.yml plus reusable-*.yml pair the rest of
CI uses. Rename it to reusable-telemetry-validation.yml, take workflow_call, and
let on-pr.yml decide when it runs.

on-pr.yml gains a second changed-files list for the telemetry paths. Its existing
`go` flag is true for nearly every pull request, so gating on `go` alone would run
a self-hosted build and a 30-minute cluster on all of them.

The configure step no longer repeats the telemetry option. A command-line define
is written to the cache before the toolchain runs and wins over it, so it could
turn telemetry on with the package never fetched. build-xrpld reads the value the
build actually configured out of CMakeCache.txt and publishes it as a job output;
validate-telemetry runs only when that says telemetry is on, and every value CMake
does not accept as a boolean fails the job rather than skipping silently.
2026-09-17 16:55:56 +01:00

447 lines
21 KiB
YAML

# Telemetry Validation CI Workflow
#
# Builds rippled, runs the multi-node workload harness, validates all telemetry
# data, and runs performance benchmarks.
#
# The build runs whatever the caller's tree configures, so a telemetry-off tree
# is compiled here too. Only the validation is gated: a build with the tracing
# compiled out emits no spans and no metrics to assert on.
#
# The caller decides only whether to start this at all. on-pr.yml runs it when
# telemetry-relevant paths changed.
#
# The workflow is intentionally heavyweight (builds rippled, starts Docker
# services, runs a multi-node cluster) — it validates the full telemetry
# stack end-to-end rather than individual unit tests.
#
# Architecture: three jobs to leverage cached dependencies:
# 0. linux-image-tag — reads the CI image tag from the build matrix so this
# workflow cannot drift onto a different compiler than the main CI.
# 1. build-xrpld — runs on a self-hosted runner inside the same container
# image the main CI uses. This ensures Conan packages are fetched from
# the XRPLF remote instead of built from source, and ccache hits the
# remote cache. It also reports whether the configured build has telemetry
# compiled in.
# 2. validate-telemetry — runs on ubuntu-latest (which has Docker) to
# launch the telemetry stack (OTel collector, Prometheus, Tempo, etc.)
# and validate the full pipeline end-to-end, against the binary job 1
# uploaded. Skipped when job 1 reports telemetry off.
name: Telemetry Validation
on:
workflow_call:
inputs:
run_benchmark:
description: "Run performance benchmarks."
required: false
type: boolean
default: false
concurrency:
group: telemetry-validation-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
env:
BUILD_DIR: build
jobs:
# ── Job 0: Resolve the CI image tag ────────────────────────────────
# The tag is pinned once, alongside the build matrix, in linux.json. Reading
# it here rather than hardcoding a second copy means this workflow always
# builds in the same image (and therefore the same compiler) as the main CI.
# A hardcoded copy silently went stale and left this job on gcc 13 after the
# rest of CI moved to gcc 15, which broke the build on code the main CI
# compiled fine.
linux-image-tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Read nix image tag
id: tag
run: echo "tag=$(jq -r .image_tag .github/scripts/strategy-matrix/linux.json)" >>"${GITHUB_OUTPUT}"
# ── Job 1: Build xrpld in the same container the main CI uses ──────
# This ensures Conan binary packages are fetched from the XRPLF remote
# (matching package IDs) and ccache hits the remote compilation cache.
build-xrpld:
name: Build xrpld
needs: linux-image-tag
outputs:
telemetry: ${{ steps.telemetry.outputs.enabled }}
runs-on: [self-hosted, Linux, X64, heavy]
container: ghcr.io/xrplf/xrpld/nix-debian:${{ needs.linux-image-tag.outputs.tag }}
timeout-minutes: 60
env:
CCACHE_NAMESPACE: telemetry-validation
CCACHE_REMOTE_ONLY: true
CCACHE_REMOTE_STORAGE: http://cache.dev.ripplex.io:8080|layout=bazel
CCACHE_SLOPPINESS: include_file_ctime,include_file_mtime
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f
with:
enable_ccache: ${{ github.repository_owner == 'XRPLF' }}
- name: Print build environment
uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574
- name: Get number of processors
uses: XRPLF/actions/get-nproc@cf0433aa74563aead044a1e395610c96d65a37cf
id: nproc
with:
subtract: 2
# The nix image ships several toolchains, so CC/CXX must be set
# explicitly for Conan to detect the intended one. gcc matches the
# debian gcc-release config the main CI builds.
- name: Set compiler environment
uses: ./.github/actions/set-compiler-env
with:
compiler: gcc
- name: Setup Conan
uses: ./.github/actions/setup-conan
- name: Build dependencies
uses: ./.github/actions/build-deps
with:
build_nproc: ${{ steps.nproc.outputs.nproc }}
build_type: Release
log_verbosity: verbose
# The telemetry option is deliberately not repeated here. Conan owns it and
# the generated toolchain hands its value to CMake. A command-line define
# is written to the cache before the toolchain runs and wins over it, so it
# could turn telemetry on with the package never fetched.
- name: Configure CMake
working-directory: ${{ env.BUILD_DIR }}
run: |
cmake \
-G Ninja \
-DCMAKE_TOOLCHAIN_FILE:FILEPATH=build/generators/conan_toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release \
..
# A build with tracing compiled out emits nothing to assert on, so the
# validation job below gates on this. Read from the configured build, so
# this file holds no copy of the setting.
#
# Every value CMake does not accept as a boolean fails the job, including a
# missing entry. A gate that guessed would skip the validation silently,
# which is the one failure it must not have.
- name: Read whether telemetry is compiled in
id: telemetry
working-directory: ${{ env.BUILD_DIR }}
run: |
value=$(sed -n 's/^telemetry:[^=]*=//p' CMakeCache.txt)
case "${value}" in
True | TRUE | true | ON | on | YES | yes | Y | y | 1) enabled=true ;;
False | FALSE | false | OFF | off | NO | no | N | n | 0) enabled=false ;;
*)
echo "telemetry reads '${value}' in CMakeCache.txt, which is not" >&2
echo "a boolean. conan install sets it through the toolchain." >&2
exit 1
;;
esac
echo "telemetry compiled in: ${enabled}"
echo "enabled=${enabled}" >>"${GITHUB_OUTPUT}"
- name: Build xrpld
working-directory: ${{ env.BUILD_DIR }}
env:
BUILD_NPROC: ${{ steps.nproc.outputs.nproc }}
run: |
cmake \
--build . \
--config Release \
--parallel "${BUILD_NPROC}" \
--target xrpld
- name: Show ccache statistics
if: ${{ github.repository_owner == 'XRPLF' }}
run: ccache --show-stats -vv
- name: Upload xrpld binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: xrpld-telemetry
path: ${{ env.BUILD_DIR }}/xrpld
retention-days: 1
if-no-files-found: error
# ── Job 2: Run telemetry validation on ubuntu-latest (has Docker) ──
validate-telemetry:
name: Telemetry Stack Validation
needs: build-xrpld
# Reuses the binary job 1 built and uploaded, so a skip here costs no build.
if: needs.build-xrpld.outputs.telemetry == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Python dependencies
run: pip3 install -r docker/telemetry/workload/requirements.txt
- name: Download xrpld binary
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: xrpld-telemetry
path: ${{ env.BUILD_DIR }}
- name: Make binaries and scripts executable
run: |
chmod +x ${{ env.BUILD_DIR }}/xrpld
chmod +x docker/telemetry/workload/*.sh
- name: Run full telemetry validation
id: validation
env:
RPC_RATE: "50"
RPC_DURATION: "120"
TX_TPS: "5"
TX_DURATION: "120"
RUN_BENCHMARK: ${{ inputs.run_benchmark }}
run: |
# The four rate/duration flags below are inert:
# run-full-validation.sh parses them and never reads them. Load shape
# comes from the default --profile full-validation. They are still
# passed so the flags stay exercised if they are ever wired up.
ARGS="--xrpld ${{ env.BUILD_DIR }}/xrpld"
ARGS="$ARGS --rpc-rate $RPC_RATE"
ARGS="$ARGS --rpc-duration $RPC_DURATION"
ARGS="$ARGS --tx-tps $TX_TPS"
ARGS="$ARGS --tx-duration $TX_DURATION"
if [ "$RUN_BENCHMARK" = "true" ]; then
ARGS="$ARGS --with-benchmark"
fi
docker/telemetry/workload/run-full-validation.sh $ARGS
# continue-on-error allows subsequent steps (artifact upload,
# summary printing) to run even if validation fails. The final
# "Check validation result" step re-checks steps.validation.outcome
# (the pre-continue-on-error result) and fails the job properly.
continue-on-error: true
- name: Upload validation reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: telemetry-validation-reports
path: /tmp/xrpld-validation/reports/
retention-days: 30
# Keyed on the validation step's own outcome, not job status. The step
# above sets continue-on-error, so the job is not failing at this point
# and `if: failure()` never fires -- which silently skipped these logs on
# every failed run, and they are the only record of why a node did not
# reach consensus.
#
# stdout.log matters as much as debug.log: a node that dies before its
# log sink opens writes no debug.log at all, so stdout is the only place
# its reason survives. A run that timed out at 4/5 nodes was left
# undiagnosable because that file was not collected.
- name: Upload node logs
if: always() && steps.validation.outcome != 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: xrpld-node-logs
path: |
/tmp/xrpld-validation/node*/debug.log
/tmp/xrpld-validation/node*/stdout.log
/tmp/xrpld-validation/*.log
retention-days: 7
if-no-files-found: warn
- name: Print validation summary
if: always()
run: |
REPORT="/tmp/xrpld-validation/reports/validation-report.json"
if [ -f "$REPORT" ]; then
echo "## Telemetry Validation Results" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
TOTAL=$(jq '.summary.total' "$REPORT")
PASSED=$(jq '.summary.passed' "$REPORT")
FAILED=$(jq '.summary.failed' "$REPORT")
echo "| Metric | Value |" >>"$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >>"$GITHUB_STEP_SUMMARY"
echo "| Total Checks | $TOTAL |" >>"$GITHUB_STEP_SUMMARY"
echo "| Passed | $PASSED |" >>"$GITHUB_STEP_SUMMARY"
echo "| Failed | $FAILED |" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
if [ "$FAILED" -gt 0 ]; then
echo "### Failed Checks" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
jq -r '.checks[] | select(.passed == false) | "- **\(.name)**: \(.message)"' "$REPORT" >>"$GITHUB_STEP_SUMMARY"
fi
fi
# Publishes captured OTel timings + regression report to the Step Summary.
# When the committed baseline is a placeholder AND the capture is
# complete, emits a fenced JSON block that can be copy-pasted directly
# into baselines/baseline-timings.json. An incomplete capture is named as
# such and its JSON withheld — see the comment on that branch below.
# When the baseline is populated, summarises the top regressions so the
# PR author sees the failure reason without downloading artifacts.
- name: Print regression summary
if: always()
run: |
set -euo pipefail
TIMINGS="/tmp/xrpld-validation/reports/timings.json"
REGRESSION="/tmp/xrpld-validation/reports/regression-report.json"
BASELINE="docker/telemetry/workload/baselines/baseline-timings.json"
if [ ! -f "$TIMINGS" ]; then
echo "## Regression Gate: no timings captured" >>"$GITHUB_STEP_SUMMARY"
echo "::warning::capture_timings.py did not produce timings.json — regression gate was not evaluated."
exit 0
fi
if [ ! -f "$BASELINE" ]; then
echo "## Regression Gate: baseline file missing" >>"$GITHUB_STEP_SUMMARY"
echo "::error::baselines/baseline-timings.json not found in checkout"
exit 1
fi
# NOTE: do NOT use `jq -e` here. With -e, jq exits non-zero when the
# filter's result is boolean false — which is the normal case for a
# populated (non-placeholder) baseline — and that would be
# misreported as a parse failure. Plain `jq -r` exits 0 on any valid
# JSON, so a real non-zero exit genuinely means malformed JSON.
IS_PLACEHOLDER=$(jq -r '.placeholder == true or (.metrics | length == 0)' "$BASELINE") || {
echo "::error::Failed to parse baseline JSON"
exit 1
}
# Whether the capture is usable as baseline material is the capture's
# own verdict, carried in the artifact by capture_timings.py, which
# computes it against --min-capture-ratio. It is READ here, never
# re-derived: a second copy of the ratio rule in shell would be a
# second source of truth and would drift from the producer.
#
# `// false` covers both an artifact written before the block existed
# and a truncated one. Neither can prove it is complete, so neither is
# offered — the whole point is that a degraded capture must not look
# like a good one. Same `jq -r` reasoning as the baseline parse above.
CAPTURE_COMPLETE=$(jq -r '.capture.complete // false' "$TIMINGS") || {
echo "::error::Failed to parse timings JSON"
exit 1
}
if [ "$(jq -r 'has("capture")' "$TIMINGS")" = "true" ]; then
CAPTURE_COUNTS=$(jq -r '"\(.capture.captured)/\(.capture.declared)"' "$TIMINGS")
CAPTURE_SHORTFALL="only **$CAPTURE_COUNTS** declared metrics came back, below the capture's own minimum ratio"
else
CAPTURE_COUNTS="unknown"
CAPTURE_SHORTFALL="the artifact carries no \`capture\` block, so it predates completeness reporting and cannot state what it captured"
fi
echo "## OTel Timings Regression Gate" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
if [ "$IS_PLACEHOLDER" = "true" ] && [ "$CAPTURE_COMPLETE" != "true" ]; then
# The placeholder path is the ONLY route to a committed baseline,
# which makes it the one place an incomplete capture does lasting
# damage: pasted in, it silently narrows the gate to the keys that
# happened to come back. So the JSON is withheld rather than
# printed with a caveat — a warning above a copyable block is
# still a copyable block. The counts are shown so the reader knows
# how thin it was, and the artifact is still uploaded for anyone
# who needs to inspect it deliberately.
echo "### Baseline NOT refreshable from this run" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
echo "The committed baseline is a placeholder, so this run would" \
"normally print a block to paste into" \
"\`baselines/baseline-timings.json\`. It is withheld because" \
"$CAPTURE_SHORTFALL, so the JSON may describe metrics that" \
"were never measured. Pasting it would narrow the gate to" \
"whichever keys were captured, with nothing reporting that" \
"it had narrowed." >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
echo "Fix the capture first — the usual cause is Prometheus not" \
"being scraped for long enough, or nodes not reaching" \
"consensus — then re-run. See the \`timings.json\` artifact's" \
"\`capture\` block for the exact counts." >>"$GITHUB_STEP_SUMMARY"
echo "::error::Timing capture is incomplete ($CAPTURE_COUNTS metrics) — no baseline block printed. Do not refresh the baseline from this run."
elif [ "$IS_PLACEHOLDER" = "true" ]; then
echo "### Paste into \`baselines/baseline-timings.json\`" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
echo "The committed baseline is a placeholder. Open a PR replacing" \
"its contents with the JSON block below to activate the" \
"regression gate. The capture is complete" \
"($CAPTURE_COUNTS declared metrics)." >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
echo '```json' >>"$GITHUB_STEP_SUMMARY"
cat "$TIMINGS" >>"$GITHUB_STEP_SUMMARY"
echo '```' >>"$GITHUB_STEP_SUMMARY"
elif [ -f "$REGRESSION" ]; then
# Existence is not readability: a truncated report satisfies -f,
# and the `|| =0` fallbacks below would then render a clean table
# of zeros for a run that compared nothing. Same trap the note
# above the baseline parse warns about, so check the shape first.
SUMMARY_OK=$(jq -r 'if (.summary | type) == "object" then "yes" else "no" end' \
"$REGRESSION" 2>/dev/null) || SUMMARY_OK=no
if [ "$SUMMARY_OK" != "yes" ]; then
echo "## Regression Gate: report unreadable" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
echo "\`$REGRESSION\` exists but carries no \`summary\` object, so no" \
"table can be rendered. The pass/fail above still comes from the" \
"comparator's exit code." >>"$GITHUB_STEP_SUMMARY"
echo "::error::Regression report is present but has no summary object"
else
# No `jq -e`: it exits non-zero when a field is legitimately 0 or
# false, which the fallbacks would silently turn into 0 as well.
REGR_COUNT=$(jq -r '.summary.regressions // 0' "$REGRESSION")
IMPR_COUNT=$(jq -r '.summary.improvements // 0' "$REGRESSION")
TOTAL=$(jq -r '.summary.total // 0' "$REGRESSION")
MISSING_COUNT=$(jq -r '.summary.missing_in_current // 0' "$REGRESSION")
COMPARED=$(jq -r '.summary.compared // 0' "$REGRESSION")
# `total` is every key in the report, i.e. the union of the
# baseline and this run, so it is not what was gated. The
# comparator reports `compared` for that; do not derive it from
# total minus missing, because a key can also be skipped for
# being new or for having no data on either side.
echo "| Stat | Count |" >>"$GITHUB_STEP_SUMMARY"
echo "|------|-------|" >>"$GITHUB_STEP_SUMMARY"
echo "| Metrics in report | $TOTAL |" >>"$GITHUB_STEP_SUMMARY"
echo "| Metrics compared | $COMPARED |" >>"$GITHUB_STEP_SUMMARY"
echo "| Not captured this run | $MISSING_COUNT |" >>"$GITHUB_STEP_SUMMARY"
echo "| Regressions | $REGR_COUNT |" >>"$GITHUB_STEP_SUMMARY"
echo "| Improvements | $IMPR_COUNT |" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
if [ "$MISSING_COUNT" -gt 0 ]; then
echo "::warning::$MISSING_COUNT baseline metric(s) were not captured this run, so they were not gated"
fi
if [ "$REGR_COUNT" -gt 0 ]; then
echo "### Regressions" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
echo "| Metric | Baseline | Current | Δ | % | Unit |" >>"$GITHUB_STEP_SUMMARY"
echo "|--------|---------:|--------:|--:|--:|------|" >>"$GITHUB_STEP_SUMMARY"
jq -r '.metrics[] | select(.regressed) | "| \(.key) | \(.baseline) | \(.current) | \(.delta) | \(.pct_change)% | \(.unit) |"' \
"$REGRESSION" >>"$GITHUB_STEP_SUMMARY"
fi
fi
fi
- name: Cleanup
if: always()
run: |
docker/telemetry/workload/run-full-validation.sh --cleanup 2>/dev/null || true
- name: Check validation result
if: steps.validation.outcome == 'failure'
run: |
echo "Telemetry validation failed. Check the uploaded reports for details."
exit 1