Skip to main content

adk_telemetry/
span_exporter.rs

1use std::collections::HashMap;
2use std::sync::{
3    Arc, RwLock,
4    atomic::{AtomicBool, Ordering},
5};
6use tracing::{Id, Subscriber, debug};
7use tracing_subscriber::{Layer, layer::Context, registry::LookupSpan};
8
9/// Destination for spans captured by [`AdkSpanLayer`].
10///
11/// Implemented by [`AdkSpanExporter`] (in-memory, queried by the server debug
12/// routes) and, with the `sqlite` feature, by
13/// `SqliteSpanExporter` (persistent,
14/// zero-infrastructure tracing). Implementations decide which spans to keep —
15/// the layer forwards every closed span.
16pub trait SpanSink: Send + Sync {
17    /// Receive one closed span with its collected attributes.
18    fn export_span(&self, span_name: &str, attributes: HashMap<String, String>);
19}
20
21/// ADK-Go style span exporter that retains runtime spans in memory.
22///
23/// Spans are keyed by a stable span ID while preserving the originating ADK
24/// event ID as an attribute. This lets multiple runtime operations describe the
25/// same event without overwriting one another.
26#[derive(Debug, Clone, Default)]
27pub struct AdkSpanExporter {
28    /// Map of span ID to span attributes.
29    trace_dict: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
30    /// Whether this exporter has observed at least one retained runtime span.
31    collecting: Arc<AtomicBool>,
32}
33
34impl AdkSpanExporter {
35    /// Creates an empty in-process span exporter.
36    pub fn new() -> Self {
37        Self {
38            trace_dict: Arc::new(RwLock::new(HashMap::new())),
39            collecting: Arc::new(AtomicBool::new(false)),
40        }
41    }
42
43    /// Returns a snapshot of retained spans keyed by span ID.
44    pub fn get_trace_dict(&self) -> HashMap<String, HashMap<String, String>> {
45        self.trace_dict.read().unwrap_or_else(|e| e.into_inner()).clone()
46    }
47
48    /// Returns the first span associated with an ADK event ID.
49    pub fn get_trace_by_event_id(&self, event_id: &str) -> Option<HashMap<String, String>> {
50        debug!("AdkSpanExporter::get_trace_by_event_id called with event_id: {}", event_id);
51        let trace_dict = self.trace_dict.read().unwrap_or_else(|e| e.into_inner());
52        let result = trace_dict.get(event_id).cloned().or_else(|| {
53            trace_dict
54                .values()
55                .find(|attributes| {
56                    attributes.get("gcp.vertex.agent.event_id").is_some_and(|id| id == event_id)
57                })
58                .cloned()
59        });
60        debug!("get_trace_by_event_id result for event_id '{}': {:?}", event_id, result.is_some());
61        result
62    }
63
64    /// Returns whether the exporter has retained at least one runtime span.
65    ///
66    /// A configured exporter reports `false` until a supported span closes.
67    /// Servers use this to distinguish a ready collector from one proven to be
68    /// collecting, instead of advertising telemetry from configuration alone.
69    pub fn is_collecting(&self) -> bool {
70        self.collecting.load(Ordering::Acquire)
71    }
72
73    /// Get all spans for a session (by filtering spans that have matching session_id)
74    pub fn get_session_trace(&self, session_id: &str) -> Vec<HashMap<String, String>> {
75        debug!("AdkSpanExporter::get_session_trace called with session_id: {}", session_id);
76        let trace_dict = self.trace_dict.read().unwrap_or_else(|e| e.into_inner());
77
78        let mut spans = Vec::new();
79        for (_event_id, attributes) in trace_dict.iter() {
80            // Check if this span belongs to the session
81            if let Some(span_session_id) = attributes.get("gcp.vertex.agent.session_id")
82                && span_session_id == session_id
83            {
84                spans.push(attributes.clone());
85            }
86        }
87
88        debug!("get_session_trace result for session_id '{}': {} spans", session_id, spans.len());
89        spans
90    }
91}
92
93impl SpanSink for AdkSpanExporter {
94    /// Stores supported runtime spans in memory for the debug API.
95    fn export_span(&self, span_name: &str, attributes: HashMap<String, String>) {
96        if is_runtime_span(span_name) {
97            if let Some(event_id) = attributes.get("gcp.vertex.agent.event_id") {
98                debug!(
99                    "AdkSpanExporter: Storing span '{}' with event_id '{}'",
100                    span_name, event_id
101                );
102                let storage_key =
103                    attributes.get("span_id").cloned().unwrap_or_else(|| event_id.clone());
104                let mut trace_dict = self.trace_dict.write().unwrap_or_else(|e| e.into_inner());
105                trace_dict.insert(storage_key, attributes);
106                self.collecting.store(true, Ordering::Release);
107                debug!("AdkSpanExporter: Span stored, total spans: {}", trace_dict.len());
108            } else {
109                debug!("AdkSpanExporter: Skipping span '{}' - no event_id found", span_name);
110            }
111        } else {
112            debug!("AdkSpanExporter: Skipping span '{}' - not in allowed list", span_name);
113        }
114    }
115}
116
117pub(crate) fn is_runtime_span(span_name: &str) -> bool {
118    span_name == "agent.execute"
119        || span_name == "call_llm"
120        || span_name == "send_data"
121        || span_name.starts_with("execute_tool")
122        || matches!(span_name, "team.run" | "team.member.run" | "team.relationship.execute")
123}
124
125/// Tracing layer that captures spans and exports them via a [`SpanSink`]
126/// (in-memory [`AdkSpanExporter`], SQLite, or any custom sink).
127pub struct AdkSpanLayer {
128    exporter: Arc<dyn SpanSink>,
129}
130
131impl AdkSpanLayer {
132    pub fn new<S: SpanSink + 'static>(exporter: Arc<S>) -> Self {
133        Self { exporter }
134    }
135}
136
137#[derive(Clone)]
138struct SpanFields {
139    values: HashMap<String, String>,
140    event_id_declared: bool,
141}
142
143#[derive(Clone)]
144struct SpanTiming {
145    start_time: std::time::Instant,
146}
147
148impl<S> Layer<S> for AdkSpanLayer
149where
150    S: Subscriber + for<'a> LookupSpan<'a>,
151{
152    fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
153        let Some(span) = ctx.span(id) else { return };
154        let mut extensions = span.extensions_mut();
155
156        // Record start time
157        extensions.insert(SpanTiming { start_time: std::time::Instant::now() });
158
159        // Capture fields
160        let mut visitor = StringVisitor::default();
161        attrs.record(&mut visitor);
162        let mut fields_map = visitor.0;
163        let event_id_declared = fields_map.contains_key("gcp.vertex.agent.event_id");
164
165        // Propagate fields from parent span (for context inheritance)
166        if let Some(parent) = span.parent()
167            && let Some(parent_fields) = parent.extensions().get::<SpanFields>()
168        {
169            let context_keys = [
170                "gcp.vertex.agent.session_id",
171                "gcp.vertex.agent.invocation_id",
172                "gcp.vertex.agent.event_id",
173                "gen_ai.conversation.id",
174                #[cfg(feature = "genai-semconv")]
175                "gen_ai.provider.name",
176                #[cfg(feature = "genai-semconv")]
177                "gen_ai.system",
178            ];
179
180            for key in context_keys {
181                if !fields_map.contains_key(key)
182                    && let Some(val) = parent_fields.values.get(key)
183                {
184                    fields_map.insert(key.to_string(), val.clone());
185                }
186            }
187        }
188
189        extensions.insert(SpanFields { values: fields_map, event_id_declared });
190    }
191
192    fn on_record(&self, id: &Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>) {
193        let Some(span) = ctx.span(id) else { return };
194        let mut extensions = span.extensions_mut();
195        if let Some(fields) = extensions.get_mut::<SpanFields>() {
196            let mut visitor = StringVisitor::default();
197            values.record(&mut visitor);
198            for (k, v) in visitor.0 {
199                if k == "gcp.vertex.agent.event_id" {
200                    fields.event_id_declared = true;
201                }
202                fields.values.insert(k, v);
203            }
204        }
205    }
206
207    fn on_close(&self, id: Id, ctx: Context<'_, S>) {
208        let Some(span) = ctx.span(&id) else { return };
209        let extensions = span.extensions();
210
211        // Calculate actual duration
212        let timing = extensions.get::<SpanTiming>();
213        let end_time = std::time::Instant::now();
214        let duration_nanos =
215            timing.map(|t| end_time.duration_since(t.start_time).as_nanos() as u64).unwrap_or(0);
216
217        // Get captured fields
218        let span_fields = extensions.get::<SpanFields>();
219        let event_id_declared = span_fields.is_some_and(|fields| fields.event_id_declared);
220        let mut attributes = span_fields.map(|fields| fields.values.clone()).unwrap_or_default();
221
222        // Get span name - prefer otel.name attribute (for dynamic names), fallback to metadata
223        let metadata = span.metadata();
224        let span_name =
225            attributes.get("otel.name").cloned().unwrap_or_else(|| metadata.name().to_string());
226
227        // Add span metadata and actual timing with unique IDs
228        let now_nanos = std::time::SystemTime::now()
229            .duration_since(std::time::UNIX_EPOCH)
230            .unwrap_or_default()
231            .as_nanos() as u64;
232
233        // Use invocation_id as trace_id (for grouping in UI). Spans that
234        // declare their own event ID keep it as the span ID for compatibility;
235        // child spans that inherit a parent event ID use tracing's unique ID so
236        // they cannot overwrite the parent or a sibling. `send_data` describes
237        // the same event as its enclosing `call_llm`, so it also needs its own
238        // ID to preserve both operations.
239        let generated_span_id = format!("{:016x}", id.into_u64());
240        let invocation_id = attributes
241            .get("gcp.vertex.agent.invocation_id")
242            .cloned()
243            .unwrap_or_else(|| generated_span_id.clone());
244        let event_id = attributes
245            .get("gcp.vertex.agent.event_id")
246            .cloned()
247            .unwrap_or_else(|| generated_span_id.clone());
248        let span_id = if event_id_declared && span_name != "send_data" {
249            event_id
250        } else {
251            generated_span_id
252        };
253
254        attributes.insert("span_name".to_string(), span_name.clone());
255        attributes.insert("trace_id".to_string(), invocation_id); // Group by invocation
256        attributes.insert("span_id".to_string(), span_id);
257        attributes.insert("start_time".to_string(), (now_nanos - duration_nanos).to_string());
258        attributes.insert("end_time".to_string(), now_nanos.to_string());
259
260        // Don't set parent_span_id to keep all spans at same level like ADK-Go
261
262        // Export the span
263        self.exporter.export_span(&span_name, attributes);
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::sync::Arc;
271    use tracing_subscriber::{
272        EnvFilter,
273        filter::filter_fn,
274        layer::{Layer, SubscriberExt},
275    };
276
277    #[test]
278    fn test_conversation_id_propagates_to_child_spans() {
279        let exporter = Arc::new(AdkSpanExporter::new());
280        let layer = AdkSpanLayer::new(exporter.clone());
281        let subscriber = tracing_subscriber::registry().with(layer);
282
283        tracing::subscriber::with_default(subscriber, || {
284            let parent = tracing::info_span!(
285                "agent.execute",
286                "gcp.vertex.agent.event_id" = "evt-parent",
287                "gcp.vertex.agent.invocation_id" = "inv-1",
288                "gcp.vertex.agent.session_id" = "session-1",
289                "gen_ai.conversation.id" = "session-1",
290                "agent.name" = "test-agent"
291            );
292
293            let _parent_guard = parent.enter();
294
295            let child = tracing::info_span!(
296                "call_llm",
297                "gcp.vertex.agent.event_id" = "evt-child",
298                "gcp.vertex.agent.llm_request" = "{}"
299            );
300            let _child_guard = child.enter();
301            tracing::info!("child span body");
302        });
303
304        let child_trace =
305            exporter.get_trace_by_event_id("evt-child").expect("child span should be exported");
306        assert_eq!(
307            child_trace.get("gen_ai.conversation.id").map(String::as_str),
308            Some("session-1")
309        );
310    }
311
312    #[test]
313    fn console_log_filter_does_not_suppress_runtime_span_capture() {
314        let exporter = Arc::new(AdkSpanExporter::new());
315        let capture = AdkSpanLayer::new(exporter.clone()).with_filter(filter_fn(|metadata| {
316            metadata.is_span() && is_runtime_span(metadata.name())
317        }));
318        let console = tracing_subscriber::fmt::layer()
319            .with_writer(std::io::sink)
320            .with_filter(EnvFilter::new("warn"));
321        let subscriber = tracing_subscriber::registry().with(console).with(capture);
322
323        tracing::subscriber::with_default(subscriber, || {
324            let span = tracing::info_span!(
325                "agent.execute",
326                "gcp.vertex.agent.event_id" = "evt-filtered-console",
327                "gcp.vertex.agent.invocation_id" = "inv-filtered-console",
328                "gcp.vertex.agent.session_id" = "session-filtered-console"
329            );
330            let _guard = span.enter();
331        });
332
333        assert!(exporter.get_trace_by_event_id("evt-filtered-console").is_some());
334        assert!(exporter.is_collecting());
335    }
336
337    #[test]
338    fn inherited_event_ids_do_not_overwrite_team_relationship_spans() {
339        let exporter = Arc::new(AdkSpanExporter::new());
340        let layer = AdkSpanLayer::new(exporter.clone());
341        let subscriber = tracing_subscriber::registry().with(layer);
342
343        tracing::subscriber::with_default(subscriber, || {
344            let parent = tracing::info_span!(
345                "agent.execute",
346                "gcp.vertex.agent.event_id" = "evt-team",
347                "gcp.vertex.agent.invocation_id" = "inv-team",
348                "gcp.vertex.agent.session_id" = "session-team"
349            );
350            let parent_guard = parent.enter();
351            let relationship = tracing::info_span!(
352                "team.relationship.execute",
353                team.name = "support",
354                team.relationship.from = "supervisor",
355                team.relationship.to = "billing",
356                team.relationship.kind = "handoff",
357                team.edge.id = "edge-1"
358            );
359            let relationship_guard = relationship.enter();
360            drop(relationship_guard);
361            drop(relationship);
362            drop(parent_guard);
363            drop(parent);
364        });
365
366        let spans = exporter.get_session_trace("session-team");
367        assert_eq!(spans.len(), 2);
368        assert!(spans.iter().any(|span| {
369            span.get("span_name").is_some_and(|name| name == "team.relationship.execute")
370        }));
371        let unique_span_ids = spans
372            .iter()
373            .filter_map(|span| span.get("span_id"))
374            .collect::<std::collections::HashSet<_>>();
375        assert_eq!(unique_span_ids.len(), 2);
376    }
377
378    #[test]
379    fn send_data_does_not_overwrite_call_llm_for_the_same_event() {
380        let exporter = Arc::new(AdkSpanExporter::new());
381        let layer = AdkSpanLayer::new(exporter.clone());
382        let subscriber = tracing_subscriber::registry().with(layer);
383
384        tracing::subscriber::with_default(subscriber, || {
385            let call_llm = tracing::info_span!(
386                "call_llm",
387                "gcp.vertex.agent.event_id" = "evt-model",
388                "gcp.vertex.agent.invocation_id" = "inv-model",
389                "gcp.vertex.agent.session_id" = "session-model"
390            );
391            drop(call_llm);
392
393            let send_data = tracing::info_span!(
394                "send_data",
395                "gcp.vertex.agent.event_id" = "evt-model",
396                "gcp.vertex.agent.invocation_id" = "inv-model",
397                "gcp.vertex.agent.session_id" = "session-model"
398            );
399            drop(send_data);
400        });
401
402        let spans = exporter.get_session_trace("session-model");
403        assert_eq!(spans.len(), 2);
404        assert!(
405            spans.iter().any(|span| span.get("span_name").is_some_and(|name| name == "call_llm"))
406        );
407        assert!(
408            spans.iter().any(|span| span.get("span_name").is_some_and(|name| name == "send_data"))
409        );
410    }
411}
412
413#[derive(Default)]
414struct StringVisitor(HashMap<String, String>);
415
416impl tracing::field::Visit for StringVisitor {
417    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
418        self.0.insert(field.name().to_string(), format!("{:?}", value));
419    }
420
421    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
422        self.0.insert(field.name().to_string(), value.to_string());
423    }
424
425    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
426        self.0.insert(field.name().to_string(), value.to_string());
427    }
428
429    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
430        self.0.insert(field.name().to_string(), value.to_string());
431    }
432
433    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
434        self.0.insert(field.name().to_string(), value.to_string());
435    }
436
437    fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
438        self.0.insert(field.name().to_string(), value.to_string());
439    }
440}