1use crate::analyzers::AnalyzerError;
21use crate::model::{LlmCallRow, ViewResult, ViewSink};
22use crate::view::llm::provider_from_host;
23use http_body_util::{BodyExt, Full};
24use hyper::body::Bytes;
25use hyper_util::client::legacy::Client;
26use hyper_util::rt::TokioExecutor;
27use serde_json::{Value, json};
28use std::sync::Arc;
29
30const DEFAULT_OTLP_ENDPOINT: &str = "http://localhost:4318";
32
33#[derive(Clone)]
34struct SpanInput {
35 start_unix_nano: u128,
36 provider: String,
37 server_address: String,
38 model: Option<String>,
39 conversation_id: Option<String>,
40 max_tokens: Option<i64>,
41 temperature: Option<f64>,
42 top_p: Option<f64>,
43 input_messages: Option<String>,
45}
46
47pub struct OtelExporter {
49 traces_url: String,
51 service_name: String,
53 capture_content: bool,
55 client: Arc<Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>>>,
56}
57
58impl OtelExporter {
59 pub fn new(endpoint: Option<String>, capture_content: bool) -> Self {
64 let traces_url = if let Ok(full) = std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
65 full
66 } else {
67 let base = endpoint
68 .or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
69 .unwrap_or_else(|| DEFAULT_OTLP_ENDPOINT.to_string());
70 format!("{}/v1/traces", base.trim_end_matches('/'))
71 };
72
73 let service_name =
74 std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "agentsight".to_string());
75
76 Self {
77 traces_url,
78 service_name,
79 capture_content,
80 client: Arc::new(Client::builder(TokioExecutor::new()).build_http()),
81 }
82 }
83}
84
85fn usage_int(usage: &Value, names: &[&str]) -> Option<i64> {
89 names
90 .iter()
91 .find_map(|n| usage.get(*n).and_then(|v| v.as_i64()))
92}
93
94fn finish_reasons(body: &Value) -> Vec<String> {
96 if let Some(choices) = body.get("choices").and_then(|c| c.as_array()) {
98 let reasons: Vec<String> = choices
99 .iter()
100 .filter_map(|c| {
101 c.get("finish_reason")
102 .and_then(|r| r.as_str())
103 .map(String::from)
104 })
105 .collect();
106 if !reasons.is_empty() {
107 return reasons;
108 }
109 }
110 if let Some(stop) = body.get("stop_reason").and_then(|v| v.as_str()) {
112 return vec![stop.to_string()];
113 }
114 Vec::new()
115}
116
117fn av_str(s: &str) -> Value {
119 json!({ "stringValue": s })
120}
121
122fn attr_str(key: &str, s: &str) -> Value {
124 json!({ "key": key, "value": av_str(s) })
125}
126
127fn attr_int(key: &str, n: i64) -> Value {
130 json!({ "key": key, "value": { "intValue": n.to_string() } })
131}
132
133fn attr_double(key: &str, n: f64) -> Value {
135 json!({ "key": key, "value": { "doubleValue": n } })
136}
137
138fn attr_str_array(key: &str, items: &[String]) -> Value {
140 let values: Vec<Value> = items.iter().map(|s| av_str(s)).collect();
141 json!({ "key": key, "value": { "arrayValue": { "values": values } } })
142}
143
144fn build_otlp_payload(
147 service_name: &str,
148 trace_id: &str,
149 span_id: &str,
150 req: &SpanInput,
151 end_unix_nano: u128,
152 status_code: Option<u16>,
153 response_body: Option<&Value>,
154 capture_content: bool,
155) -> Value {
156 let model_name = req.model.as_deref().unwrap_or("unknown");
157
158 let mut attributes = vec![
159 attr_str("gen_ai.operation.name", "chat"),
160 attr_str("gen_ai.provider.name", &req.provider),
161 attr_str("server.address", &req.server_address),
162 ];
163 if let Some(conversation_id) = &req.conversation_id {
164 attributes.push(attr_str("gen_ai.conversation.id", conversation_id));
165 }
166 if let Some(model) = &req.model {
167 attributes.push(attr_str("gen_ai.request.model", model));
168 }
169 if let Some(mt) = req.max_tokens {
170 attributes.push(attr_int("gen_ai.request.max_tokens", mt));
171 }
172 if let Some(t) = req.temperature {
173 attributes.push(attr_double("gen_ai.request.temperature", t));
174 }
175 if let Some(p) = req.top_p {
176 attributes.push(attr_double("gen_ai.request.top_p", p));
177 }
178
179 let mut span_status = json!({ "code": 1 }); if let Some(body) = response_body {
182 if let Some(rmodel) = body.get("model").and_then(|v| v.as_str()) {
183 attributes.push(attr_str("gen_ai.response.model", rmodel));
184 }
185 if let Some(id) = body.get("id").and_then(|v| v.as_str()) {
186 attributes.push(attr_str("gen_ai.response.id", id));
187 }
188 if let Some(usage) = body.get("usage") {
189 if let Some(input) = usage_int(usage, &["input_tokens", "prompt_tokens"]) {
190 attributes.push(attr_int("gen_ai.usage.input_tokens", input));
191 }
192 if let Some(output) = usage_int(usage, &["output_tokens", "completion_tokens"]) {
193 attributes.push(attr_int("gen_ai.usage.output_tokens", output));
194 }
195 }
196 let reasons = finish_reasons(body);
197 if !reasons.is_empty() {
198 attributes.push(attr_str_array("gen_ai.response.finish_reasons", &reasons));
199 }
200 if capture_content {
201 attributes.push(attr_str("gen_ai.output.messages", &body.to_string()));
202 }
203 }
204
205 if let Some(code) = status_code {
207 attributes.push(attr_int("http.response.status_code", code as i64));
208 if code >= 400 {
209 span_status = json!({ "code": 2, "message": format!("HTTP {}", code) });
210 }
211 }
212
213 if capture_content && let Some(msgs) = &req.input_messages {
214 attributes.push(attr_str("gen_ai.input.messages", msgs));
215 }
216
217 json!({
218 "resourceSpans": [{
219 "resource": {
220 "attributes": [ attr_str("service.name", service_name) ]
221 },
222 "scopeSpans": [{
223 "scope": { "name": "agentsight", "version": env!("CARGO_PKG_VERSION") },
224 "spans": [{
225 "traceId": trace_id,
226 "spanId": span_id,
227 "name": format!("chat {}", model_name),
228 "kind": 3, "startTimeUnixNano": req.start_unix_nano.to_string(),
230 "endTimeUnixNano": end_unix_nano.to_string(),
231 "attributes": attributes,
232 "status": span_status
233 }]
234 }]
235 }]
236 })
237}
238
239fn new_ids() -> (String, String) {
241 let trace = uuid::Uuid::new_v4().simple().to_string(); let span = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
243 (trace, span)
244}
245
246impl SpanInput {
247 fn from_call(call: &LlmCallRow, capture_content: bool) -> Self {
248 let request = &call.request;
249 let host = call.host.as_deref().unwrap_or_default();
250 Self {
251 start_unix_nano: (call.start_timestamp_ms as u128) * 1_000_000,
252 provider: call
253 .provider
254 .clone()
255 .unwrap_or_else(|| provider_from_host(host)),
256 server_address: host.to_string(),
257 conversation_id: conversation_id_from_request(request),
258 model: call.model.clone().or_else(|| {
259 request
260 .get("model")
261 .and_then(Value::as_str)
262 .map(String::from)
263 }),
264 max_tokens: request
265 .get("max_tokens")
266 .or_else(|| request.get("max_output_tokens"))
267 .and_then(Value::as_i64),
268 temperature: request.get("temperature").and_then(Value::as_f64),
269 top_p: request.get("top_p").and_then(Value::as_f64),
270 input_messages: capture_content
271 .then(|| {
272 request
273 .get("messages")
274 .or_else(|| request.get("input"))
275 .map(Value::to_string)
276 })
277 .flatten(),
278 }
279 }
280}
281
282impl ViewSink for OtelExporter {
283 fn llm_call(&mut self, call: &LlmCallRow) -> ViewResult<()> {
284 let Some(end_ms) = call.end_timestamp_ms else {
285 return Ok(());
286 };
287 let span_input = SpanInput::from_call(call, self.capture_content);
288 let (trace_id, span_id) = new_ids();
289 let payload = build_otlp_payload(
290 &self.service_name,
291 &trace_id,
292 &span_id,
293 &span_input,
294 (end_ms as u128) * 1_000_000,
295 call.status_code,
296 Some(&call.response),
297 self.capture_content,
298 );
299 let client = self.client.clone();
300 let url = self.traces_url.clone();
301 tokio::spawn(async move {
302 if let Err(e) = post_otlp(&client, &url, payload).await {
303 log::warn!("OtelExporter: failed to export span: {}", e);
304 }
305 });
306 Ok(())
307 }
308}
309
310fn conversation_id_from_request(request: &Value) -> Option<String> {
311 ["conversation_id", "conversationId", "thread_id", "threadId"]
312 .iter()
313 .filter_map(|key| request.get(*key).and_then(Value::as_str))
314 .find(|value| !value.is_empty())
315 .or_else(|| request.pointer("/conversation/id").and_then(Value::as_str))
316 .filter(|value| !value.is_empty())
317 .map(str::to_string)
318}
319
320async fn post_otlp(
322 client: &Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>>,
323 url: &str,
324 payload: Value,
325) -> Result<(), AnalyzerError> {
326 let body = serde_json::to_vec(&payload)?;
327 let req = hyper::Request::builder()
328 .method(hyper::Method::POST)
329 .uri(url)
330 .header(hyper::header::CONTENT_TYPE, "application/json")
331 .body(Full::new(Bytes::from(body)))?;
332
333 let resp = client.request(req).await?;
334 let status = resp.status();
335 if !status.is_success() {
336 let bytes = resp.into_body().collect().await?.to_bytes();
337 let text = String::from_utf8_lossy(&bytes);
338 return Err(format!("collector returned {}: {}", status, text.trim()).into());
339 }
340 Ok(())
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn maps_providers() {
349 assert_eq!(provider_from_host("api.openai.com"), "openai");
350 assert_eq!(provider_from_host("api.anthropic.com"), "anthropic");
351 assert_eq!(
352 provider_from_host("generativelanguage.googleapis.com"),
353 "gcp.gen_ai"
354 );
355 assert_eq!(
356 provider_from_host("my-resource.openai.azure.com"),
357 "azure.ai.openai"
358 );
359 assert_eq!(provider_from_host("localhost:8443"), "localhost:8443");
361 }
362
363 #[test]
364 fn parses_usage_both_shapes() {
365 let openai = json!({ "usage": { "prompt_tokens": 12, "completion_tokens": 7 } });
366 assert_eq!(
367 usage_int(&openai["usage"], &["input_tokens", "prompt_tokens"]),
368 Some(12)
369 );
370 assert_eq!(
371 usage_int(&openai["usage"], &["output_tokens", "completion_tokens"]),
372 Some(7)
373 );
374
375 let anthropic = json!({ "usage": { "input_tokens": 30, "output_tokens": 15 } });
376 assert_eq!(
377 usage_int(&anthropic["usage"], &["input_tokens", "prompt_tokens"]),
378 Some(30)
379 );
380 }
381
382 #[test]
383 fn extracts_finish_reasons() {
384 let openai = json!({ "choices": [{ "finish_reason": "stop" }] });
385 assert_eq!(finish_reasons(&openai), vec!["stop".to_string()]);
386 let anthropic = json!({ "stop_reason": "end_turn" });
387 assert_eq!(finish_reasons(&anthropic), vec!["end_turn".to_string()]);
388 let none = json!({ "foo": 1 });
389 assert!(finish_reasons(&none).is_empty());
390 }
391
392 #[test]
393 fn builds_payload_with_gen_ai_attributes() {
394 let req = SpanInput {
395 start_unix_nano: 1_000_000_000,
396 provider: "openai".to_string(),
397 server_address: "api.openai.com".to_string(),
398 model: Some("gpt-4o".to_string()),
399 conversation_id: Some("conv_123".to_string()),
400 max_tokens: Some(256),
401 temperature: Some(0.7),
402 top_p: None,
403 input_messages: None,
404 };
405 let response = json!({
406 "model": "gpt-4o-2024",
407 "usage": { "prompt_tokens": 10, "completion_tokens": 5 },
408 "choices": [{ "finish_reason": "stop" }]
409 });
410 let payload = build_otlp_payload(
411 "agentsight",
412 "0123456789abcdef0123456789abcdef",
413 "0123456789abcdef",
414 &req,
415 2_000_000_000,
416 Some(200),
417 Some(&response),
418 false,
419 );
420
421 let span = &payload["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
422 assert_eq!(span["name"], "chat gpt-4o");
423 assert_eq!(span["kind"], 3);
424 assert_eq!(span["startTimeUnixNano"], "1000000000");
425 assert_eq!(span["endTimeUnixNano"], "2000000000");
426
427 let attrs = span["attributes"].as_array().unwrap();
428 let find = |k: &str| attrs.iter().find(|a| a["key"] == k).cloned();
429 assert_eq!(
430 find("gen_ai.operation.name").unwrap()["value"]["stringValue"],
431 "chat"
432 );
433 assert_eq!(
434 find("gen_ai.provider.name").unwrap()["value"]["stringValue"],
435 "openai"
436 );
437 assert_eq!(
438 find("gen_ai.request.model").unwrap()["value"]["stringValue"],
439 "gpt-4o"
440 );
441 assert_eq!(
442 find("gen_ai.conversation.id").unwrap()["value"]["stringValue"],
443 "conv_123"
444 );
445 assert_eq!(
446 conversation_id_from_request(&json!({ "conversation": { "id": "conv_123" } }))
447 .as_deref(),
448 Some("conv_123")
449 );
450 assert_eq!(
451 conversation_id_from_request(&json!({ "session_id": "sid_123" })),
452 None
453 );
454 assert_eq!(
455 find("gen_ai.request.max_tokens").unwrap()["value"]["intValue"],
456 "256"
457 );
458 assert_eq!(
459 find("gen_ai.usage.input_tokens").unwrap()["value"]["intValue"],
460 "10"
461 );
462 assert_eq!(
463 find("gen_ai.usage.output_tokens").unwrap()["value"]["intValue"],
464 "5"
465 );
466 assert_eq!(span["status"]["code"], 1);
467 assert!(find("gen_ai.input.messages").is_none());
469 }
470
471 #[test]
472 fn error_status_marks_span_error() {
473 let req = SpanInput {
474 start_unix_nano: 1,
475 provider: "openai".to_string(),
476 server_address: "api.openai.com".to_string(),
477 model: Some("gpt-4o".to_string()),
478 conversation_id: None,
479 max_tokens: None,
480 temperature: None,
481 top_p: None,
482 input_messages: None,
483 };
484 let payload = build_otlp_payload("agentsight", "t", "s", &req, 2, Some(429), None, false);
485 let span = &payload["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
486 assert_eq!(span["status"]["code"], 2);
487 }
488}