1use 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
54pub(crate) const ALGORITHM_KEY: &str = "algorithm";
57
58pub fn algorithm_label(ctx: &Context) -> &str {
64 ctx.values
65 .get(ALGORITHM_KEY)
66 .map(String::as_str)
67 .unwrap_or("")
68}
69
70fn meter() -> Meter {
72 global::meter(METRICS_SCOPE)
73}
74
75pub(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
95fn outcome_value<T>(result: &Result<T>) -> &'static str {
97 if result.is_ok() { "ok" } else { "error" }
98}
99
100pub(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
148pub(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
171fn 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
202fn record_routing_overhead(
206 algorithm: &str,
207 run: Duration,
208 routed_call: Option<Duration>,
209) -> Option<Duration> {
210 let routed_call = routed_call?;
211 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
224pub(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
238pub(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 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
323pub(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}