feat: otel instrumentation#4
Conversation
|
|
Heads-up on an integration point with the libsy observability work (#69, SWITCH-928, now in review): libsy records its
Suggestion: one line in opentelemetry::global::set_meter_provider(provider.clone());— so the same Prometheus pull reader (and optional OTLP push) also picks up globally-recorded instruments from embedded libraries. No behavior change for the Tracked as SWITCH-948 (linked to SWITCH-928) — happy to fold it in here or take it as a follow-up after this merges, whichever you prefer. 🤖 Generated with Claude Code |
Instrument Switchyard with OpenTelemetry; remove bespoke metrics
Summary
Replaces Switchyard's hand-rolled metrics/stats machinery with OpenTelemetry across both serving paths — the Python FastAPI chain and the Rust-native
switchyard-server/components-v2profiles. Both paths now emit the same metric surface (traces + metrics), export over OTLP, and serve an identical Prometheus/metricsscrape endpoint. The bespokeStatsAccumulatorstack, ddtrace tracing, and the JSON/v1/statsendpoints are removed.138 files changed, +3,815 / −9,188— a net deletion: one standard observability layer replaces several parallel hand-rolled ones.Why
The previous setup had multiple disjoint collectors (a Rust
StatsAccumulatorwith a JSON/v1/stats, a hand-rolled Prometheus exposition, an optional ddtrace span layer, a CLI live-stats footer, and per-backend emitters). OTel gives us one vendor-neutral pipeline, per-stage router timing, routing-decision telemetry, and the success metrics we track for benchmarking — without maintaining bespoke aggregation.What's instrumented
Per-stage router timing (the headline ask): distinct histograms for
routing_overhead_msvsmodel_call_latency_msvstotal_latency_ms, so router decision cost is separated from raw backend time. Classifier/planner strategies (which add a real LLM call) are bucketed separately underrole.Routing-decision telemetry: a
switchyard.route_decisionspan (router, tier, selected model/target, score/draw/threshold where available) plus arouting_decisions{router,source,tier}counter, on all strategies (random, routellm, cascade, deterministic, plan-execute).Spans (per request):
request_processors → backend_call → response_processors, with Rust upstream spans on the Rust path.Metrics (names chosen so the OTel→Prometheus exporter reproduces the conventional surface; no
unitset, so no_ms_msdouble-suffix):switchyard_total_latency_ms{model,tier}switchyard_ttft_ms{model,tier}(first-streamed-chunk)switchyard_cost_usd{model,tier,role,kind}(via the retained pricing table)switchyard_routing_overhead_ms{router}switchyard_requests_total{model,tier,router}Task accuracy is not observable at the proxy and is intentionally out of scope.
Removed
StatsAccumulator,StatsLlmBackend,Stats{Request,Response}Processorand their PyO3 bindings;profile_stats_accumulator; the JSON/v1/stats(+/v1/routing/stats) endpoints.live_stats_collector,prometheus_exposition,outcome_metrics,prometheus_emitter, the ddtracetracing.py,stats_endpoint, the stats processor/backend shims, and the CLI live-stats footer + session summary.ddtrace/tracingextra.Kept (still depended on):
cost_estimator, the intake pipeline,telemetry.py, and the Rustusage/cost/cache_eligibility/contexthelpers +set_stats_route_label(backctx.backend_call_latency_msand the intake route label).Architecture notes
MeterProviderper process holding a Prometheus pull reader (/metrics) and an OTLP push reader. Same instrument names on both languages so the two paths produce an identical scrape surface.traceparentpropagated across the FFI boundary (Rust bridges its existingtracingspans). Note: trace export is wired but kept minimal in this PR — see follow-ups.otelextra isn't installed,OTEL_SDK_DISABLEDis set, or no OTLP endpoint is configured — default installs pay no overhead.New dependencies
otelextra:opentelemetry-{api,sdk,exporter-otlp,exporter-prometheus}.opentelemetry,opentelemetry_sdk,opentelemetry-prometheus,opentelemetry-otlp,prometheus(0.32 line).Breaking changes
GET /v1/statsand/v1/routing/statsare removed. ScrapeGET /metrics(Prometheus) instead.quantile=…) to histograms (_bucket/_sum/_count) — dashboards/alerts keyed onquantile="0.99"must move tohistogram_quantile(...).routing_overhead_msgained a{router}label.curl -s …/metrics.Testing
pytest(non-e2e) 1797 passed, 9 skipped;ruff+mypy --strictclean. OTel surface asserted via in-memory metric reader / span exporter.cargo test --workspace392 passed, 0 failed;cargo fmt/clippyclean;maturin developbuilds.Follow-ups (not in this PR)
Endpoint-level request root span (needs streaming-span-lifetime handling in the SSE iterator).
Fuller Rust trace export + a cross-process Python↔Rust trace-stitch test.
uv run ruff check .cleanuv run mypy switchyardcleanuv run pytest tests/greenManual smoke (describe what was run)
Checklist
snake_caseof the primary class.switchyard/__init__.py.__all__if intended for downstream use.--helpupdated if customer-facing surface changed.Signed-off-by: Your Name <email>) per the DCO.Notes for reviewers
Anything reviewers should pay extra attention to — risky paths, follow-up tickets, intentional trade-offs.