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 is_answer_call: bool,
215 duration: Duration,
216 result: &Result<Response>,
217 span: &Span,
218) {
219 let outcome = outcome_value(result);
220 span.record("outcome", outcome);
221
222 let meter = meter();
223 let call_attributes = [
224 KeyValue::new("algorithm", algorithm.to_string()),
225 KeyValue::new("selected_model", selected_model.to_string()),
226 KeyValue::new("outcome", outcome),
227 ];
228 meter
229 .u64_counter("switchyard.llm_calls")
230 .build()
231 .add(1, &call_attributes);
232 meter
233 .f64_histogram("switchyard.llm_call_duration_ms")
234 .build()
235 .record(duration.as_secs_f64() * 1000.0, &call_attributes);
236
237 if is_answer_call {
238 TOTAL_REQUESTS.fetch_add(1, Ordering::Relaxed);
239 let routed_attributes = [KeyValue::new("model", selected_model.to_string())];
240 if result.is_ok() {
241 meter
242 .u64_counter("switchyard.requests")
243 .build()
244 .add(1, &routed_attributes);
245 meter
246 .f64_histogram("switchyard.model_call_latency_ms")
247 .build()
248 .record(duration.as_secs_f64() * 1000.0, &routed_attributes);
249 } else {
250 TOTAL_ERRORS.fetch_add(1, Ordering::Relaxed);
251 meter
252 .u64_counter("switchyard.errors")
253 .build()
254 .add(1, &routed_attributes);
255 }
256 }
257
258 match result {
259 Ok(response) => {
260 let Some(usage) = response.llm_response.as_agg().map(|agg| &agg.usage) else {
263 return;
264 };
265 for (field, value) in [
266 ("input_tokens", usage.input_tokens),
267 ("output_tokens", usage.output_tokens),
268 ("total_tokens", usage.total_tokens),
269 ("reasoning_tokens", usage.reasoning_tokens),
270 ] {
271 if let Some(value) = value {
272 span.record(field, value);
273 }
274 }
275 }
276 Err(error) => {
277 span.record("error", tracing::field::display(error));
278 tracing::warn!(
279 target: TRACING_TARGET,
280 algorithm,
281 selected_model,
282 error = %error,
283 "model call failed"
284 );
285 }
286 }
287}
288
289pub(crate) fn record_decision(ctx: &Context, decision: &Decision) {
292 let algorithm = algorithm_label(ctx);
293 let selected_model = decision.selected_model_id();
294 tracing::debug!(
295 target: TRACING_TARGET,
296 algorithm,
297 selected_model,
298 reasoning = decision.reasoning().unwrap_or(""),
299 "routing decision"
300 );
301 meter().u64_counter("switchyard.decisions").build().add(
302 1,
303 &[
304 KeyValue::new("algorithm", algorithm.to_string()),
305 KeyValue::new("selected_model", selected_model.to_string()),
306 ],
307 );
308}