Files
rippled/.github/workflows/telemetry-validation.yml
Pratik Mankawde ed92501730 style(telemetry): cut the comments I over-wrote back to the guideline
The comments I added with the hierarchy sampling fix and the trigger change ran
to sixteen and twelve lines. The guideline is short and plain English. Rationale,
CI run numbers and the list of which relationships were affected belong in the
commit message, which is where they already are; inline they push the code apart
and go stale as soon as the reasons change.

Trimmed the sampling comment from sixteen lines to four, the re-check comment
from eight to four, _traceql_name_predicate's docstring from fourteen lines of
explanation to three, and the push-trigger comment from twelve to seven. Each
keeps what a reader needs at that line -- what the code does and the one
non-obvious reason -- and drops the history.

Comment-only: 13 insertions against 35 deletions, no statement changed.

Left alone deliberately: this file has ten pre-existing comment blocks longer
than six lines, including one added recently by another party. Rewriting someone
else's comments is not mine to do here, and the guideline is being applied to what
I wrote.

Verification: 7/7 validator tests pass; validate_telemetry.py compiles; the
workflow YAML parses, still carries no branches filter, and still lists 12 paths;
otel-naming exits 0.
2026-08-27 12:46:17 +01:00

452 lines
21 KiB
YAML

# Telemetry Validation CI Workflow
#
# Builds rippled with telemetry enabled, runs the multi-node workload
# harness, validates all telemetry data, and runs performance benchmarks.
#
# This is a separate workflow from the main CI. It runs:
# - On manual dispatch (workflow_dispatch)
# - On pushes to telemetry-related branches
#
# 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.
# 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.
name: Telemetry Validation
on:
workflow_dispatch:
# NOTE: rpc_rate / rpc_duration / tx_tps / tx_duration have NO effect.
# They are forwarded to run-full-validation.sh, which parses them into
# shell variables and never reads them again — load shape comes entirely
# from --profile and docker/telemetry/workload/workload-profiles.json.
# They are kept (and labelled) rather than removed so existing dispatch
# bookmarks and any saved input sets do not break. To change the load,
# edit or add a profile in workload-profiles.json.
inputs:
rpc_rate:
description: "UNUSED — has no effect. Load shape comes from the workload profile."
required: false
default: "50"
rpc_duration:
description: "UNUSED — has no effect. Load shape comes from the workload profile."
required: false
default: "120"
tx_tps:
description: "UNUSED — has no effect. Load shape comes from the workload profile."
required: false
default: "5"
tx_duration:
description: "UNUSED — has no effect. Load shape comes from the workload profile."
required: false
default: "120"
run_benchmark:
description: "Run performance benchmarks (the only input that changes behaviour)"
required: false
type: boolean
default: false
push:
# No branches filter, deliberately: GitHub ANDs branches with paths, so a
# branch glob decides validation by what a branch is CALLED rather than by
# what it CHANGED, and a non-matching name gets no run at all. The paths
# below already ask the real question.
#
# Keep these globs pointing at paths that exist -- two earlier entries
# matched zero tracked files, so C++ telemetry changes never triggered.
paths:
# This workflow, and the harness it runs.
- ".github/workflows/telemetry-validation.yml"
- "docker/telemetry/**"
# The telemetry modules themselves.
- "include/xrpl/telemetry/**"
- "src/libxrpl/telemetry/**"
- "src/xrpld/telemetry/**"
# beast::insight, whose gauges and counters this workflow asserts on.
# Both halves: the interface headers decide what the OTel collector can
# publish, so editing one moves the metric surface as surely as editing
# the implementation does.
- "include/xrpl/beast/insight/**"
- "src/libxrpl/beast/insight/**"
# The span-name and metric-name constants. These are the wire contract the
# harness asserts against by literal string, so a rename here compiles
# cleanly and silently empties the assertions. Matched by filename rather
# than directory because the convention colocates each header with the
# class it serves: they live under consensus/, overlay/, app/ledger/,
# app/main/, app/misc/, rpc/ and tx/, not under a telemetry/ directory.
- "**/*SpanNames.h"
- "**/*MetricNames.h"
# The GTests that pin those same constants.
- "src/tests/libxrpl/telemetry/**"
# The checkers that gate this surface. otel-naming derives the legal
# attribute and metric names from the headers above; the telemetry scripts
# enforce the histogram-bucket parity and regression-bound rules the
# harness depends on.
- ".github/scripts/otel-naming/**"
- ".github/scripts/telemetry/**"
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
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
# telemetry is passed explicitly even though the CMake option and the
# Conan recipe both default it on. The whole point of this workflow is to
# exercise telemetry, so it should not silently depend on a default it
# does not control: if that default ever flips, every span and metric
# assertion would fail for a reason no log names. Stated here, a build
# without the dependency fails loudly instead, because CMakeLists.txt
# does find_package(opentelemetry-cpp CONFIG REQUIRED) under this option.
- 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 \
-Dtelemetry=ON \
..
- 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
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: ${{ github.event.inputs.rpc_rate || '50' }}
RPC_DURATION: ${{ github.event.inputs.rpc_duration || '120' }}
TX_TPS: ${{ github.event.inputs.tx_tps || '5' }}
TX_DURATION: ${{ github.event.inputs.tx_duration || '120' }}
RUN_BENCHMARK: ${{ github.event.inputs.run_benchmark }}
run: |
# The four rate/duration flags below are inert (see the
# workflow_dispatch inputs note): 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
REGR_COUNT=$(jq -e '.summary.regressions' "$REGRESSION") || REGR_COUNT=0
IMPR_COUNT=$(jq -e '.summary.improvements' "$REGRESSION") || IMPR_COUNT=0
TOTAL=$(jq -e '.summary.total' "$REGRESSION") || TOTAL=0
echo "| Stat | Count |" >>"$GITHUB_STEP_SUMMARY"
echo "|------|-------|" >>"$GITHUB_STEP_SUMMARY"
echo "| Metrics compared | $TOTAL |" >>"$GITHUB_STEP_SUMMARY"
echo "| Regressions | $REGR_COUNT |" >>"$GITHUB_STEP_SUMMARY"
echo "| Improvements | $IMPR_COUNT |" >>"$GITHUB_STEP_SUMMARY"
echo "" >>"$GITHUB_STEP_SUMMARY"
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
- 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