Skip to main content

switchyard_libsy/
observability.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenTelemetry metrics plus `tracing` spans and structured logs for the
5//! algorithm layer.
6//!
7//! [`Algorithm::run_stream`](crate::Algorithm::run_stream) and [`Driver`] call these
8//! helpers around the [`Decision`] hook and the offload boundary, so every algorithm is
9//! instrumented from the outside and carries no telemetry code of its own. The provider
10//! call on the other side of the offload belongs to the host, and is instrumented by
11//! whoever makes it. Metrics record through the
12//! OpenTelemetry **global** meter provider under the `switchyard` scope — the host
13//! installs an SDK provider and exporters; with none installed, recording is a
14//! no-op. Spans and logs use the `tracing` facade (the async-native surface the
15//! OpenTelemetry ecosystem bridges with `tracing-opentelemetry` /
16//! `opentelemetry-appender-tracing`), so the host's subscriber decides where
17//! they go. Method spans use `#[tracing::instrument]`; the `libsy.run` span is
18//! attached to the spawned run task with [`tracing::Instrument`]. Neither holds
19//! a [`Span::enter`] guard across an `.await` — a suspended task would leave
20//! the span entered on its executor thread, mis-parenting every span other
21//! tasks create there (see the `tracing` docs on spans in asynchronous code).
22//!
23//! Instrument names use the OTel dotted form with the unit baked into the name
24//! (`switchyard.run_duration_ms`), matching the switchyard metric surface; a
25//! Prometheus exporter sanitizes them to `switchyard_run_duration_ms`. Attribute
26//! cardinality is bounded: `algorithm` and `selected_model` are small
27//! configured sets and `outcome` is `ok`/`error`. Nothing per-request becomes a
28//! metric attribute — correlation ids ride on the `libsy.run` span instead.
29//!
30//! Instruments are resolved from the global provider on every record (an
31//! instrument-cache lookup inside the SDK) so recording follows a meter
32//! provider installed at any point in the process lifetime; the cost is
33//! negligible next to a model call.
34
35use std::future::Future;
36use std::sync::OnceLock;
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::time::{Duration, Instant};
39
40use opentelemetry::metrics::{Meter, ObservableGauge};
41use opentelemetry::{KeyValue, global};
42use tracing::Span;
43
44use crate::Result;
45use switchyard_protocol::{Context, Decision, Request, Response};
46
47const METRICS_SCOPE: &str = "switchyard";
48const TRACING_TARGET: &str = "libsy";
49
50static TOTAL_REQUESTS: AtomicU64 = AtomicU64::new(0);
51static TOTAL_ERRORS: AtomicU64 = AtomicU64::new(0);
52static TOTAL_GAUGES: OnceLock<(ObservableGauge<u64>, ObservableGauge<u64>)> = OnceLock::new();
53
54/// [`Context::values`] key under which `run_stream` stamps the algorithm's
55/// telemetry label ([`Algorithm::name`](crate::Algorithm::name)).
56pub(crate) const ALGORITHM_KEY: &str = "algorithm";
57
58/// The algorithm label [`Algorithm::run_stream`](crate::Algorithm::run_stream) stamps into
59/// a request context; empty until stamped.
60///
61/// A host instrumenting the calls it serves reads this to attribute its own spans to the
62/// algorithm that produced them.
63pub fn algorithm_label(ctx: &Context) -> &str {
64    ctx.values
65        .get(ALGORITHM_KEY)
66        .map(String::as_str)
67        .unwrap_or("")
68}
69
70/// The `libsy`-scoped meter from the globally installed provider.
71fn meter() -> Meter {
72    global::meter(METRICS_SCOPE)
73}
74
75/// Registers process-wide compatibility gauges with the installed global meter provider.
76pub(crate) fn initialize_metrics() {
77    TOTAL_GAUGES.get_or_init(|| {
78        let meter = meter();
79        let requests = meter
80            .u64_observable_gauge("switchyard.total_requests")
81            .with_callback(|observer| {
82                observer.observe(TOTAL_REQUESTS.load(Ordering::Relaxed), &[]);
83            })
84            .build();
85        let errors = meter
86            .u64_observable_gauge("switchyard.total_errors")
87            .with_callback(|observer| {
88                observer.observe(TOTAL_ERRORS.load(Ordering::Relaxed), &[]);
89            })
90            .build();
91        (requests, errors)
92    });
93}
94
95/// `outcome` attribute value for a result: `ok` or `error`.
96fn outcome_value<T>(result: &Result<T>) -> &'static str {
97    if result.is_ok() { "ok" } else { "error" }
98}
99
100/// Span covering one algorithm run (the whole `create_run_task` execution).
101///
102/// Correlation ids from the request [`switchyard_protocol::Metadata`] are recorded as span fields
103/// when present. `tracing` spans cannot grow field names at runtime, so
104/// arbitrary host labels ride in via [`switchyard_protocol::Metadata::extra_metadata`], recorded
105/// whole into the `extra_metadata` field. `outcome` and `error` are filled in
106/// by [`record_run`] when the run ends.
107pub(crate) fn run_span(algorithm: &str, request: &Request) -> Span {
108    let span = tracing::info_span!(
109        target: TRACING_TARGET,
110        "libsy.run",
111        algorithm,
112        switchyard.algorithm = algorithm,
113        openinference.span.kind = "CHAIN",
114        switchyard.route = tracing::field::Empty,
115        session_id = tracing::field::Empty,
116        session.id = tracing::field::Empty,
117        agent_id = tracing::field::Empty,
118        task_id = tracing::field::Empty,
119        correlation_id = tracing::field::Empty,
120        extra_metadata = tracing::field::Empty,
121        outcome = tracing::field::Empty,
122        error = tracing::field::Empty,
123    );
124    if let Some(route) = request.requested_model() {
125        span.record("switchyard.route", route);
126    }
127    if let Some(metadata) = &request.metadata {
128        for (field, value) in [
129            ("session_id", &metadata.session_id),
130            ("agent_id", &metadata.agent_id),
131            ("task_id", &metadata.task_id),
132            ("correlation_id", &metadata.correlation_id),
133        ] {
134            if let Some(value) = value {
135                span.record(field, value.as_str());
136            }
137        }
138        if let Some(session_id) = &metadata.session_id {
139            span.record("session.id", session_id.as_str());
140        }
141        if let Some(extra) = &metadata.extra_metadata {
142            span.record("extra_metadata", tracing::field::debug(extra));
143        }
144    }
145    span
146}
147
148/// Runs one algorithm task to completion, recording the run counter, duration
149/// histogram, span outcome, and failure log when it resolves.
150/// Executes inside the `libsy.run` span its caller instruments the task with.
151pub(crate) async fn observe_run(
152    ctx: Context,
153    run: impl Future<Output = Result<Response>>,
154) -> Result<Response> {
155    let started = Instant::now();
156    let result = run.await;
157    let duration = started.elapsed();
158    let algorithm = algorithm_label(&ctx);
159    record_run(algorithm, duration, &result, &Span::current());
160    result
161}
162
163/// Records the end of one algorithm run: the run counter and duration
164/// histogram, the `outcome`/`error` fields on `span`, and a warn log when the
165/// run failed.
166fn record_run(algorithm: &str, duration: Duration, result: &Result<Response>, span: &Span) {
167    let outcome = outcome_value(result);
168    span.record("outcome", outcome);
169    if let Err(error) = result {
170        span.record("error", tracing::field::display(error));
171        tracing::warn!(
172            target: TRACING_TARGET,
173            algorithm,
174            error = %error,
175            "algorithm run failed"
176        );
177    }
178
179    let attributes = [
180        KeyValue::new("algorithm", algorithm.to_string()),
181        KeyValue::new("outcome", outcome),
182    ];
183    let meter = meter();
184    meter
185        .u64_counter("switchyard.runs")
186        .build()
187        .add(1, &attributes);
188    meter
189        .f64_histogram("switchyard.run_duration_ms")
190        .build()
191        .record(duration.as_secs_f64() * 1000.0, &attributes);
192}
193
194/// Records a judge failure that made the classifier route without a verdict.
195pub(crate) fn record_classifier_fail_open(judge_model: &str, reason: &'static str) {
196    meter()
197        .u64_counter("switchyard.classifier_fail_open")
198        .build()
199        .add(
200            1,
201            &[
202                KeyValue::new("judge_model", judge_model.to_string()),
203                KeyValue::new("reason", reason),
204            ],
205        );
206}
207
208/// Records the resolution of one offloaded model call: the call counter and
209/// latency histogram, the `outcome`/`error`/token fields on `span`, and a warn
210/// log when the call failed.
211pub(crate) fn record_llm_call(
212    algorithm: &str,
213    selected_model: &str,
214    tier: Option<&str>,
215    is_routed: bool,
216    duration: Duration,
217    result: &Result<Response>,
218    span: &Span,
219) {
220    let outcome = outcome_value(result);
221    span.record("outcome", outcome);
222
223    let meter = meter();
224    let call_attributes = [
225        KeyValue::new("algorithm", algorithm.to_string()),
226        KeyValue::new("selected_model", selected_model.to_string()),
227        KeyValue::new("outcome", outcome),
228    ];
229    meter
230        .u64_counter("switchyard.llm_calls")
231        .build()
232        .add(1, &call_attributes);
233    meter
234        .f64_histogram("switchyard.llm_call_duration_ms")
235        .build()
236        .record(duration.as_secs_f64() * 1000.0, &call_attributes);
237
238    if is_routed {
239        TOTAL_REQUESTS.fetch_add(1, Ordering::Relaxed);
240        let mut routed_attributes = vec![KeyValue::new("model", selected_model.to_string())];
241        if let Some(tier) = tier {
242            routed_attributes.push(KeyValue::new("tier", tier.to_string()));
243        }
244        if result.is_ok() {
245            meter
246                .u64_counter("switchyard.requests")
247                .build()
248                .add(1, &routed_attributes);
249            meter
250                .f64_histogram("switchyard.model_call_latency_ms")
251                .build()
252                .record(duration.as_secs_f64() * 1000.0, &routed_attributes);
253        } else {
254            TOTAL_ERRORS.fetch_add(1, Ordering::Relaxed);
255            meter
256                .u64_counter("switchyard.errors")
257                .build()
258                .add(1, &routed_attributes);
259        }
260    }
261
262    match result {
263        Ok(response) => {
264            // Token usage exists only once a response is buffered; a streamed
265            // response resolves before its usage is known, so none is recorded.
266            let Some(usage) = response.llm_response.as_agg().map(|agg| &agg.usage) else {
267                return;
268            };
269            for (field, value) in [
270                ("input_tokens", usage.input_tokens),
271                ("output_tokens", usage.output_tokens),
272                ("total_tokens", usage.total_tokens),
273                ("reasoning_tokens", usage.reasoning_tokens),
274            ] {
275                if let Some(value) = value {
276                    span.record(field, value);
277                }
278            }
279        }
280        Err(error) => {
281            span.record("error", tracing::field::display(error));
282            tracing::warn!(
283                target: TRACING_TARGET,
284                algorithm,
285                selected_model,
286                error = %error,
287                "model call failed"
288            );
289        }
290    }
291}
292
293/// Records one published routing decision: the decision counter plus a
294/// structured debug event carrying the decision's reasoning.
295pub(crate) fn record_decision(ctx: &Context, decision: &dyn Decision) {
296    let algorithm = algorithm_label(ctx);
297    let selected_model = decision.selected_model();
298    tracing::debug!(
299        target: TRACING_TARGET,
300        algorithm,
301        selected_model,
302        reasoning = decision.reasoning().unwrap_or(""),
303        "routing decision"
304    );
305    meter().u64_counter("switchyard.decisions").build().add(
306        1,
307        &[
308            KeyValue::new("algorithm", algorithm.to_string()),
309            KeyValue::new("selected_model", selected_model.to_string()),
310        ],
311    );
312}