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::{Driver, 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, routing overhead, span outcome, and failure log when it resolves.
150/// Executes inside the `libsy.run` span its caller instruments the task with.
151/// `driver` is the run's own, holding the duration of the call that served it.
152pub(crate) async fn observe_run(
153    ctx: Context,
154    driver: Driver,
155    run: impl Future<Output = Result<Response>>,
156) -> Result<Response> {
157    let started = Instant::now();
158    let result = run.await;
159    let duration = started.elapsed();
160    let algorithm = algorithm_label(&ctx);
161    record_run(algorithm, duration, &result, &Span::current());
162    if result.is_ok()
163        && let Some(overhead) =
164            record_routing_overhead(algorithm, duration, driver.routed_call_duration())
165    {
166        driver.observe_routing_overhead(overhead);
167    }
168    result
169}
170
171/// Records the end of one algorithm run: the run counter and duration
172/// histogram, the `outcome`/`error` fields on `span`, and a warn log when the
173/// run failed.
174fn record_run(algorithm: &str, duration: Duration, result: &Result<Response>, span: &Span) {
175    let outcome = outcome_value(result);
176    span.record("outcome", outcome);
177    if let Err(error) = result {
178        span.record("error", tracing::field::display(error));
179        tracing::warn!(
180            target: TRACING_TARGET,
181            algorithm,
182            error = %error,
183            "algorithm run failed"
184        );
185    }
186
187    let attributes = [
188        KeyValue::new("algorithm", algorithm.to_string()),
189        KeyValue::new("outcome", outcome),
190    ];
191    let meter = meter();
192    meter
193        .u64_counter("switchyard.runs")
194        .build()
195        .add(1, &attributes);
196    meter
197        .f64_histogram("switchyard.run_duration_ms")
198        .build()
199        .record(duration.as_secs_f64() * 1000.0, &attributes);
200}
201
202/// Records what routing cost on top of the call that served the run: classifier
203/// calls, target resolution, decision publishing. A run with no routed call has
204/// nothing to subtract, so it records nothing.
205fn record_routing_overhead(
206    algorithm: &str,
207    run: Duration,
208    routed_call: Option<Duration>,
209) -> Option<Duration> {
210    let routed_call = routed_call?;
211    // Saturating: the two clocks start a moment apart, so a run that is all
212    // routed call can come out fractionally negative.
213    let overhead = run.saturating_sub(routed_call);
214    meter()
215        .f64_histogram("switchyard.routing_overhead_ms")
216        .build()
217        .record(
218            overhead.as_secs_f64() * 1000.0,
219            &[KeyValue::new("algorithm", algorithm.to_string())],
220        );
221    Some(overhead)
222}
223
224/// Records a judge failure that made the classifier route without a verdict.
225pub(crate) fn record_classifier_fail_open(judge_model: &str, reason: &'static str) {
226    meter()
227        .u64_counter("switchyard.classifier_fail_open")
228        .build()
229        .add(
230            1,
231            &[
232                KeyValue::new("judge_model", judge_model.to_string()),
233                KeyValue::new("reason", reason),
234            ],
235        );
236}
237
238/// Records the resolution of one offloaded model call: the call counter and
239/// latency histogram, the `outcome`/`error`/token fields on `span`, and a warn
240/// log when the call failed.
241pub(crate) fn record_llm_call(
242    algorithm: &str,
243    selected_model: &str,
244    tier: Option<&str>,
245    is_routed: bool,
246    duration: Duration,
247    result: &Result<Response>,
248    span: &Span,
249) {
250    let outcome = outcome_value(result);
251    span.record("outcome", outcome);
252
253    let meter = meter();
254    let call_attributes = [
255        KeyValue::new("algorithm", algorithm.to_string()),
256        KeyValue::new("selected_model", selected_model.to_string()),
257        KeyValue::new("outcome", outcome),
258    ];
259    meter
260        .u64_counter("switchyard.llm_calls")
261        .build()
262        .add(1, &call_attributes);
263    meter
264        .f64_histogram("switchyard.llm_call_duration_ms")
265        .build()
266        .record(duration.as_secs_f64() * 1000.0, &call_attributes);
267
268    if is_routed {
269        TOTAL_REQUESTS.fetch_add(1, Ordering::Relaxed);
270        let mut routed_attributes = vec![KeyValue::new("model", selected_model.to_string())];
271        if let Some(tier) = tier {
272            routed_attributes.push(KeyValue::new("tier", tier.to_string()));
273        }
274        if result.is_ok() {
275            meter
276                .u64_counter("switchyard.requests")
277                .build()
278                .add(1, &routed_attributes);
279            meter
280                .f64_histogram("switchyard.model_call_latency_ms")
281                .build()
282                .record(duration.as_secs_f64() * 1000.0, &routed_attributes);
283        } else {
284            TOTAL_ERRORS.fetch_add(1, Ordering::Relaxed);
285            meter
286                .u64_counter("switchyard.errors")
287                .build()
288                .add(1, &routed_attributes);
289        }
290    }
291
292    match result {
293        Ok(response) => {
294            // Token usage exists only once a response is buffered; a streamed
295            // response resolves before its usage is known, so none is recorded.
296            let Some(usage) = response.llm_response.as_agg().map(|agg| &agg.usage) else {
297                return;
298            };
299            for (field, value) in [
300                ("input_tokens", usage.input_tokens),
301                ("output_tokens", usage.output_tokens),
302                ("total_tokens", usage.total_tokens),
303                ("reasoning_tokens", usage.reasoning_tokens),
304            ] {
305                if let Some(value) = value {
306                    span.record(field, value);
307                }
308            }
309        }
310        Err(error) => {
311            span.record("error", tracing::field::display(error));
312            tracing::warn!(
313                target: TRACING_TARGET,
314                algorithm,
315                selected_model,
316                error = %error,
317                "model call failed"
318            );
319        }
320    }
321}
322
323/// Records one published routing decision: the decision counter plus a
324/// structured debug event carrying the decision's reasoning.
325pub(crate) fn record_decision(ctx: &Context, decision: &dyn Decision) {
326    let algorithm = algorithm_label(ctx);
327    let selected_model = decision.selected_model();
328    tracing::debug!(
329        target: TRACING_TARGET,
330        algorithm,
331        selected_model,
332        reasoning = decision.reasoning().unwrap_or(""),
333        "routing decision"
334    );
335    meter().u64_counter("switchyard.decisions").build().add(
336        1,
337        &[
338            KeyValue::new("algorithm", algorithm.to_string()),
339            KeyValue::new("selected_model", selected_model.to_string()),
340        ],
341    );
342}