Skip to main content

observability_core/
traits.rs

1//! Core traits for the observability plugin system
2
3use crate::error::ObservabilityResult;
4use std::collections::HashMap;
5use std::sync::Arc;
6use web_time::{Duration, Instant};
7
8#[cfg(feature = "structured-logging")]
9use serde_json::Value as JsonValue;
10
11/// Log levels for structured logging
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13#[cfg_attr(
14    feature = "structured-logging",
15    derive(serde::Serialize, serde::Deserialize)
16)]
17pub enum LogLevel {
18    Error = 0,
19    Warn = 1,
20    Info = 2,
21    Debug = 3,
22    Trace = 4,
23}
24
25impl LogLevel {
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            LogLevel::Error => "ERROR",
29            LogLevel::Warn => "WARN",
30            LogLevel::Info => "INFO",
31            LogLevel::Debug => "DEBUG",
32            LogLevel::Trace => "TRACE",
33        }
34    }
35}
36
37/// Span guard that automatically ends spans when dropped
38pub struct SpanGuard {
39    span_id: String,
40    start_time: Instant,
41    plugin: Option<Arc<dyn ObservabilityPlugin>>,
42}
43
44impl SpanGuard {
45    pub fn new(span_id: String, plugin: Arc<dyn ObservabilityPlugin>) -> Self {
46        Self {
47            span_id,
48            start_time: Instant::now(),
49            plugin: Some(plugin),
50        }
51    }
52
53    pub fn no_op() -> Self {
54        Self {
55            span_id: String::new(),
56            start_time: Instant::now(),
57            plugin: None,
58        }
59    }
60
61    pub fn span_id(&self) -> &str {
62        &self.span_id
63    }
64
65    pub fn duration(&self) -> Duration {
66        self.start_time.elapsed()
67    }
68
69    pub fn add_attribute(&self, key: &str, value: &str) {
70        if let Some(plugin) = &self.plugin {
71            plugin.add_span_attribute(&self.span_id, key, value);
72        }
73    }
74
75    pub fn set_status(&self, status: SpanStatus) {
76        if let Some(plugin) = &self.plugin {
77            plugin.set_span_status(&self.span_id, status);
78        }
79    }
80}
81
82impl Drop for SpanGuard {
83    fn drop(&mut self) {
84        if let Some(plugin) = self.plugin.take() {
85            plugin.end_span(&self.span_id);
86        }
87    }
88}
89
90/// Status of a span
91#[derive(Debug, Clone, Copy)]
92pub enum SpanStatus {
93    Ok,
94    Error,
95    Cancelled,
96}
97
98/// Core observability plugin trait
99pub trait ObservabilityPlugin: Send + Sync {
100    /// Start a new span and return a guard
101    fn start_span(&self, name: &str, attributes: &[(&str, &str)]) -> SpanGuard;
102
103    /// End a span by ID
104    fn end_span(&self, span_id: &str);
105
106    /// Add an attribute to an existing span
107    fn add_span_attribute(&self, span_id: &str, key: &str, value: &str);
108
109    /// Set the status of a span
110    fn set_span_status(&self, span_id: &str, status: SpanStatus);
111
112    /// Record a metric with labels
113    fn record_metric(&self, name: &str, value: f64, labels: &[(&str, &str)]);
114
115    /// Increment a counter metric
116    fn increment_counter(&self, name: &str, labels: &[(&str, &str)]) {
117        self.record_metric(name, 1.0, labels);
118    }
119
120    /// Record a histogram value
121    fn record_histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
122        self.record_metric(name, value, labels);
123    }
124
125    /// Log a structured message with fields
126    #[cfg(feature = "structured-logging")]
127    fn log_structured(&self, level: LogLevel, message: &str, fields: &JsonValue);
128
129    /// Log a simple message
130    fn log(&self, level: LogLevel, message: &str) {
131        #[cfg(feature = "structured-logging")]
132        self.log_structured(level, message, &serde_json::json!({}));
133
134        #[cfg(not(feature = "structured-logging"))]
135        {
136            // Simple console output for WASM
137            let level_str = level.as_str();
138            let output = format!("[{}] {}", level_str, message);
139            self.write_log(&output);
140        }
141    }
142
143    /// Write log output (implementation-specific)
144    fn write_log(&self, message: &str);
145
146    /// Flush any pending telemetry data
147    fn flush(&self) -> ObservabilityResult<()>;
148
149    /// Check if the plugin is enabled
150    fn is_enabled(&self) -> bool {
151        true
152    }
153
154    /// Get the plugin name/type
155    fn plugin_type(&self) -> &'static str {
156        "generic"
157    }
158}
159
160/// Trait for collecting and managing metrics
161pub trait MetricsCollector: Send + Sync {
162    /// Register a new counter
163    fn register_counter(
164        &mut self,
165        name: &str,
166        description: &str,
167        labels: &[&str],
168    ) -> ObservabilityResult<()>;
169
170    /// Register a new histogram  
171    fn register_histogram(
172        &mut self,
173        name: &str,
174        description: &str,
175        buckets: &[f64],
176        labels: &[&str],
177    ) -> ObservabilityResult<()>;
178
179    /// Register a new gauge
180    fn register_gauge(
181        &mut self,
182        name: &str,
183        description: &str,
184        labels: &[&str],
185    ) -> ObservabilityResult<()>;
186
187    /// Record a counter increment
188    fn record_counter(
189        &self,
190        name: &str,
191        value: f64,
192        labels: &HashMap<String, String>,
193    ) -> ObservabilityResult<()>;
194
195    /// Record a histogram observation
196    fn record_histogram(
197        &self,
198        name: &str,
199        value: f64,
200        labels: &HashMap<String, String>,
201    ) -> ObservabilityResult<()>;
202
203    /// Set a gauge value
204    fn set_gauge(
205        &self,
206        name: &str,
207        value: f64,
208        labels: &HashMap<String, String>,
209    ) -> ObservabilityResult<()>;
210
211    /// Get current metric values (for testing/debugging)
212    fn get_metrics(&self) -> HashMap<String, f64>;
213
214    /// Clear all metrics
215    fn clear(&mut self);
216}
217
218/// Trait for structured logging
219#[cfg(feature = "structured-logging")]
220pub trait StructuredLogger: Send + Sync {
221    /// Log with trace context correlation
222    fn log_with_trace(
223        &self,
224        level: LogLevel,
225        message: &str,
226        fields: &JsonValue,
227        trace_id: Option<&str>,
228        span_id: Option<&str>,
229    );
230
231    /// Log performance metrics
232    fn log_performance(
233        &self,
234        operation: &str,
235        duration: Duration,
236        success: bool,
237        additional_fields: &JsonValue,
238    );
239
240    /// Log errors with context
241    fn log_error(&self, error: &dyn std::error::Error, context: &JsonValue);
242
243    /// Set the minimum log level
244    fn set_level(&mut self, level: LogLevel);
245
246    /// Check if a level is enabled
247    fn is_level_enabled(&self, level: LogLevel) -> bool;
248}
249
250/// Builder trait for creating observability plugins
251pub trait ObservabilityBuilder {
252    type Plugin: ObservabilityPlugin;
253
254    /// Build the plugin with the current configuration
255    fn build(self) -> ObservabilityResult<Self::Plugin>;
256
257    /// Set the plugin name
258    fn with_name(self, name: impl Into<String>) -> Self;
259
260    /// Enable/disable the plugin
261    fn enabled(self, enabled: bool) -> Self;
262}
263
264/// Trait for plugins that support batching
265pub trait BatchingSupport {
266    /// Get the current batch size
267    fn batch_size(&self) -> usize;
268
269    /// Set the batch size
270    fn set_batch_size(&mut self, size: usize);
271
272    /// Get the flush interval
273    fn flush_interval(&self) -> Duration;
274
275    /// Set the flush interval
276    fn set_flush_interval(&mut self, interval: Duration);
277
278    /// Force flush all batched data
279    fn force_flush(&self) -> ObservabilityResult<()>;
280}
281
282/// Metric label allowlist for cardinality reduction.
283///
284/// Backends should treat unknown labels as optional or drop them to avoid cardinality explosions.
285pub const METRIC_LABEL_ALLOWLIST: &[&str] = &[
286    // Service identity (bounded)
287    "app",       // Application name
288    "version",   // Application version
289    "namespace", // Kubernetes namespace
290    // A2A / SDK (bounded)
291    "component", // a2a_server | a2a_client | llm_client | sdk
292    "operation", // rpc method / operation name (bounded set)
293    "status",    // ok | error
294    // LLM (bounded)
295    "provider",  // LLM provider
296    "model",     // LLM model name
297    "direction", // input | output
298];
299
300/// Create a label map from key-value pairs
301pub fn create_labels(pairs: &[(&str, &str)]) -> HashMap<String, String> {
302    pairs
303        .iter()
304        .map(|(k, v)| (k.to_string(), v.to_string()))
305        .collect()
306}
307
308/// Validate that label keys are within the allowlist.
309pub fn validate_metric_label_allowlist(label_keys: &[&str]) -> bool {
310    label_keys
311        .iter()
312        .all(|label| METRIC_LABEL_ALLOWLIST.contains(label))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::sync::Mutex;
319    use std::sync::atomic::{AtomicUsize, Ordering};
320
321    #[derive(Default)]
322    struct MockPlugin {
323        end_calls: AtomicUsize,
324        attrs: Mutex<Vec<(String, String, String)>>,
325        statuses: Mutex<Vec<(String, SpanStatus)>>,
326    }
327
328    impl ObservabilityPlugin for MockPlugin {
329        fn start_span(&self, _name: &str, _attributes: &[(&str, &str)]) -> SpanGuard {
330            // Not used by these tests; guard behavior is tested directly.
331            SpanGuard::no_op()
332        }
333
334        fn end_span(&self, span_id: &str) {
335            self.end_calls.fetch_add(1, Ordering::SeqCst);
336            // Ensure we receive the expected ID (helps catch regressions).
337            assert!(!span_id.is_empty());
338        }
339
340        fn add_span_attribute(&self, span_id: &str, key: &str, value: &str) {
341            self.attrs.lock().unwrap().push((
342                span_id.to_string(),
343                key.to_string(),
344                value.to_string(),
345            ));
346        }
347
348        fn set_span_status(&self, span_id: &str, status: SpanStatus) {
349            self.statuses
350                .lock()
351                .unwrap()
352                .push((span_id.to_string(), status));
353        }
354
355        fn record_metric(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
356
357        #[cfg(feature = "structured-logging")]
358        fn log_structured(&self, _level: LogLevel, _message: &str, _fields: &JsonValue) {}
359
360        fn write_log(&self, _message: &str) {}
361
362        fn flush(&self) -> ObservabilityResult<()> {
363            Ok(())
364        }
365    }
366
367    #[test]
368    fn span_guard_drop_calls_end_span_once() {
369        let typed = Arc::new(MockPlugin::default());
370        let plugin: Arc<dyn ObservabilityPlugin> = typed.clone();
371
372        {
373            let g = SpanGuard::new("span-1".to_string(), plugin);
374            g.add_attribute("k", "v");
375            g.set_status(SpanStatus::Error);
376        } // drop => end_span
377
378        assert_eq!(typed.end_calls.load(Ordering::SeqCst), 1);
379
380        let attrs = typed.attrs.lock().unwrap().clone();
381        assert_eq!(
382            attrs,
383            vec![("span-1".to_string(), "k".to_string(), "v".to_string())]
384        );
385
386        let statuses = typed.statuses.lock().unwrap().clone();
387        assert_eq!(statuses.len(), 1);
388        assert_eq!(statuses[0].0, "span-1");
389        assert!(matches!(statuses[0].1, SpanStatus::Error));
390    }
391
392    #[test]
393    fn span_guard_no_op_is_safe() {
394        // Should not panic, and should not call into any plugin.
395        let g = SpanGuard::no_op();
396        g.add_attribute("k", "v");
397        g.set_status(SpanStatus::Error);
398        drop(g);
399    }
400}