feat: add OTel observability benchmark demo - #13
Conversation
|
Unsigned commits: 74990af. Please sign your commits. |
|
PR too large: 1500 lines added (limit: 750, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. Add |
74990af to
476fc27
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
4 findings (1 Large, 3 Medium)
Overall: well-structured demo with good reproducibility design -- pinned chart versions, configurable benchmark parameters, multiple runs with warmup. The OTel collector pipeline, Tempo integration, and benchmark methodology are sound. The three-way comparison (baseline / noop / full) is the right approach for isolating overhead. A few issues to address.
76b9126 to
6988b33
Compare
6988b33 to
b232cf7
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review (re-review)
Summary: Previous findings addressed -- ARG FEATURES replaces the fragile sed approach, vegeta prerequisite check added, report table formatting fixed. One new issue found.
| Severity | Count |
|---|---|
| Critical | 0 |
| Large | 0 |
| Medium | 1 |
cf132ab to
746594d
Compare
746594d to
ee5e4f8
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review (re-review)
Summary: All five previous findings addressed -- Containerfile.ai-otel removed in favor of unified ARG FEATURES build-arg, vegeta prerequisite check added, report table now uses Python (no trailing ||), REPORT_SCRIPT fixed. Two new issues.
| Severity | Count |
|---|---|
| Critical | 0 |
| Large | 0 |
| Medium | 2 |
34ce23d to
af05200
Compare
Full observability stack on KIND for benchmarking Praxis experimental server OTel tracing overhead across two scenarios: - AI gateway (500 RPS, 22 spans/request, POST /v1/chat/completions) - Core proxy (2000 RPS, 10 spans/request, GET to echo backend) AI scenario uses extended filter chain: request_id, access_log, model_to_header, token_usage_headers, token_count, time_to_first_token, intelligent_route, and load_balancer — demonstrating the full AI gateway pipeline in the trace waterfall. Each scenario runs baseline/otel-noop/otel-full configurations with vegeta load testing via a single parameterized benchmark.sh script. Stack: Prometheus, Grafana 11.x, Tempo, Loki, OTel Collector, MLflow (file-backed SQLite), Fortio echo, llm-d inference-sim, plus 5 Grafana dashboards. Also adds: - otel feature to praxis-experimental-server (praxis-core/otel + praxis-filter/otel + praxis-ai-proxy/opentelemetry) - FEATURES build-arg in Containerfile - Patches praxis crates to main rev 1b439271 for Tokio runtime fix and filter/otel feature not yet in v0.5.3 Signed-off-by: Ladislav Smola <lsmola@redhat.com>
af05200 to
4d7935f
Compare
Follow-ups to praxis-proxy#13, found by a review pass after it merged. Each one is a defect with an observable consequence; nothing here changes what the benchmark measures. Broken or dead: - The `ai-extended` scenario referenced two config files that do not exist, so selecting it aborted on the first `kubectl create` under `set -euo pipefail`, and report.sh had no branch for its result prefix. - The per-run `kubectl top pod` snapshot always failed into `|| true`, because no stack installs metrics-server and KIND does not ship it. It wrote nine empty `*-resources.txt` files per 3x3 run; the dashboards already plot CPU and memory from cAdvisor. - `export BRANCH=$(...)` in report.sh masked the command's exit status (SC2155), which shellcheck never saw because the lint target only covered `hack/` and `.hooks/`. - Two comments described a `[patch.crates-io]` table that the 0.5.4 bump had already removed. Observability of the trace pipeline: - A `memory_limiter` now runs first in the collector pipeline. The container is capped at 512Mi and nothing shed load before it, so under exporter backpressure the queue grew until the kernel killed the collector and every buffered span went with it. Refusals land in `otelcol_processor_refused_spans`, charted beside the export failures. - The Span Export Failures panel filters to `exporter="otlp/tempo"`. The pipeline also has a `debug` exporter, so the unfiltered sum counted every span twice and a debug-exporter hiccup read as a Tempo failure. Supply chain and build: - praxis-ai is pinned to the commit tagged v0.3.0 rather than to the tag, which is what deny.toml's comment already claimed. A tag can be force-moved upstream; a commit cannot. - `make container FEATURES=otel` plumbs the build-arg through, so the repo's own tooling can build the image the demo READMEs ask for instead of the hand-rolled `docker build` lines they carry today. - A cargo git cache mount alongside the registry one: the ai dependency is a git source and was re-cloned on every image build. Signed-off-by: Ladislav Smola <lsmola@redhat.com>
Follow-ups to praxis-proxy#13, found by a review pass after it merged. Each one is a defect with an observable consequence; nothing here changes what the benchmark measures. Broken or dead: - The `ai-extended` scenario referenced two config files that do not exist, so selecting it aborted on the first `kubectl create` under `set -euo pipefail`, and report.sh had no branch for its result prefix. - The per-run `kubectl top pod` snapshot always failed into `|| true`, because no stack installs metrics-server and KIND does not ship it. It wrote nine empty `*-resources.txt` files per 3x3 run; the dashboards already plot CPU and memory from cAdvisor. - `export BRANCH=$(...)` in report.sh masked the command's exit status (SC2155), which shellcheck never saw because the lint target only covered `hack/` and `.hooks/`. - Two comments described a `[patch.crates-io]` table that the 0.5.4 bump had already removed. - The prerequisites told you to install forge from a feature branch. praxis-proxy/forge#16 has merged, so `extraPortMappings` is in main. Observability of the trace pipeline: - A `memory_limiter` now runs first in the collector pipeline. The container is capped at 512Mi and nothing shed load before it, so under exporter backpressure the queue grew until the kernel killed the collector and every buffered span went with it. Refusals land in `otelcol_processor_refused_spans`, charted beside the export failures. - The Span Export Failures panel filters to `exporter="otlp/tempo"`. The pipeline also has a `debug` exporter, so the unfiltered sum counted every span twice and a debug-exporter hiccup read as a Tempo failure. Supply chain and build: - praxis-ai is pinned to the commit tagged v0.3.0 rather than to the tag, which is what deny.toml's comment already claimed. A tag can be force-moved upstream; a commit cannot. - `make container FEATURES=otel` plumbs the build-arg through, so the repo's own tooling can build the image the demo READMEs ask for instead of the hand-rolled `docker build` lines they carry today. - A cargo git cache mount alongside the registry one: the ai dependency is a git source and was re-cloned on every image build. Signed-off-by: Ladislav Smola <lsmola@redhat.com>
Summary
A KIND-based observability stack for measuring what OTel tracing costs the
Praxis experimental AI gateway, plus the
otelcargo feature onpraxis-experimental-serverand aFEATURESbuild-arg so the image can bebuilt with or without it.
Two scenarios, each running three configurations against the same filter
chain so only tracing varies:
otelotel0.1POST /v1/chat/completionsagainst llm-dinference-sim, 8 filters, 23 spans per request
GET /against Fortio echoFive Grafana dashboards, Tempo traces, Loki logs, Prometheus metrics.
Results
Clean run: fresh cluster, 8 vCPUs, nothing else on the machine.
P50 overhead is +0.1% to +0.6%, which is at or below what this setup can
resolve. Per-run P50 spread within a config is 27-47us, comparable to the
between-config difference. The honest reading is "no measurable median
overhead", not a specific figure. Export is batched and off the request path;
what remains on the hot path is span creation.
P99 is not resolvable at three runs. With one outlier the median becomes
the larger of the two clean values, which is exactly how OTel (full) reports
1764us and a +20.5% delta. The
P99 rangecolumn exists so this is visiblerather than hidden. P50 is trustworthy; the tail number is not.
Tail-latency signal worth following up. Extreme outliers (90ms and 208ms
max) appeared in both
otel-compiled builds across runs, but never in thebaseline (max 13ms). It shows up with no endpoint configured, so it is not the
export path -- something in the otel build itself. Two events across three
runs, so this is a flag for investigation, not a finding.
Methodology, and two bugs the results exposed
Runs are interleaved (
A B C, A B C, ...), not grouped. Grouped orderingaliases drift over time onto the config comparison and whichever config runs
first absorbs it: in an earlier grouped run the baseline's three measurements
fell monotonically 815 -> 775 -> 735us purely from running first, which biased
it slow and made OTel look free -- at one point reporting OTel as faster
than baseline. Every config now sees the same distribution of machine states,
each with its own rollout and warmup.
The report medians rather than means. A single cold run had previously
dragged the mean P99 to a reported +546% regression that did not exist.
98.7% of spans were being silently dropped. Tempo's OTLP gRPC receiver
inherits grpc-go's 4 MiB default message size. The collector's 8192-span
batches exceeded it, Tempo answered
ResourceExhausted, and because that codeis non-retryable without server-supplied
RetryInfothe collector discardedentire batches as permanent errors -- 832,514 of 843,156 spans. Every
request still returned HTTP 200, so the only symptom was a suspiciously empty
Tempo.
Fixed at the root by raising the receiver limit, with the batch cap as defence
in depth (both are span-count caps, so neither alone can guarantee a byte
limit):
Verified after: 0 dropped batches, 0 permanent errors, and a controlled probe
measured exactly 23.0 spans per request exported.
And made detectable. The incident was invisible because nothing scraped
the collector's own counters. Its self-telemetry is now exposed and scraped,
with a "Span Export Failures (collector -> Tempo)" panel on
otelcol_exporter_send_failed_spans. The panel's series are filtered toexporter="otlp/tempo": the pipeline also has adebugexporter, so anunfiltered
sum()counts every span twice and a debug-exporter hiccup wouldread as a Tempo failure.
A
memory_limiterruns first in the pipeline. The collector container iscapped at 512Mi. Under exporter backpressure the queue grew until the kernel
killed it and every buffered span went with it; the limiter refuses new spans
near the limit instead and counts them in
otelcol_processor_refused_spans,which is now a series on the same panel. Verified live: Memory limiter
configured in the collector log, pod stable, counter present.
Sampling. 500 RPS x 23 spans is ~11,500 spans/sec unsampled, which no real
deployment exports in full. Run C uses
sampling_rate: 0.1in bothscenarios -- the core config had been left unsampled, which at 2000 RPS is the
larger of the two firehoses. The sampler is parent-based, so it samples 10% of
traces and each sampled trace arrives complete -- fewer traces, not partial
ones.
Reports are self-describing.
benchmark.shrecords rate, duration, runs,connections and per-config cargo features (read off the image's
io.praxis.build.featureslabel rather than assumed), trace sampling and theactual filter chain into
scenario.env.report.shrenders it as a Scenariotable, so a report cannot drift from the configs that produced it.
Dependencies
Requires praxis-proxy/forge#16 (
extraPortMappings, to expose KINDNodePorts to the host). Once that merges, the README's install line drops
--branch feat/extra-port-mappings-v2.Demo structure
Datasources are provisioned declaratively with fixed uids. They were
previously POSTed to Grafana's API, which meant a pod restart silently dropped
them and every panel rendered "No data".
Review fixes in the latest push
forge.yaml'sexecsteps now name the kubectl context. forge scopesmanifest/helm/waitsteps to the cluster but runsexecwith theambient context, so with two KIND clusters up the namespace and dashboard
ConfigMaps could land on whichever was created last.
ai-extendedbenchmark scenario: it referenced configs thatnever existed, so selecting it aborted on the first
kubectl create.kubectl top podsnapshot. No stack installsmetrics-server, so it always failed into
|| trueand wrote nine empty*-resources.txtfiles; the dashboards already plot CPU and memory fromcAdvisor.
make container FEATURES=otelnow plumbs the build-arg through, so therepo's own tooling can build the image the demos ask for instead of
hand-rolled
docker buildlines. Added a cargo git cache mount alongsidethe registry one, since the ai dependency is a git source and was re-cloned
every build.
shellcheckinmake lint/CI now coversdemos/*/scripts/*.sh, which itnever did; the first run found a masked exit status in
report.sh.Cargo.tomlanddeny.tomldescribing a[patch.crates-io]table that the 0.5.4 bump removed.Test plan
praxis-forge upcreates the cluster with all four host port mappingsrendering ~27 indistinguishable "P50"/"P99" series and half-zero stat tiles)
make build,make lint,make test, taplo, markdownlint all cleanKnown limitations
praxis-ai-proxyis not published to crates.io, so it stays a gitdependency, pinned to the commit tagged
v0.3.0rather than to the tagitself: a tag can be force-moved upstream and a commit cannot. The praxis
core crates come from crates.io at 0.5.4.
praxis-protocolis declared with theadmin-apifeature becausepraxis-ai-proxyusesAdminEndpointOptionswithout enabling it, and thepraxis-mainfeature is on because the default path in ai v0.3.0 omits thelog_levelfield 0.5.4 requires. Both are upstream gaps worth filing.Slow Traces (>100ms)and the twoai#92-blocked token panels arelegitimately empty.