Skip to main content

heartbit_core/agent/
observability.rs

1//! Observability mode — controls tracing verbosity and metric emission.
2
3/// Controls the verbosity of agent execution observability.
4///
5/// Configurable via:
6/// 1. `HEARTBIT_OBSERVABILITY` env var (highest priority)
7/// 2. `[telemetry] observability_mode` in config TOML
8/// 3. `AgentRunnerBuilder::observability_mode()` / `OrchestratorBuilder::observability_mode()`
9/// 4. Default: `Production`
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ObservabilityMode {
12    /// Near-zero overhead: span names + durations only.
13    #[default]
14    Production,
15    /// Metrics: tokens, latencies, costs, stop reasons.
16    Analysis,
17    /// Full payloads (truncated to 4KB) for debugging.
18    Debug,
19}
20
21impl ObservabilityMode {
22    /// Parse from a case-insensitive string. Returns `None` for unknown values.
23    pub fn from_str_loose(s: &str) -> Option<Self> {
24        match s.to_ascii_lowercase().as_str() {
25            "production" | "prod" => Some(Self::Production),
26            "analysis" | "analyze" => Some(Self::Analysis),
27            "debug" | "dbg" => Some(Self::Debug),
28            _ => None,
29        }
30    }
31
32    /// Resolve the effective mode from all configuration sources.
33    ///
34    /// Priority: env var > config string > builder value > default.
35    pub fn resolve(
36        env_key: &str,
37        config_str: Option<&str>,
38        builder_value: Option<ObservabilityMode>,
39    ) -> Self {
40        // 1. Environment variable (highest priority)
41        if let Ok(val) = std::env::var(env_key) {
42            if let Some(mode) = Self::from_str_loose(&val) {
43                return mode;
44            }
45            tracing::warn!(
46                env = env_key,
47                value = %val,
48                "unknown observability mode, falling back"
49            );
50        }
51        // 2. Config file string
52        if let Some(s) = config_str {
53            if let Some(mode) = Self::from_str_loose(s) {
54                return mode;
55            }
56            tracing::warn!(
57                value = %s,
58                "unknown observability mode in config, falling back"
59            );
60        }
61        // 3. Builder value
62        if let Some(mode) = builder_value {
63            return mode;
64        }
65        // 4. Default
66        Self::default()
67    }
68
69    /// Whether this mode includes metrics (token counts, latencies, costs).
70    pub fn includes_metrics(self) -> bool {
71        matches!(self, Self::Analysis | Self::Debug)
72    }
73
74    /// Whether this mode includes full payloads (request/response text, tool I/O).
75    pub fn includes_payloads(self) -> bool {
76        matches!(self, Self::Debug)
77    }
78}
79
80impl std::fmt::Display for ObservabilityMode {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::Production => write!(f, "production"),
84            Self::Analysis => write!(f, "analysis"),
85            Self::Debug => write!(f, "debug"),
86        }
87    }
88}
89
90/// Environment variable key for observability mode.
91pub const OBSERVABILITY_ENV_KEY: &str = "HEARTBIT_OBSERVABILITY";
92
93// --- OpenTelemetry GenAI Semantic Convention constants (v1.38.0) ---
94// Used as span attribute names so OTel-compatible backends (Jaeger, Grafana
95// Tempo, Arize Phoenix) recognize the spans as GenAI operations.
96
97/// The GenAI system producing the response (e.g., "anthropic", "openrouter").
98pub const GEN_AI_SYSTEM: &str = "gen_ai.system";
99/// Model requested by the caller.
100pub const GEN_AI_REQUEST_MODEL: &str = "gen_ai.request.model";
101/// Model that actually generated the response (may differ from request).
102pub const GEN_AI_RESPONSE_MODEL: &str = "gen_ai.response.model";
103/// Reason the model stopped generating (e.g., "end_turn", "tool_use").
104pub const GEN_AI_RESPONSE_FINISH_REASON: &str = "gen_ai.response.finish_reasons";
105/// Number of input tokens consumed.
106pub const GEN_AI_USAGE_INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
107/// Number of output tokens generated.
108pub const GEN_AI_USAGE_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn mode_default_is_production() {
116        assert_eq!(ObservabilityMode::default(), ObservabilityMode::Production);
117    }
118
119    #[test]
120    fn from_str_loose_all_variants() {
121        assert_eq!(
122            ObservabilityMode::from_str_loose("production"),
123            Some(ObservabilityMode::Production)
124        );
125        assert_eq!(
126            ObservabilityMode::from_str_loose("prod"),
127            Some(ObservabilityMode::Production)
128        );
129        assert_eq!(
130            ObservabilityMode::from_str_loose("PRODUCTION"),
131            Some(ObservabilityMode::Production)
132        );
133        assert_eq!(
134            ObservabilityMode::from_str_loose("analysis"),
135            Some(ObservabilityMode::Analysis)
136        );
137        assert_eq!(
138            ObservabilityMode::from_str_loose("analyze"),
139            Some(ObservabilityMode::Analysis)
140        );
141        assert_eq!(
142            ObservabilityMode::from_str_loose("ANALYSIS"),
143            Some(ObservabilityMode::Analysis)
144        );
145        assert_eq!(
146            ObservabilityMode::from_str_loose("debug"),
147            Some(ObservabilityMode::Debug)
148        );
149        assert_eq!(
150            ObservabilityMode::from_str_loose("dbg"),
151            Some(ObservabilityMode::Debug)
152        );
153        assert_eq!(
154            ObservabilityMode::from_str_loose("DEBUG"),
155            Some(ObservabilityMode::Debug)
156        );
157    }
158
159    #[test]
160    fn from_str_loose_unknown_returns_none() {
161        assert_eq!(ObservabilityMode::from_str_loose("banana"), None);
162        assert_eq!(ObservabilityMode::from_str_loose(""), None);
163    }
164
165    #[test]
166    fn resolve_env_overrides_config() {
167        // Use a unique env var to avoid test interference
168        let key = "HEARTBIT_OBSERVABILITY_TEST_1";
169        // SAFETY: test-only, no concurrent env access during this test.
170        unsafe {
171            std::env::set_var(key, "debug");
172        }
173        let mode =
174            ObservabilityMode::resolve(key, Some("production"), Some(ObservabilityMode::Analysis));
175        assert_eq!(mode, ObservabilityMode::Debug);
176        // SAFETY: test-only, no concurrent env access during this test.
177        unsafe {
178            std::env::remove_var(key);
179        }
180    }
181
182    #[test]
183    fn resolve_config_overrides_builder() {
184        let key = "HEARTBIT_OBSERVABILITY_TEST_2";
185        // SAFETY: test-only, no concurrent env access during this test.
186        unsafe {
187            std::env::remove_var(key);
188        }
189        let mode =
190            ObservabilityMode::resolve(key, Some("analysis"), Some(ObservabilityMode::Production));
191        assert_eq!(mode, ObservabilityMode::Analysis);
192    }
193
194    #[test]
195    fn resolve_default_fallback() {
196        let key = "HEARTBIT_OBSERVABILITY_TEST_3";
197        // SAFETY: test-only, no concurrent env access during this test.
198        unsafe {
199            std::env::remove_var(key);
200        }
201        let mode = ObservabilityMode::resolve(key, None, None);
202        assert_eq!(mode, ObservabilityMode::Production);
203    }
204
205    #[test]
206    fn includes_metrics_analysis_and_debug() {
207        assert!(!ObservabilityMode::Production.includes_metrics());
208        assert!(ObservabilityMode::Analysis.includes_metrics());
209        assert!(ObservabilityMode::Debug.includes_metrics());
210    }
211
212    #[test]
213    fn includes_payloads_debug_only() {
214        assert!(!ObservabilityMode::Production.includes_payloads());
215        assert!(!ObservabilityMode::Analysis.includes_payloads());
216        assert!(ObservabilityMode::Debug.includes_payloads());
217    }
218
219    #[test]
220    fn display_impl() {
221        assert_eq!(ObservabilityMode::Production.to_string(), "production");
222        assert_eq!(ObservabilityMode::Analysis.to_string(), "analysis");
223        assert_eq!(ObservabilityMode::Debug.to_string(), "debug");
224    }
225
226    // --- GenAI Semantic Convention constants tests ---
227
228    #[test]
229    fn gen_ai_constants_match_spec() {
230        // Verify string values match OTel GenAI Semantic Conventions v1.38.0
231        assert_eq!(GEN_AI_SYSTEM, "gen_ai.system");
232        assert_eq!(GEN_AI_REQUEST_MODEL, "gen_ai.request.model");
233        assert_eq!(GEN_AI_RESPONSE_MODEL, "gen_ai.response.model");
234        assert_eq!(
235            GEN_AI_RESPONSE_FINISH_REASON,
236            "gen_ai.response.finish_reasons"
237        );
238        assert_eq!(GEN_AI_USAGE_INPUT_TOKENS, "gen_ai.usage.input_tokens");
239        assert_eq!(GEN_AI_USAGE_OUTPUT_TOKENS, "gen_ai.usage.output_tokens");
240    }
241}