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::collections::HashMap;
29use std::sync::Arc;
30
31const DEFAULT_OTLP_ENDPOINT: &str = "http://localhost:4318";
33
34#[derive(Clone)]
35struct SpanInput {
36 start_unix_nano: u128,
37 provider: String,
38 server_address: String,
39 model: Option<String>,
40 conversation_id: Option<String>,
41 max_tokens: Option<i64>,
42 temperature: Option<f64>,
43 top_p: Option<f64>,
44 input_messages: Option<String>,
46}
47
48pub struct OtelExporter {
50 traces_url: String,
52 service_name: String,
54 capture_content: bool,
56 trace_ids: HashMap<String, String>,
58 fallback_trace_id: String,
60 client: Arc<Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>>>,
61}
62
63impl OtelExporter {
64 pub fn new(endpoint: Option<String>, capture_content: bool) -> Self {
69 let traces_url = if let Ok(full) = std::env::var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") {
70 full
71 } else {
72 let base = endpoint
73 .or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
74 .unwrap_or_else(|| DEFAULT_OTLP_ENDPOINT.to_string());
75 format!("{}/v1/traces", base.trim_end_matches('/'))
76 };
77
78 let service_name =
79 std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "agentsight".to_string());
80
81 Self {
82 traces_url,
83 service_name,
84 capture_content,
85 trace_ids: HashMap::new(),
86 fallback_trace_id: new_trace_id(),
87 client: Arc::new(Client::builder(TokioExecutor::new()).build_http()),
88 }
89 }
90
91 fn trace_id_for(&mut self, conversation_id: Option<&str>, session_id: Option<&str>) -> String {
92 let key = conversation_id
93 .filter(|id| !id.is_empty())
94 .map(|id| format!("conversation:{id}"))
95 .or_else(|| {
96 session_id
97 .filter(|id| !id.is_empty())
98 .map(|id| format!("session:{id}"))
99 });
100 let Some(key) = key else {
101 return self.fallback_trace_id.clone();
102 };
103 self.trace_ids
104 .entry(key)
105 .or_insert_with(new_trace_id)
106 .clone()
107 }
108}
109
110fn usage_int(usage: &Value, names: &[&str]) -> Option<i64> {
114 names
115 .iter()
116 .find_map(|n| usage.get(*n).and_then(|v| v.as_i64()))
117}
118
119fn finish_reasons(body: &Value) -> Vec<String> {
121 if let Some(choices) = body.get("choices").and_then(|c| c.as_array()) {
123 let reasons: Vec<String> = choices
124 .iter()
125 .filter_map(|c| {
126 c.get("finish_reason")
127 .and_then(|r| r.as_str())
128 .map(String::from)
129 })
130 .collect();
131 if !reasons.is_empty() {
132 return reasons;
133 }
134 }
135 if let Some(stop) = body.get("stop_reason").and_then(|v| v.as_str()) {
137 return vec![stop.to_string()];
138 }
139 Vec::new()
140}
141
142fn av_str(s: &str) -> Value {
144 json!({ "stringValue": s })
145}
146
147fn attr_str(key: &str, s: &str) -> Value {
149 json!({ "key": key, "value": av_str(s) })
150}
151
152fn attr_int(key: &str, n: i64) -> Value {
155 json!({ "key": key, "value": { "intValue": n.to_string() } })
156}
157
158fn attr_double(key: &str, n: f64) -> Value {
160 json!({ "key": key, "value": { "doubleValue": n } })
161}
162
163fn attr_str_array(key: &str, items: &[String]) -> Value {
165 let values: Vec<Value> = items.iter().map(|s| av_str(s)).collect();
166 json!({ "key": key, "value": { "arrayValue": { "values": values } } })
167}
168
169fn build_otlp_payload(
172 service_name: &str,
173 trace_id: &str,
174 span_id: &str,
175 req: &SpanInput,
176 end_unix_nano: u128,
177 status_code: Option<u16>,
178 response_body: Option<&Value>,
179 capture_content: bool,
180) -> Value {
181 let model_name = req.model.as_deref().unwrap_or("unknown");
182
183 let mut attributes = vec![
184 attr_str("gen_ai.operation.name", "chat"),
185 attr_str("gen_ai.provider.name", &req.provider),
186 attr_str("server.address", &req.server_address),
187 ];
188 if let Some(conversation_id) = &req.conversation_id {
189 attributes.push(attr_str("gen_ai.conversation.id", conversation_id));
190 }
191 if let Some(model) = &req.model {
192 attributes.push(attr_str("gen_ai.request.model", model));
193 }
194 if let Some(mt) = req.max_tokens {
195 attributes.push(attr_int("gen_ai.request.max_tokens", mt));
196 }
197 if let Some(t) = req.temperature {
198 attributes.push(attr_double("gen_ai.request.temperature", t));
199 }
200 if let Some(p) = req.top_p {
201 attributes.push(attr_double("gen_ai.request.top_p", p));
202 }
203
204 let mut span_status = json!({ "code": 1 }); if let Some(body) = response_body {
207 if let Some(rmodel) = body.get("model").and_then(|v| v.as_str()) {
208 attributes.push(attr_str("gen_ai.response.model", rmodel));
209 }
210 if let Some(id) = body.get("id").and_then(|v| v.as_str()) {
211 attributes.push(attr_str("gen_ai.response.id", id));
212 }
213 if let Some(usage) = body.get("usage") {
214 if let Some(input) = usage_int(usage, &["input_tokens", "prompt_tokens"]) {
215 attributes.push(attr_int("gen_ai.usage.input_tokens", input));
216 }
217 if let Some(output) = usage_int(usage, &["output_tokens", "completion_tokens"]) {
218 attributes.push(attr_int("gen_ai.usage.output_tokens", output));
219 }
220 }
221 let reasons = finish_reasons(body);
222 if !reasons.is_empty() {
223 attributes.push(attr_str_array("gen_ai.response.finish_reasons", &reasons));
224 }
225 if capture_content {
226 attributes.push(attr_str("gen_ai.output.messages", &body.to_string()));
227 }
228 }
229
230 if let Some(code) = status_code {
232 attributes.push(attr_int("http.response.status_code", code as i64));
233 if code >= 400 {
234 span_status = json!({ "code": 2, "message": format!("HTTP {}", code) });
235 }
236 }
237
238 if capture_content && let Some(msgs) = &req.input_messages {
239 attributes.push(attr_str("gen_ai.input.messages", msgs));
240 }
241
242 json!({
243 "resourceSpans": [{
244 "resource": {
245 "attributes": [ attr_str("service.name", service_name) ]
246 },
247 "scopeSpans": [{
248 "scope": { "name": "agentsight", "version": env!("CARGO_PKG_VERSION") },
249 "spans": [{
250 "traceId": trace_id,
251 "spanId": span_id,
252 "name": format!("chat {}", model_name),
253 "kind": 3, "startTimeUnixNano": req.start_unix_nano.to_string(),
255 "endTimeUnixNano": end_unix_nano.to_string(),
256 "attributes": attributes,
257 "status": span_status
258 }]
259 }]
260 }]
261 })
262}
263
264fn new_trace_id() -> String {
265 uuid::Uuid::new_v4().simple().to_string()
266}
267
268fn new_span_id() -> String {
269 uuid::Uuid::new_v4().simple().to_string()[..16].to_string()
270}
271
272impl SpanInput {
273 fn from_call(call: &LlmCallRow, capture_content: bool) -> Self {
274 let request = &call.request;
275 let host = call.host.as_deref().unwrap_or_default();
276 Self {
277 start_unix_nano: (call.start_timestamp_ms as u128) * 1_000_000,
278 provider: call
279 .provider
280 .clone()
281 .unwrap_or_else(|| provider_from_host(host)),
282 server_address: host.to_string(),
283 conversation_id: explicit_conversation_id(request)
287 .or_else(|| explicit_conversation_id(&call.response)),
288 model: call.model.clone().or_else(|| {
289 request
290 .get("model")
291 .and_then(Value::as_str)
292 .map(String::from)
293 }),
294 max_tokens: request
295 .get("max_tokens")
296 .or_else(|| request.get("max_output_tokens"))
297 .and_then(Value::as_i64),
298 temperature: request.get("temperature").and_then(Value::as_f64),
299 top_p: request.get("top_p").and_then(Value::as_f64),
300 input_messages: capture_content
301 .then(|| {
302 request
303 .get("messages")
304 .or_else(|| request.get("input"))
305 .map(Value::to_string)
306 })
307 .flatten(),
308 }
309 }
310}
311
312impl ViewSink for OtelExporter {
313 fn llm_call(&mut self, call: &LlmCallRow) -> ViewResult<()> {
314 let Some(end_ms) = call.end_timestamp_ms else {
315 return Ok(());
316 };
317 let span_input = SpanInput::from_call(call, self.capture_content);
318 let trace_id = self.trace_id_for(
319 span_input.conversation_id.as_deref(),
320 call.session_id.as_deref(),
321 );
322 let span_id = new_span_id();
323 let payload = build_otlp_payload(
324 &self.service_name,
325 &trace_id,
326 &span_id,
327 &span_input,
328 (end_ms as u128) * 1_000_000,
329 call.status_code,
330 Some(&call.response),
331 self.capture_content,
332 );
333 let client = self.client.clone();
334 let url = self.traces_url.clone();
335 tokio::spawn(async move {
336 if let Err(e) = post_otlp(&client, &url, payload).await {
337 log::warn!("OtelExporter: failed to export span: {}", e);
338 }
339 });
340 Ok(())
341 }
342}
343
344fn explicit_conversation_id(body: &Value) -> Option<String> {
345 [
346 "/conversation_id",
347 "/conversationId",
348 "/thread_id",
349 "/threadId",
350 "/metadata/conversation_id",
351 "/metadata/conversationId",
352 "/metadata/thread_id",
353 "/metadata/threadId",
354 "/conversation/id",
355 "/thread/id",
356 "/response/conversation_id",
357 "/response/conversationId",
358 "/response/thread_id",
359 "/response/threadId",
360 "/response/conversation/id",
361 "/response/thread/id",
362 ]
363 .iter()
364 .filter_map(|path| body.pointer(path).and_then(Value::as_str))
365 .find(|value| !value.is_empty())
366 .map(str::to_string)
367}
368
369async fn post_otlp(
371 client: &Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>>,
372 url: &str,
373 payload: Value,
374) -> Result<(), AnalyzerError> {
375 let body = serde_json::to_vec(&payload)?;
376 let req = hyper::Request::builder()
377 .method(hyper::Method::POST)
378 .uri(url)
379 .header(hyper::header::CONTENT_TYPE, "application/json")
380 .body(Full::new(Bytes::from(body)))?;
381
382 let resp = client.request(req).await?;
383 let status = resp.status();
384 if !status.is_success() {
385 let bytes = resp.into_body().collect().await?.to_bytes();
386 let text = String::from_utf8_lossy(&bytes);
387 return Err(format!("collector returned {}: {}", status, text.trim()).into());
388 }
389 Ok(())
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn completed_call(
397 id: &str,
398 session_id: Option<&str>,
399 conversation_id: Option<&str>,
400 request: Value,
401 ) -> LlmCallRow {
402 LlmCallRow {
403 id: id.to_string(),
404 session_id: session_id.map(str::to_string),
405 conversation_id: conversation_id.map(str::to_string),
406 start_timestamp_ms: 1,
407 end_timestamp_ms: Some(2),
408 pid: Some(1),
409 comm: Some("agent".to_string()),
410 provider: Some("openai".to_string()),
411 model: Some("model".to_string()),
412 call_kind: Some("chat".to_string()),
413 status: "complete".to_string(),
414 error_type: None,
415 finish_reason: None,
416 host: Some("api.openai.com".to_string()),
417 path: Some("/v1/chat/completions".to_string()),
418 status_code: Some(200),
419 input_tokens: 0,
420 output_tokens: 0,
421 total_tokens: 0,
422 request,
423 response: Value::Null,
424 }
425 }
426
427 #[test]
428 fn maps_providers() {
429 assert_eq!(provider_from_host("api.openai.com"), "openai");
430 assert_eq!(provider_from_host("api.anthropic.com"), "anthropic");
431 assert_eq!(
432 provider_from_host("generativelanguage.googleapis.com"),
433 "gcp.gen_ai"
434 );
435 assert_eq!(
436 provider_from_host("my-resource.openai.azure.com"),
437 "azure.ai.openai"
438 );
439 assert_eq!(provider_from_host("localhost:8443"), "localhost:8443");
441 }
442
443 #[test]
444 fn parses_usage_both_shapes() {
445 let openai = json!({ "usage": { "prompt_tokens": 12, "completion_tokens": 7 } });
446 assert_eq!(
447 usage_int(&openai["usage"], &["input_tokens", "prompt_tokens"]),
448 Some(12)
449 );
450 assert_eq!(
451 usage_int(&openai["usage"], &["output_tokens", "completion_tokens"]),
452 Some(7)
453 );
454
455 let anthropic = json!({ "usage": { "input_tokens": 30, "output_tokens": 15 } });
456 assert_eq!(
457 usage_int(&anthropic["usage"], &["input_tokens", "prompt_tokens"]),
458 Some(30)
459 );
460 }
461
462 #[test]
463 fn extracts_finish_reasons() {
464 let openai = json!({ "choices": [{ "finish_reason": "stop" }] });
465 assert_eq!(finish_reasons(&openai), vec!["stop".to_string()]);
466 let anthropic = json!({ "stop_reason": "end_turn" });
467 assert_eq!(finish_reasons(&anthropic), vec!["end_turn".to_string()]);
468 let none = json!({ "foo": 1 });
469 assert!(finish_reasons(&none).is_empty());
470 }
471
472 #[test]
473 fn builds_payload_with_gen_ai_attributes() {
474 let req = SpanInput {
475 start_unix_nano: 1_000_000_000,
476 provider: "openai".to_string(),
477 server_address: "api.openai.com".to_string(),
478 model: Some("gpt-4o".to_string()),
479 conversation_id: Some("conv_123".to_string()),
480 max_tokens: Some(256),
481 temperature: Some(0.7),
482 top_p: None,
483 input_messages: None,
484 };
485 let response = json!({
486 "model": "gpt-4o-2024",
487 "usage": { "prompt_tokens": 10, "completion_tokens": 5 },
488 "choices": [{ "finish_reason": "stop" }]
489 });
490 let payload = build_otlp_payload(
491 "agentsight",
492 "0123456789abcdef0123456789abcdef",
493 "0123456789abcdef",
494 &req,
495 2_000_000_000,
496 Some(200),
497 Some(&response),
498 false,
499 );
500
501 let span = &payload["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
502 assert_eq!(span["name"], "chat gpt-4o");
503 assert_eq!(span["kind"], 3);
504 assert_eq!(span["startTimeUnixNano"], "1000000000");
505 assert_eq!(span["endTimeUnixNano"], "2000000000");
506
507 let attrs = span["attributes"].as_array().unwrap();
508 let find = |k: &str| attrs.iter().find(|a| a["key"] == k).cloned();
509 assert_eq!(
510 find("gen_ai.operation.name").unwrap()["value"]["stringValue"],
511 "chat"
512 );
513 assert_eq!(
514 find("gen_ai.provider.name").unwrap()["value"]["stringValue"],
515 "openai"
516 );
517 assert_eq!(
518 find("gen_ai.request.model").unwrap()["value"]["stringValue"],
519 "gpt-4o"
520 );
521 assert_eq!(
522 find("gen_ai.conversation.id").unwrap()["value"]["stringValue"],
523 "conv_123"
524 );
525 assert_eq!(
526 explicit_conversation_id(&json!({ "conversation": { "id": "conv_123" } })).as_deref(),
527 Some("conv_123")
528 );
529 assert_eq!(
530 explicit_conversation_id(&json!({ "session_id": "sid_123" })),
531 None
532 );
533 assert_eq!(
534 find("gen_ai.request.max_tokens").unwrap()["value"]["intValue"],
535 "256"
536 );
537 assert_eq!(
538 find("gen_ai.usage.input_tokens").unwrap()["value"]["intValue"],
539 "10"
540 );
541 assert_eq!(
542 find("gen_ai.usage.output_tokens").unwrap()["value"]["intValue"],
543 "5"
544 );
545 assert_eq!(span["status"]["code"], 1);
546 assert!(find("gen_ai.input.messages").is_none());
548 }
549
550 #[test]
551 fn error_status_marks_span_error() {
552 let req = SpanInput {
553 start_unix_nano: 1,
554 provider: "openai".to_string(),
555 server_address: "api.openai.com".to_string(),
556 model: Some("gpt-4o".to_string()),
557 conversation_id: None,
558 max_tokens: None,
559 temperature: None,
560 top_p: None,
561 input_messages: None,
562 };
563 let payload = build_otlp_payload("agentsight", "t", "s", &req, 2, Some(429), None, false);
564 let span = &payload["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
565 assert_eq!(span["status"]["code"], 2);
566 }
567
568 #[test]
569 fn correlates_trace_ids_by_explicit_conversation_then_session() {
570 let mut exporter = OtelExporter::new(Some("http://localhost:4318".to_string()), false);
571
572 let conversation_a = exporter.trace_id_for(Some("conversation-a"), Some("session-a"));
573 assert_eq!(
574 conversation_a,
575 exporter.trace_id_for(Some("conversation-a"), Some("session-b"))
576 );
577 assert_ne!(
578 conversation_a,
579 exporter.trace_id_for(Some("conversation-b"), Some("session-a"))
580 );
581
582 let session_a = exporter.trace_id_for(None, Some("session-a"));
583 assert_eq!(session_a, exporter.trace_id_for(None, Some("session-a")));
584 assert_ne!(session_a, exporter.trace_id_for(None, Some("session-b")));
585 }
586
587 #[test]
588 fn per_response_conversation_ids_do_not_split_one_session() {
589 let mut exporter = OtelExporter::new(Some("http://localhost:4318".to_string()), false);
590 let first = completed_call("first", Some("session-a"), Some("chatcmpl-a"), json!({}));
591 let second = completed_call("second", Some("session-a"), Some("chatcmpl-b"), json!({}));
592
593 let first_input = SpanInput::from_call(&first, false);
594 let second_input = SpanInput::from_call(&second, false);
595 assert!(first_input.conversation_id.is_none());
596 assert_eq!(
597 exporter.trace_id_for(
598 first_input.conversation_id.as_deref(),
599 first.session_id.as_deref()
600 ),
601 exporter.trace_id_for(
602 second_input.conversation_id.as_deref(),
603 second.session_id.as_deref()
604 )
605 );
606 }
607
608 #[test]
609 fn explicit_conversation_overrides_different_sessions() {
610 let mut exporter = OtelExporter::new(Some("http://localhost:4318".to_string()), false);
611 let first = completed_call(
612 "first",
613 Some("session-a"),
614 None,
615 json!({ "metadata": { "conversation_id": "conversation-a" } }),
616 );
617 let mut second = completed_call("second", Some("session-b"), None, json!({}));
618 second.response = json!({ "thread": { "id": "conversation-a" } });
619
620 let first_input = SpanInput::from_call(&first, false);
621 let second_input = SpanInput::from_call(&second, false);
622 assert_eq!(
623 first_input.conversation_id.as_deref(),
624 Some("conversation-a")
625 );
626 assert_eq!(first_input.conversation_id, second_input.conversation_id);
627 assert_eq!(
628 exporter.trace_id_for(
629 first_input.conversation_id.as_deref(),
630 first.session_id.as_deref()
631 ),
632 exporter.trace_id_for(
633 second_input.conversation_id.as_deref(),
634 second.session_id.as_deref()
635 )
636 );
637 }
638
639 #[test]
640 fn uses_one_recording_trace_without_correlation_ids() {
641 let mut exporter = OtelExporter::new(Some("http://localhost:4318".to_string()), false);
642 assert_eq!(
643 exporter.trace_id_for(None, None),
644 exporter.trace_id_for(None, None)
645 );
646 assert_ne!(new_span_id(), new_span_id());
647 }
648}