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::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(
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
163fn 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
194pub(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
208pub(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 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
293pub(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}