Skip to main content

adk_telemetry/
span_exporter.rs

1use std::collections::HashMap;
2use std::sync::{Arc, RwLock};
3use tracing::{Id, Subscriber, debug};
4use tracing_subscriber::{Layer, layer::Context, registry::LookupSpan};
5
6/// Destination for spans captured by [`AdkSpanLayer`].
7///
8/// Implemented by [`AdkSpanExporter`] (in-memory, queried by the server debug
9/// routes) and, with the `sqlite` feature, by
10/// `SqliteSpanExporter` (persistent,
11/// zero-infrastructure tracing). Implementations decide which spans to keep —
12/// the layer forwards every closed span.
13pub trait SpanSink: Send + Sync {
14    /// Receive one closed span with its collected attributes.
15    fn export_span(&self, span_name: &str, attributes: HashMap<String, String>);
16}
17
18/// ADK-Go style span exporter that stores spans by event_id
19/// Follows the pattern from APIServerSpanExporter in ADK-Go
20#[derive(Debug, Clone, Default)]
21pub struct AdkSpanExporter {
22    /// Map of event_id -> span attributes (following ADK-Go pattern)
23    trace_dict: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
24}
25
26impl AdkSpanExporter {
27    pub fn new() -> Self {
28        Self { trace_dict: Arc::new(RwLock::new(HashMap::new())) }
29    }
30
31    /// Get trace dict (following ADK-Go GetTraceDict method)
32    pub fn get_trace_dict(&self) -> HashMap<String, HashMap<String, String>> {
33        self.trace_dict.read().unwrap_or_else(|e| e.into_inner()).clone()
34    }
35
36    /// Get trace by event_id (following ADK-Go pattern)
37    pub fn get_trace_by_event_id(&self, event_id: &str) -> Option<HashMap<String, String>> {
38        debug!("AdkSpanExporter::get_trace_by_event_id called with event_id: {}", event_id);
39        let trace_dict = self.trace_dict.read().unwrap_or_else(|e| e.into_inner());
40        let result = trace_dict.get(event_id).cloned();
41        debug!("get_trace_by_event_id result for event_id '{}': {:?}", event_id, result.is_some());
42        result
43    }
44
45    /// Get all spans for a session (by filtering spans that have matching session_id)
46    pub fn get_session_trace(&self, session_id: &str) -> Vec<HashMap<String, String>> {
47        debug!("AdkSpanExporter::get_session_trace called with session_id: {}", session_id);
48        let trace_dict = self.trace_dict.read().unwrap_or_else(|e| e.into_inner());
49
50        let mut spans = Vec::new();
51        for (_event_id, attributes) in trace_dict.iter() {
52            // Check if this span belongs to the session
53            if let Some(span_session_id) = attributes.get("gcp.vertex.agent.session_id")
54                && span_session_id == session_id
55            {
56                spans.push(attributes.clone());
57            }
58        }
59
60        debug!("get_session_trace result for session_id '{}': {} spans", session_id, spans.len());
61        spans
62    }
63}
64
65impl SpanSink for AdkSpanExporter {
66    /// Store a span in the in-memory trace dict (following ADK-Go ExportSpans
67    /// pattern). Only the agent-loop span names are kept.
68    fn export_span(&self, span_name: &str, attributes: HashMap<String, String>) {
69        // Only capture specific span names (following ADK-Go pattern)
70        if span_name == "agent.execute"
71            || span_name == "call_llm"
72            || span_name == "send_data"
73            || span_name.starts_with("execute_tool")
74        {
75            if let Some(event_id) = attributes.get("gcp.vertex.agent.event_id") {
76                debug!(
77                    "AdkSpanExporter: Storing span '{}' with event_id '{}'",
78                    span_name, event_id
79                );
80                let mut trace_dict = self.trace_dict.write().unwrap_or_else(|e| e.into_inner());
81                trace_dict.insert(event_id.clone(), attributes);
82                debug!("AdkSpanExporter: Span stored, total event_ids: {}", trace_dict.len());
83            } else {
84                debug!("AdkSpanExporter: Skipping span '{}' - no event_id found", span_name);
85            }
86        } else {
87            debug!("AdkSpanExporter: Skipping span '{}' - not in allowed list", span_name);
88        }
89    }
90}
91
92/// Tracing layer that captures spans and exports them via a [`SpanSink`]
93/// (in-memory [`AdkSpanExporter`], SQLite, or any custom sink).
94pub struct AdkSpanLayer {
95    exporter: Arc<dyn SpanSink>,
96}
97
98impl AdkSpanLayer {
99    pub fn new<S: SpanSink + 'static>(exporter: Arc<S>) -> Self {
100        Self { exporter }
101    }
102}
103
104#[derive(Clone)]
105struct SpanFields(HashMap<String, String>);
106
107#[derive(Clone)]
108struct SpanTiming {
109    start_time: std::time::Instant,
110}
111
112impl<S> Layer<S> for AdkSpanLayer
113where
114    S: Subscriber + for<'a> LookupSpan<'a>,
115{
116    fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
117        let Some(span) = ctx.span(id) else { return };
118        let mut extensions = span.extensions_mut();
119
120        // Record start time
121        extensions.insert(SpanTiming { start_time: std::time::Instant::now() });
122
123        // Capture fields
124        let mut visitor = StringVisitor::default();
125        attrs.record(&mut visitor);
126        let mut fields_map = visitor.0;
127
128        // Propagate fields from parent span (for context inheritance)
129        if let Some(parent) = span.parent()
130            && let Some(parent_fields) = parent.extensions().get::<SpanFields>()
131        {
132            let context_keys = [
133                "gcp.vertex.agent.session_id",
134                "gcp.vertex.agent.invocation_id",
135                "gcp.vertex.agent.event_id",
136                "gen_ai.conversation.id",
137                #[cfg(feature = "genai-semconv")]
138                "gen_ai.provider.name",
139                #[cfg(feature = "genai-semconv")]
140                "gen_ai.system",
141            ];
142
143            for key in context_keys {
144                if !fields_map.contains_key(key)
145                    && let Some(val) = parent_fields.0.get(key)
146                {
147                    fields_map.insert(key.to_string(), val.clone());
148                }
149            }
150        }
151
152        extensions.insert(SpanFields(fields_map));
153    }
154
155    fn on_record(&self, id: &Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>) {
156        let Some(span) = ctx.span(id) else { return };
157        let mut extensions = span.extensions_mut();
158        if let Some(fields) = extensions.get_mut::<SpanFields>() {
159            let mut visitor = StringVisitor::default();
160            values.record(&mut visitor);
161            for (k, v) in visitor.0 {
162                fields.0.insert(k, v);
163            }
164        }
165    }
166
167    fn on_close(&self, id: Id, ctx: Context<'_, S>) {
168        let Some(span) = ctx.span(&id) else { return };
169        let extensions = span.extensions();
170
171        // Calculate actual duration
172        let timing = extensions.get::<SpanTiming>();
173        let end_time = std::time::Instant::now();
174        let duration_nanos =
175            timing.map(|t| end_time.duration_since(t.start_time).as_nanos() as u64).unwrap_or(0);
176
177        // Get captured fields
178        let mut attributes =
179            extensions.get::<SpanFields>().map(|f| f.0.clone()).unwrap_or_default();
180
181        // Get span name - prefer otel.name attribute (for dynamic names), fallback to metadata
182        let metadata = span.metadata();
183        let span_name =
184            attributes.get("otel.name").cloned().unwrap_or_else(|| metadata.name().to_string());
185
186        // Add span metadata and actual timing with unique IDs
187        let now_nanos = std::time::SystemTime::now()
188            .duration_since(std::time::UNIX_EPOCH)
189            .unwrap_or_default()
190            .as_nanos() as u64;
191
192        // Use invocation_id as trace_id (for grouping in UI)
193        // Use event_id as span_id (for uniqueness)
194        let invocation_id = attributes
195            .get("gcp.vertex.agent.invocation_id")
196            .cloned()
197            .unwrap_or_else(|| format!("{:016x}", id.into_u64()));
198        let event_id = attributes
199            .get("gcp.vertex.agent.event_id")
200            .cloned()
201            .unwrap_or_else(|| format!("{:016x}", id.into_u64()));
202
203        attributes.insert("span_name".to_string(), span_name.clone());
204        attributes.insert("trace_id".to_string(), invocation_id); // Group by invocation
205        attributes.insert("span_id".to_string(), event_id); // Unique per span
206        attributes.insert("start_time".to_string(), (now_nanos - duration_nanos).to_string());
207        attributes.insert("end_time".to_string(), now_nanos.to_string());
208
209        // Don't set parent_span_id to keep all spans at same level like ADK-Go
210
211        // Export the span
212        self.exporter.export_span(&span_name, attributes);
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::sync::Arc;
220    use tracing_subscriber::layer::SubscriberExt;
221
222    #[test]
223    fn test_conversation_id_propagates_to_child_spans() {
224        let exporter = Arc::new(AdkSpanExporter::new());
225        let layer = AdkSpanLayer::new(exporter.clone());
226        let subscriber = tracing_subscriber::registry().with(layer);
227
228        tracing::subscriber::with_default(subscriber, || {
229            let parent = tracing::info_span!(
230                "agent.execute",
231                "gcp.vertex.agent.event_id" = "evt-parent",
232                "gcp.vertex.agent.invocation_id" = "inv-1",
233                "gcp.vertex.agent.session_id" = "session-1",
234                "gen_ai.conversation.id" = "session-1",
235                "agent.name" = "test-agent"
236            );
237
238            let _parent_guard = parent.enter();
239
240            let child = tracing::info_span!(
241                "call_llm",
242                "gcp.vertex.agent.event_id" = "evt-child",
243                "gcp.vertex.agent.llm_request" = "{}"
244            );
245            let _child_guard = child.enter();
246            tracing::info!("child span body");
247        });
248
249        let child_trace =
250            exporter.get_trace_by_event_id("evt-child").expect("child span should be exported");
251        assert_eq!(
252            child_trace.get("gen_ai.conversation.id").map(String::as_str),
253            Some("session-1")
254        );
255    }
256}
257
258#[derive(Default)]
259struct StringVisitor(HashMap<String, String>);
260
261impl tracing::field::Visit for StringVisitor {
262    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
263        self.0.insert(field.name().to_string(), format!("{:?}", value));
264    }
265
266    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
267        self.0.insert(field.name().to_string(), value.to_string());
268    }
269
270    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
271        self.0.insert(field.name().to_string(), value.to_string());
272    }
273
274    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
275        self.0.insert(field.name().to_string(), value.to_string());
276    }
277
278    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
279        self.0.insert(field.name().to_string(), value.to_string());
280    }
281
282    fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
283        self.0.insert(field.name().to_string(), value.to_string());
284    }
285}