1use std::time::{SystemTime, UNIX_EPOCH};
18
19pub fn now_unix_nanos() -> u128 {
21 SystemTime::now()
22 .duration_since(UNIX_EPOCH)
23 .map(|d| d.as_nanos())
24 .unwrap_or(0)
25}
26
27pub struct RunSpan {
33 #[cfg(feature = "otel")]
34 inner: Option<imp::RunSpan>,
35}
36
37pub fn run_begin(trace_id: Option<&str>, start_unix_nanos: u128) -> RunSpan {
40 #[cfg(feature = "otel")]
41 {
42 RunSpan {
43 inner: imp::RunSpan::begin(trace_id, start_unix_nanos),
44 }
45 }
46 #[cfg(not(feature = "otel"))]
47 {
48 let _ = (trace_id, start_unix_nanos);
49 RunSpan {}
50 }
51}
52
53impl RunSpan {
54 pub fn record_chat(
56 &mut self,
57 model: &str,
58 input_tokens: u64,
59 output_tokens: u64,
60 ok: bool,
61 start_unix_nanos: u128,
62 ) {
63 #[cfg(feature = "otel")]
64 if let Some(i) = self.inner.as_mut() {
65 i.record_chat(model, input_tokens, output_tokens, ok, start_unix_nanos);
66 }
67 #[cfg(not(feature = "otel"))]
68 let _ = (model, input_tokens, output_tokens, ok, start_unix_nanos);
69 }
70
71 pub fn record_tool(&mut self, tool_name: &str, ok: bool, start_unix_nanos: u128) {
73 #[cfg(feature = "otel")]
74 if let Some(i) = self.inner.as_mut() {
75 i.record_tool(tool_name, ok, start_unix_nanos);
76 }
77 #[cfg(not(feature = "otel"))]
78 let _ = (tool_name, ok, start_unix_nanos);
79 }
80
81 pub fn finish(self, model: &str, input_tokens: u64, output_tokens: u64, ok: bool) {
84 #[cfg(feature = "otel")]
85 if let Some(i) = self.inner {
86 i.finish(model, input_tokens, output_tokens, ok);
87 }
88 #[cfg(not(feature = "otel"))]
89 let _ = (model, input_tokens, output_tokens, ok);
90 }
91}
92
93pub fn capture_log(unix_nanos: u128, level: &str, event: &str, fields: &serde_json::Value) {
96 #[cfg(feature = "otel")]
97 imp::capture_log(unix_nanos, level, event, fields);
98 #[cfg(not(feature = "otel"))]
99 let _ = (unix_nanos, level, event, fields);
100}
101
102pub fn arm_logs(endpoint: &str, service: &str, version: &str) {
105 #[cfg(feature = "otel")]
106 imp::arm_logs(endpoint, service, version);
107 #[cfg(not(feature = "otel"))]
108 let _ = (endpoint, service, version);
109}
110
111#[cfg(feature = "otel")]
112mod imp {
113 use crate::net::http::{self, Url};
114 use serde_json::{Value, json};
115 use std::time::Duration;
116
117 pub(super) struct Span {
119 pub trace_id: String,
120 pub span_id: String,
121 pub parent_span_id: Option<String>,
122 pub name: String,
123 pub start_unix_nanos: u128,
124 pub end_unix_nanos: u128,
125 pub ok: bool,
126 pub attrs: Vec<(&'static str, Value)>,
127 }
128
129 pub(super) struct RunSpan {
132 trace_id: String,
133 span_id: String,
134 start_unix_nanos: u128,
135 endpoint: String,
136 children: Vec<Span>,
137 }
138
139 pub(super) fn str_val(s: impl Into<String>) -> Value {
141 json!({ "stringValue": s.into() })
142 }
143
144 pub(super) fn int_val(n: u64) -> Value {
146 json!({ "intValue": n.to_string() })
147 }
148
149 impl RunSpan {
150 pub(super) fn begin(trace_id: Option<&str>, start_unix_nanos: u128) -> Option<RunSpan> {
152 let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
153 .ok()
154 .filter(|s| !s.is_empty())?;
155 let trace_id = trace_id?.to_string();
156 Some(RunSpan {
157 span_id: crate::obs::trace::new_span_id(),
158 trace_id,
159 start_unix_nanos,
160 endpoint,
161 children: Vec::new(),
162 })
163 }
164
165 pub(super) fn record_chat(
166 &mut self,
167 model: &str,
168 input_tokens: u64,
169 output_tokens: u64,
170 ok: bool,
171 start_unix_nanos: u128,
172 ) {
173 self.children.push(Span {
174 trace_id: self.trace_id.clone(),
175 span_id: crate::obs::trace::new_span_id(),
176 parent_span_id: Some(self.span_id.clone()),
177 name: "chat".into(),
178 start_unix_nanos,
179 end_unix_nanos: super::now_unix_nanos(),
180 ok,
181 attrs: vec![
182 ("gen_ai.operation.name", str_val("chat")),
183 ("gen_ai.request.model", str_val(model)),
184 ("gen_ai.usage.input_tokens", int_val(input_tokens)),
185 ("gen_ai.usage.output_tokens", int_val(output_tokens)),
186 ],
187 });
188 }
189
190 pub(super) fn record_tool(&mut self, tool_name: &str, ok: bool, start_unix_nanos: u128) {
191 self.children.push(Span {
192 trace_id: self.trace_id.clone(),
193 span_id: crate::obs::trace::new_span_id(),
194 parent_span_id: Some(self.span_id.clone()),
195 name: "execute_tool".into(),
196 start_unix_nanos,
197 end_unix_nanos: super::now_unix_nanos(),
198 ok,
199 attrs: vec![
200 ("gen_ai.operation.name", str_val("execute_tool")),
201 ("gen_ai.tool.name", str_val(tool_name)),
202 ],
203 });
204 }
205
206 pub(super) fn finish(
208 mut self,
209 model: &str,
210 input_tokens: u64,
211 output_tokens: u64,
212 ok: bool,
213 ) {
214 let run = Span {
215 trace_id: self.trace_id.clone(),
216 span_id: self.span_id.clone(),
217 parent_span_id: None,
218 name: "invoke_agent".into(),
219 start_unix_nanos: self.start_unix_nanos,
220 end_unix_nanos: super::now_unix_nanos(),
221 ok,
222 attrs: vec![
223 ("gen_ai.operation.name", str_val("invoke_agent")),
224 ("gen_ai.request.model", str_val(model)),
225 ("gen_ai.usage.input_tokens", int_val(input_tokens)),
226 ("gen_ai.usage.output_tokens", int_val(output_tokens)),
227 ],
228 };
229 self.children.push(run);
230 let _ = export(
232 &self.endpoint,
233 &to_otlp_json(&self.children, "agentd", crate::VERSION),
234 );
235 }
236 }
237
238 pub(super) fn to_otlp_json(spans: &[Span], service: &str, version: &str) -> Value {
240 let encoded: Vec<Value> = spans.iter().map(encode_span).collect();
241 json!({
242 "resourceSpans": [{
243 "resource": { "attributes": [
244 { "key": "service.name", "value": str_val(service) },
245 { "key": "service.version", "value": str_val(version) },
246 ]},
247 "scopeSpans": [{ "scope": { "name": "agentd" }, "spans": encoded }]
248 }]
249 })
250 }
251
252 fn encode_span(s: &Span) -> Value {
253 let attrs: Vec<Value> = s
254 .attrs
255 .iter()
256 .map(|(k, v)| json!({ "key": k, "value": v }))
257 .collect();
258 let mut span = json!({
259 "traceId": s.trace_id,
260 "spanId": s.span_id,
261 "name": s.name,
262 "kind": 1, "startTimeUnixNano": s.start_unix_nanos.to_string(),
264 "endTimeUnixNano": s.end_unix_nanos.to_string(),
265 "status": { "code": if s.ok { 1 } else { 2 } }, "attributes": attrs,
267 });
268 if let Some(p) = &s.parent_span_id {
269 span["parentSpanId"] = json!(p);
270 }
271 span
272 }
273
274 fn export(endpoint: &str, body: &Value) -> Result<(), String> {
277 let base = endpoint.trim_end_matches('/');
278 let target = if base.ends_with("/v1/traces") {
279 base.to_string()
280 } else {
281 format!("{base}/v1/traces")
282 };
283 let url = Url::parse(&target).map_err(|e| format!("otel: bad endpoint '{target}': {e}"))?;
284 if url.is_tls() {
285 return Err("otel: https OTLP endpoints need --features tls".into());
286 }
287 let bytes = serde_json::to_vec(body).map_err(|e| e.to_string())?;
288 let mut stream = http::connect_tcp(&url.host, url.port, Duration::from_secs(5))
289 .map_err(|e| e.to_string())?;
290 let headers = [("content-type", "application/json")];
291 let resp = http::send(
292 &mut stream,
293 &url.host_header(),
294 "POST",
295 &url.path,
296 &headers,
297 &bytes,
298 )
299 .map_err(|e| e.to_string())?;
300 if resp.is_success() {
301 Ok(())
302 } else {
303 Err(format!("otel: collector returned HTTP {}", resp.status))
304 }
305 }
306
307 use std::sync::{Mutex, OnceLock};
312
313 static LOGS: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
314
315 pub(super) fn capture_log(unix_nanos: u128, level: &str, event: &str, fields: &Value) {
318 let Some(buf) = LOGS.get() else { return };
319 let mut b = buf.lock().unwrap_or_else(|e| e.into_inner());
320 if b.len() >= 8192 {
321 return;
322 }
323 b.push(json!({
324 "timeUnixNano": unix_nanos.to_string(),
325 "severityText": level.to_ascii_uppercase(),
326 "body": { "stringValue": event },
327 "attributes": [ { "key": "log.fields", "value": { "stringValue": fields.to_string() } } ],
328 }));
329 }
330
331 pub(super) fn arm_logs(endpoint: &str, service: &str, version: &str) {
334 if LOGS.set(Mutex::new(Vec::new())).is_err() {
335 return; }
337 let (endpoint, service, version) = (
338 endpoint.to_string(),
339 service.to_string(),
340 version.to_string(),
341 );
342 std::thread::Builder::new()
343 .name("otel-logs".into())
344 .spawn(move || {
345 loop {
346 std::thread::sleep(Duration::from_secs(5));
347 let batch = {
348 let mut b = LOGS
349 .get()
350 .expect("armed")
351 .lock()
352 .unwrap_or_else(|e| e.into_inner());
353 std::mem::take(&mut *b)
354 };
355 if batch.is_empty() {
356 continue;
357 }
358 let body = to_otlp_logs_json(&batch, &service, &version);
359 let _ = export_signal(&endpoint, "logs", &body);
360 }
361 })
362 .ok();
363 }
364
365 pub(super) fn to_otlp_logs_json(records: &[Value], service: &str, version: &str) -> Value {
366 json!({
367 "resourceLogs": [{
368 "resource": { "attributes": [
369 { "key": "service.name", "value": { "stringValue": service } },
370 { "key": "service.version", "value": { "stringValue": version } },
371 ]},
372 "scopeLogs": [{ "scope": { "name": "agentd" }, "logRecords": records }]
373 }]
374 })
375 }
376
377 fn export_signal(endpoint: &str, signal: &str, body: &Value) -> Result<(), String> {
379 let base = endpoint.trim_end_matches('/');
380 let suffix = format!("/v1/{signal}");
381 let target = if base.ends_with(&suffix) {
382 base.to_string()
383 } else {
384 format!("{base}{suffix}")
385 };
386 let url = Url::parse(&target).map_err(|e| format!("otel: bad endpoint '{target}': {e}"))?;
387 if url.is_tls() {
388 return Err("otel: https OTLP endpoints need --features tls".into());
389 }
390 let bytes = serde_json::to_vec(body).map_err(|e| e.to_string())?;
391 let mut stream = http::connect_tcp(&url.host, url.port, Duration::from_secs(5))
392 .map_err(|e| e.to_string())?;
393 let headers = [("content-type", "application/json")];
394 let resp = http::send(
395 &mut stream,
396 &url.host_header(),
397 "POST",
398 &url.path,
399 &headers,
400 &bytes,
401 )
402 .map_err(|e| e.to_string())?;
403 if resp.is_success() {
404 Ok(())
405 } else {
406 Err(format!("otel: collector returned HTTP {}", resp.status))
407 }
408 }
409
410 #[cfg(test)]
411 mod tests {
412 use super::*;
413
414 fn span() -> Span {
415 Span {
416 trace_id: "4bf92f3577b34da6a3ce929d0e0e4736".into(),
417 span_id: "00f067aa0ba902b7".into(),
418 parent_span_id: None,
419 name: "invoke_agent".into(),
420 start_unix_nanos: 1_700_000_000_000_000_000,
421 end_unix_nanos: 1_700_000_001_000_000_000,
422 ok: true,
423 attrs: vec![
424 ("gen_ai.operation.name", str_val("invoke_agent")),
425 ("gen_ai.usage.input_tokens", int_val(1234)),
426 ],
427 }
428 }
429
430 #[test]
431 fn otlp_json_has_the_expected_shape() {
432 let v = to_otlp_json(&[span()], "agentd", "0.1.0");
433 let rs = &v["resourceSpans"][0];
434 assert_eq!(
435 rs["resource"]["attributes"][0]["value"]["stringValue"],
436 "agentd"
437 );
438 let sp = &rs["scopeSpans"][0]["spans"][0];
439 assert_eq!(sp["traceId"], "4bf92f3577b34da6a3ce929d0e0e4736");
440 assert_eq!(sp["name"], "invoke_agent");
441 assert_eq!(sp["status"]["code"], 1); assert_eq!(sp["startTimeUnixNano"], "1700000000000000000"); assert_eq!(sp["attributes"][1]["value"]["intValue"], "1234"); assert!(sp.get("parentSpanId").is_none());
445 }
446
447 #[test]
448 fn error_span_sets_status_error_and_parent() {
449 let mut s = span();
450 s.ok = false;
451 s.parent_span_id = Some("aaaaaaaaaaaaaaaa".into());
452 let sp = to_otlp_json(&[s], "agentd", "0.1.0");
453 let sp = &sp["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
454 assert_eq!(sp["status"]["code"], 2); assert_eq!(sp["parentSpanId"], "aaaaaaaaaaaaaaaa");
456 }
457
458 #[test]
459 fn a_run_records_chat_and_tool_children_under_the_run_span() {
460 let mut run = RunSpan {
461 trace_id: "4bf92f3577b34da6a3ce929d0e0e4736".into(),
462 span_id: "00f067aa0ba902b7".into(),
463 start_unix_nanos: 1_700_000_000_000_000_000,
464 endpoint: "http://127.0.0.1:4318".into(),
465 children: Vec::new(),
466 };
467 run.record_chat("m", 10, 20, true, 1_700_000_000_000_000_000);
468 run.record_tool("resource.read", true, 1_700_000_000_500_000_000);
469 assert_eq!(run.children.len(), 2);
470
471 let mut batch = std::mem::take(&mut run.children);
474 batch.push(Span {
475 trace_id: run.trace_id.clone(),
476 span_id: run.span_id.clone(),
477 parent_span_id: None,
478 name: "invoke_agent".into(),
479 start_unix_nanos: run.start_unix_nanos,
480 end_unix_nanos: 1_700_000_001_000_000_000,
481 ok: true,
482 attrs: vec![],
483 });
484 let v = to_otlp_json(&batch, "agentd", "0.1.0");
485 let spans = &v["resourceSpans"][0]["scopeSpans"][0]["spans"];
486 assert_eq!(spans[0]["name"], "chat");
487 assert_eq!(spans[0]["parentSpanId"], "00f067aa0ba902b7");
488 assert_eq!(spans[0]["attributes"][1]["value"]["stringValue"], "m");
489 assert_eq!(spans[1]["name"], "execute_tool");
490 assert_eq!(
491 spans[1]["attributes"][1]["value"]["stringValue"],
492 "resource.read"
493 );
494 assert_eq!(spans[1]["parentSpanId"], "00f067aa0ba902b7");
495 assert_eq!(spans[2]["name"], "invoke_agent");
496 assert!(spans[2].get("parentSpanId").is_none()); assert_eq!(spans[0]["traceId"], spans[2]["traceId"]);
499 }
500
501 #[test]
502 fn otlp_logs_json_has_the_expected_shape() {
503 let rec = json!({
504 "timeUnixNano": "1700000000000000000",
505 "severityText": "INFO",
506 "body": { "stringValue": "turn.start" },
507 "attributes": [ { "key": "log.fields", "value": { "stringValue": "{}" } } ],
508 });
509 let v = to_otlp_logs_json(&[rec], "agentd", "2.0.0");
510 let rl = &v["resourceLogs"][0];
511 assert_eq!(
512 rl["resource"]["attributes"][0]["value"]["stringValue"],
513 "agentd"
514 );
515 let lr = &rl["scopeLogs"][0]["logRecords"][0];
516 assert_eq!(lr["body"]["stringValue"], "turn.start");
517 assert_eq!(lr["severityText"], "INFO");
518 assert_eq!(lr["timeUnixNano"], "1700000000000000000");
519 }
520 }
521}