Skip to main content

a3s_code_core/
telemetry.rs

1//! Telemetry Module (Core)
2//!
3//! Provides centralized observability primitives for the A3S Code agent:
4//! - Span name and attribute key constants
5//! - LLM cost tracking (model, tokens, cost per call)
6//! - Tool execution metrics (duration, success/failure)
7//! - Span helper functions using `tracing` (no OTel dependency)
8//!
9//! For OpenTelemetry initialization (OTLP exporter, subscriber setup),
10//! see the `telemetry_init` module in the `a3s-code` server crate.
11//!
12//! ## Span Hierarchy
13//!
14//! ```text
15//! a3s.agent.execute
16//!   +-- a3s.agent.context_resolve
17//!   +-- a3s.agent.turn (repeated)
18//!   |   +-- a3s.llm.completion
19//!   |   +-- a3s.tool.execute (repeated)
20//!   +-- a3s.agent.turn_notify
21//! ```
22
23use std::time::Instant;
24
25// ============================================================================
26// Constants
27// ============================================================================
28
29/// Service name for telemetry
30pub const SERVICE_NAME: &str = "a3s-code";
31
32// Span name constants
33pub const SPAN_AGENT_EXECUTE: &str = "a3s.agent.execute";
34pub const SPAN_AGENT_TURN: &str = "a3s.agent.turn";
35pub const SPAN_LLM_COMPLETION: &str = "a3s.llm.completion";
36pub const SPAN_TOOL_EXECUTE: &str = "a3s.tool.execute";
37pub const SPAN_CONTEXT_RESOLVE: &str = "a3s.agent.context_resolve";
38
39// Attribute key constants
40pub const ATTR_SESSION_ID: &str = "a3s.session.id";
41pub const ATTR_TURN_NUMBER: &str = "a3s.agent.turn_number";
42pub const ATTR_MAX_TURNS: &str = "a3s.agent.max_turns";
43pub const ATTR_TOOL_CALLS_COUNT: &str = "a3s.agent.tool_calls_count";
44
45pub const ATTR_LLM_MODEL: &str = "a3s.llm.model";
46pub const ATTR_LLM_PROVIDER: &str = "a3s.llm.provider";
47pub const ATTR_LLM_STREAMING: &str = "a3s.llm.streaming";
48pub const ATTR_LLM_PROMPT_TOKENS: &str = "a3s.llm.prompt_tokens";
49pub const ATTR_LLM_COMPLETION_TOKENS: &str = "a3s.llm.completion_tokens";
50pub const ATTR_LLM_TOTAL_TOKENS: &str = "a3s.llm.total_tokens";
51pub const ATTR_LLM_STOP_REASON: &str = "a3s.llm.stop_reason";
52
53pub const ATTR_TOOL_NAME: &str = "a3s.tool.name";
54pub const ATTR_TOOL_ID: &str = "a3s.tool.id";
55pub const ATTR_TOOL_EXIT_CODE: &str = "a3s.tool.exit_code";
56pub const ATTR_TOOL_SUCCESS: &str = "a3s.tool.success";
57pub const ATTR_TOOL_DURATION_MS: &str = "a3s.tool.duration_ms";
58pub const ATTR_TOOL_PERMISSION: &str = "a3s.tool.permission";
59
60pub const ATTR_CONTEXT_PROVIDERS: &str = "a3s.context.providers";
61pub const ATTR_CONTEXT_ITEMS: &str = "a3s.context.items";
62pub const ATTR_CONTEXT_TOKENS: &str = "a3s.context.tokens";
63
64// ============================================================================
65// Span Helpers
66// ============================================================================
67
68/// Record LLM token usage on the current span
69pub fn record_llm_usage(
70    prompt_tokens: usize,
71    completion_tokens: usize,
72    total_tokens: usize,
73    stop_reason: Option<&str>,
74) {
75    let span = tracing::Span::current();
76    span.record(ATTR_LLM_PROMPT_TOKENS, prompt_tokens as i64);
77    span.record(ATTR_LLM_COMPLETION_TOKENS, completion_tokens as i64);
78    span.record(ATTR_LLM_TOTAL_TOKENS, total_tokens as i64);
79    if let Some(reason) = stop_reason {
80        span.record(ATTR_LLM_STOP_REASON, reason);
81    }
82}
83
84/// Record tool execution result on the current span
85pub fn record_tool_result(exit_code: i32, duration: std::time::Duration) {
86    let span = tracing::Span::current();
87    span.record(ATTR_TOOL_EXIT_CODE, exit_code as i64);
88    span.record(ATTR_TOOL_SUCCESS, exit_code == 0);
89    span.record(ATTR_TOOL_DURATION_MS, duration.as_millis() as i64);
90}
91
92/// A guard that measures elapsed time and records it when dropped
93pub struct TimedSpan {
94    start: Instant,
95    span_field: &'static str,
96}
97
98impl TimedSpan {
99    pub fn new(span_field: &'static str) -> Self {
100        Self {
101            start: Instant::now(),
102            span_field,
103        }
104    }
105
106    pub fn elapsed_ms(&self) -> u64 {
107        self.start.elapsed().as_millis() as u64
108    }
109}
110
111impl Drop for TimedSpan {
112    fn drop(&mut self) {
113        let elapsed_ms = self.elapsed_ms();
114        let span = tracing::Span::current();
115        span.record(self.span_field, elapsed_ms as i64);
116    }
117}
118
119// ============================================================================
120// LLM Cost Tracking
121// ============================================================================
122
123/// Cost record for a single LLM call
124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
125pub struct LlmCostRecord {
126    /// Model identifier
127    pub model: String,
128    /// Provider name
129    pub provider: String,
130    /// Input tokens
131    pub prompt_tokens: usize,
132    /// Output tokens
133    pub completion_tokens: usize,
134    /// Total tokens
135    pub total_tokens: usize,
136    /// Estimated cost in USD (if pricing is configured)
137    pub cost_usd: Option<f64>,
138    /// Timestamp
139    pub timestamp: chrono::DateTime<chrono::Utc>,
140    /// Session ID
141    pub session_id: Option<String>,
142}
143
144/// Pricing table for LLM models (cost per 1M tokens)
145#[derive(Debug, Clone)]
146pub struct ModelPricing {
147    /// Cost per 1M input tokens in USD
148    pub input_per_million: f64,
149    /// Cost per 1M output tokens in USD
150    pub output_per_million: f64,
151}
152
153impl ModelPricing {
154    pub fn new(input_per_million: f64, output_per_million: f64) -> Self {
155        Self {
156            input_per_million,
157            output_per_million,
158        }
159    }
160
161    /// Calculate cost for given token counts
162    pub fn calculate_cost(&self, prompt_tokens: usize, completion_tokens: usize) -> f64 {
163        let input_cost = (prompt_tokens as f64 / 1_000_000.0) * self.input_per_million;
164        let output_cost = (completion_tokens as f64 / 1_000_000.0) * self.output_per_million;
165        input_cost + output_cost
166    }
167}
168
169/// Registry of known model pricing
170pub fn default_model_pricing() -> std::collections::HashMap<String, ModelPricing> {
171    let mut pricing = std::collections::HashMap::new();
172
173    // Anthropic Claude models
174    pricing.insert(
175        "claude-sonnet-4-20250514".to_string(),
176        ModelPricing::new(3.0, 15.0),
177    );
178    pricing.insert(
179        "claude-3-5-sonnet-20241022".to_string(),
180        ModelPricing::new(3.0, 15.0),
181    );
182    pricing.insert(
183        "claude-3-haiku-20240307".to_string(),
184        ModelPricing::new(0.25, 1.25),
185    );
186    pricing.insert(
187        "claude-3-opus-20240229".to_string(),
188        ModelPricing::new(15.0, 75.0),
189    );
190
191    // OpenAI models
192    pricing.insert("gpt-4o".to_string(), ModelPricing::new(2.5, 10.0));
193    pricing.insert("gpt-4o-mini".to_string(), ModelPricing::new(0.15, 0.6));
194    pricing.insert("gpt-4-turbo".to_string(), ModelPricing::new(10.0, 30.0));
195
196    pricing
197}
198
199// ============================================================================
200// Tool Metrics
201// ============================================================================
202
203/// Per-session tool execution metrics collector
204#[derive(Debug, Clone, Default)]
205pub struct ToolMetrics {
206    /// Per-tool statistics
207    stats: std::collections::HashMap<String, ToolStats>,
208    /// Total calls across all tools
209    total_calls: u64,
210    /// Total duration across all tools
211    total_duration_ms: u64,
212}
213
214/// Statistics for a single tool
215#[derive(Debug, Clone)]
216pub struct ToolStats {
217    pub tool_name: String,
218    pub total_calls: u64,
219    pub success_count: u64,
220    pub failure_count: u64,
221    pub total_duration_ms: u64,
222    pub min_duration_ms: u64,
223    pub max_duration_ms: u64,
224    pub avg_duration_ms: u64,
225    pub last_called_at: Option<chrono::DateTime<chrono::Utc>>,
226}
227
228impl ToolMetrics {
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Record a tool execution
234    pub fn record(&mut self, tool_name: &str, success: bool, duration_ms: u64) {
235        self.total_calls += 1;
236        self.total_duration_ms += duration_ms;
237
238        let entry = self
239            .stats
240            .entry(tool_name.to_string())
241            .or_insert_with(|| ToolStats {
242                tool_name: tool_name.to_string(),
243                total_calls: 0,
244                success_count: 0,
245                failure_count: 0,
246                total_duration_ms: 0,
247                min_duration_ms: u64::MAX,
248                max_duration_ms: 0,
249                avg_duration_ms: 0,
250                last_called_at: None,
251            });
252
253        entry.total_calls += 1;
254        if success {
255            entry.success_count += 1;
256        } else {
257            entry.failure_count += 1;
258        }
259        entry.total_duration_ms += duration_ms;
260        entry.min_duration_ms = entry.min_duration_ms.min(duration_ms);
261        entry.max_duration_ms = entry.max_duration_ms.max(duration_ms);
262        entry.avg_duration_ms = entry.total_duration_ms / entry.total_calls;
263        entry.last_called_at = Some(chrono::Utc::now());
264    }
265
266    /// Get all tool stats
267    pub fn stats(&self) -> Vec<ToolStats> {
268        self.stats.values().cloned().collect()
269    }
270
271    /// Get stats for a specific tool
272    pub fn stats_for(&self, tool_name: &str) -> Vec<ToolStats> {
273        self.stats
274            .get(tool_name)
275            .map(|s| vec![s.clone()])
276            .unwrap_or_default()
277    }
278
279    /// Total calls across all tools
280    pub fn total_calls(&self) -> u64 {
281        self.total_calls
282    }
283
284    /// Total duration across all tools
285    pub fn total_duration_ms(&self) -> u64 {
286        self.total_duration_ms
287    }
288}
289
290// ============================================================================
291// Cost Aggregation
292// ============================================================================
293
294/// Aggregated cost summary
295#[derive(Debug, Clone)]
296pub struct CostSummary {
297    pub total_cost_usd: f64,
298    pub total_prompt_tokens: usize,
299    pub total_completion_tokens: usize,
300    pub total_tokens: usize,
301    pub call_count: usize,
302    pub by_model: Vec<ModelCostBreakdown>,
303    pub by_day: Vec<DayCostBreakdown>,
304}
305
306/// Cost breakdown by model
307#[derive(Debug, Clone)]
308pub struct ModelCostBreakdown {
309    pub model: String,
310    pub prompt_tokens: usize,
311    pub completion_tokens: usize,
312    pub total_tokens: usize,
313    pub cost_usd: f64,
314    pub call_count: usize,
315}
316
317/// Cost breakdown by day
318#[derive(Debug, Clone)]
319pub struct DayCostBreakdown {
320    pub date: String,
321    pub cost_usd: f64,
322    pub call_count: usize,
323    pub total_tokens: usize,
324}
325
326/// Aggregate cost records with optional filters
327pub fn aggregate_cost_records(
328    records: &[LlmCostRecord],
329    model_filter: Option<&str>,
330    start_date: Option<&str>,
331    end_date: Option<&str>,
332) -> CostSummary {
333    let filtered: Vec<&LlmCostRecord> = records
334        .iter()
335        .filter(|r| {
336            if let Some(model) = model_filter {
337                if r.model != model {
338                    return false;
339                }
340            }
341            if let Some(start) = start_date {
342                let date_str = r.timestamp.format("%Y-%m-%d").to_string();
343                if date_str.as_str() < start {
344                    return false;
345                }
346            }
347            if let Some(end) = end_date {
348                let date_str = r.timestamp.format("%Y-%m-%d").to_string();
349                if date_str.as_str() > end {
350                    return false;
351                }
352            }
353            true
354        })
355        .collect();
356
357    let mut by_model_map: std::collections::HashMap<String, ModelCostBreakdown> =
358        std::collections::HashMap::new();
359    let mut by_day_map: std::collections::HashMap<String, DayCostBreakdown> =
360        std::collections::HashMap::new();
361
362    let mut total_cost_usd = 0.0;
363    let mut total_prompt_tokens = 0usize;
364    let mut total_completion_tokens = 0usize;
365    let mut total_tokens = 0usize;
366
367    for record in &filtered {
368        let cost = record.cost_usd.unwrap_or(0.0);
369        total_cost_usd += cost;
370        total_prompt_tokens += record.prompt_tokens;
371        total_completion_tokens += record.completion_tokens;
372        total_tokens += record.total_tokens;
373
374        // By model
375        let model_entry =
376            by_model_map
377                .entry(record.model.clone())
378                .or_insert_with(|| ModelCostBreakdown {
379                    model: record.model.clone(),
380                    prompt_tokens: 0,
381                    completion_tokens: 0,
382                    total_tokens: 0,
383                    cost_usd: 0.0,
384                    call_count: 0,
385                });
386        model_entry.prompt_tokens += record.prompt_tokens;
387        model_entry.completion_tokens += record.completion_tokens;
388        model_entry.total_tokens += record.total_tokens;
389        model_entry.cost_usd += cost;
390        model_entry.call_count += 1;
391
392        // By day
393        let date_str = record.timestamp.format("%Y-%m-%d").to_string();
394        let day_entry = by_day_map
395            .entry(date_str.clone())
396            .or_insert_with(|| DayCostBreakdown {
397                date: date_str,
398                cost_usd: 0.0,
399                call_count: 0,
400                total_tokens: 0,
401            });
402        day_entry.cost_usd += cost;
403        day_entry.call_count += 1;
404        day_entry.total_tokens += record.total_tokens;
405    }
406
407    let mut by_model: Vec<ModelCostBreakdown> = by_model_map.into_values().collect();
408    by_model.sort_by(|a, b| {
409        b.cost_usd
410            .partial_cmp(&a.cost_usd)
411            .unwrap_or(std::cmp::Ordering::Equal)
412    });
413
414    let mut by_day: Vec<DayCostBreakdown> = by_day_map.into_values().collect();
415    by_day.sort_by(|a, b| a.date.cmp(&b.date));
416
417    CostSummary {
418        total_cost_usd,
419        total_prompt_tokens,
420        total_completion_tokens,
421        total_tokens,
422        call_count: filtered.len(),
423        by_model,
424        by_day,
425    }
426}
427
428/// Record LLM metrics via tracing events.
429///
430/// In the server crate (`a3s-code`), this is overridden by `telemetry_init::record_llm_metrics`
431/// which writes to OpenTelemetry counters. In the core library, we emit a tracing event
432/// so the data is available to any subscriber (including OTel if wired up externally).
433pub fn record_llm_metrics(
434    model: &str,
435    prompt_tokens: usize,
436    completion_tokens: usize,
437    cost_usd: f64,
438    duration_secs: f64,
439) {
440    tracing::info!(
441        model = model,
442        prompt_tokens = prompt_tokens,
443        completion_tokens = completion_tokens,
444        total_tokens = prompt_tokens + completion_tokens,
445        cost_usd = cost_usd,
446        duration_secs = duration_secs,
447        "llm.metrics"
448    );
449}
450
451// ============================================================================
452// Tests
453// ============================================================================
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_model_pricing_calculation() {
461        let pricing = ModelPricing::new(3.0, 15.0); // Claude Sonnet pricing
462
463        // 1000 input tokens + 500 output tokens
464        let cost = pricing.calculate_cost(1000, 500);
465        let expected = (1000.0 / 1_000_000.0) * 3.0 + (500.0 / 1_000_000.0) * 15.0;
466        assert!((cost - expected).abs() < f64::EPSILON);
467    }
468
469    #[test]
470    fn test_model_pricing_zero_tokens() {
471        let pricing = ModelPricing::new(3.0, 15.0);
472        let cost = pricing.calculate_cost(0, 0);
473        assert_eq!(cost, 0.0);
474    }
475
476    #[test]
477    fn test_model_pricing_large_tokens() {
478        let pricing = ModelPricing::new(3.0, 15.0);
479        // 1M input + 1M output
480        let cost = pricing.calculate_cost(1_000_000, 1_000_000);
481        assert!((cost - 18.0).abs() < f64::EPSILON); // $3 + $15
482    }
483
484    #[test]
485    fn test_default_model_pricing_has_known_models() {
486        let pricing = default_model_pricing();
487        assert!(pricing.contains_key("claude-sonnet-4-20250514"));
488        assert!(pricing.contains_key("claude-3-5-sonnet-20241022"));
489        assert!(pricing.contains_key("claude-3-haiku-20240307"));
490        assert!(pricing.contains_key("gpt-4o"));
491        assert!(pricing.contains_key("gpt-4o-mini"));
492    }
493
494    #[test]
495    fn test_llm_cost_record_serialize() {
496        let record = LlmCostRecord {
497            model: "claude-sonnet-4-20250514".to_string(),
498            provider: "anthropic".to_string(),
499            prompt_tokens: 1000,
500            completion_tokens: 500,
501            total_tokens: 1500,
502            cost_usd: Some(0.0105),
503            timestamp: chrono::Utc::now(),
504            session_id: Some("sess-123".to_string()),
505        };
506
507        let json = serde_json::to_string(&record).unwrap();
508        assert!(json.contains("claude-sonnet-4-20250514"));
509        assert!(json.contains("anthropic"));
510        assert!(json.contains("1000"));
511
512        // Deserialize back
513        let deserialized: LlmCostRecord = serde_json::from_str(&json).unwrap();
514        assert_eq!(deserialized.model, "claude-sonnet-4-20250514");
515        assert_eq!(deserialized.prompt_tokens, 1000);
516    }
517
518    #[test]
519    fn test_timed_span_elapsed() {
520        let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
521        std::thread::sleep(std::time::Duration::from_millis(10));
522        assert!(timer.elapsed_ms() >= 10);
523    }
524
525    #[test]
526    fn test_span_name_constants() {
527        // Verify span names follow a3s.* convention
528        assert!(SPAN_AGENT_EXECUTE.starts_with("a3s."));
529        assert!(SPAN_AGENT_TURN.starts_with("a3s."));
530        assert!(SPAN_LLM_COMPLETION.starts_with("a3s."));
531        assert!(SPAN_TOOL_EXECUTE.starts_with("a3s."));
532        assert!(SPAN_CONTEXT_RESOLVE.starts_with("a3s."));
533    }
534
535    #[test]
536    fn test_attribute_key_constants() {
537        // Verify attribute keys follow a3s.* convention
538        assert!(ATTR_SESSION_ID.starts_with("a3s."));
539        assert!(ATTR_LLM_MODEL.starts_with("a3s."));
540        assert!(ATTR_TOOL_NAME.starts_with("a3s."));
541        assert!(ATTR_CONTEXT_PROVIDERS.starts_with("a3s."));
542    }
543
544    #[test]
545    fn test_record_llm_usage_does_not_panic() {
546        // record_llm_usage should not panic even without an active span
547        record_llm_usage(100, 50, 150, Some("end_turn"));
548        record_llm_usage(0, 0, 0, None);
549        record_llm_usage(1_000_000, 500_000, 1_500_000, Some("max_tokens"));
550    }
551
552    #[test]
553    fn test_record_tool_result_does_not_panic() {
554        // record_tool_result should not panic even without an active span
555        record_tool_result(0, std::time::Duration::from_millis(100));
556        record_tool_result(1, std::time::Duration::from_secs(0));
557        record_tool_result(-1, std::time::Duration::from_secs(30));
558    }
559
560    #[test]
561    fn test_timed_span_measures_duration() {
562        let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
563        // Immediately check -- should be very small
564        assert!(timer.elapsed_ms() < 1000);
565    }
566
567    #[test]
568    fn test_model_pricing_registry_completeness() {
569        let pricing = default_model_pricing();
570        // Verify all expected providers are covered
571        let anthropic_models: Vec<&str> = pricing
572            .keys()
573            .filter(|k| k.starts_with("claude"))
574            .map(|k| k.as_str())
575            .collect();
576        assert!(
577            anthropic_models.len() >= 3,
578            "Expected at least 3 Anthropic models, got {}",
579            anthropic_models.len()
580        );
581
582        let openai_models: Vec<&str> = pricing
583            .keys()
584            .filter(|k| k.starts_with("gpt"))
585            .map(|k| k.as_str())
586            .collect();
587        assert!(
588            openai_models.len() >= 2,
589            "Expected at least 2 OpenAI models, got {}",
590            openai_models.len()
591        );
592    }
593
594    #[test]
595    fn test_model_pricing_cost_ordering() {
596        let pricing = default_model_pricing();
597        // Haiku should be cheaper than Sonnet
598        let haiku = pricing.get("claude-3-haiku-20240307").unwrap();
599        let sonnet = pricing.get("claude-sonnet-4-20250514").unwrap();
600        assert!(
601            haiku.input_per_million < sonnet.input_per_million,
602            "Haiku should be cheaper than Sonnet"
603        );
604
605        // GPT-4o-mini should be cheaper than GPT-4o
606        let mini = pricing.get("gpt-4o-mini").unwrap();
607        let full = pricing.get("gpt-4o").unwrap();
608        assert!(
609            mini.input_per_million < full.input_per_million,
610            "GPT-4o-mini should be cheaper than GPT-4o"
611        );
612    }
613
614    #[test]
615    fn test_llm_cost_record_fields() {
616        let record = LlmCostRecord {
617            model: "gpt-4o".to_string(),
618            provider: "openai".to_string(),
619            prompt_tokens: 500,
620            completion_tokens: 200,
621            total_tokens: 700,
622            cost_usd: None,
623            timestamp: chrono::Utc::now(),
624            session_id: None,
625        };
626        assert_eq!(
627            record.total_tokens,
628            record.prompt_tokens + record.completion_tokens
629        );
630        assert!(record.cost_usd.is_none());
631        assert!(record.session_id.is_none());
632    }
633
634    #[test]
635    fn test_attribute_keys_are_unique() {
636        // All attribute keys should be distinct
637        let keys = vec![
638            ATTR_SESSION_ID,
639            ATTR_TURN_NUMBER,
640            ATTR_MAX_TURNS,
641            ATTR_TOOL_CALLS_COUNT,
642            ATTR_LLM_MODEL,
643            ATTR_LLM_PROVIDER,
644            ATTR_LLM_STREAMING,
645            ATTR_LLM_PROMPT_TOKENS,
646            ATTR_LLM_COMPLETION_TOKENS,
647            ATTR_LLM_TOTAL_TOKENS,
648            ATTR_LLM_STOP_REASON,
649            ATTR_TOOL_NAME,
650            ATTR_TOOL_ID,
651            ATTR_TOOL_EXIT_CODE,
652            ATTR_TOOL_SUCCESS,
653            ATTR_TOOL_DURATION_MS,
654            ATTR_TOOL_PERMISSION,
655            ATTR_CONTEXT_PROVIDERS,
656            ATTR_CONTEXT_ITEMS,
657            ATTR_CONTEXT_TOKENS,
658        ];
659        let unique: std::collections::HashSet<&str> = keys.iter().copied().collect();
660        assert_eq!(keys.len(), unique.len(), "Attribute keys must be unique");
661    }
662
663    #[test]
664    fn test_model_pricing_new() {
665        let pricing = ModelPricing::new(3.0, 15.0);
666        assert_eq!(pricing.input_per_million, 3.0);
667        assert_eq!(pricing.output_per_million, 15.0);
668    }
669
670    #[test]
671    fn test_model_pricing_calculate_cost_zero() {
672        let pricing = ModelPricing::new(3.0, 15.0);
673        let cost = pricing.calculate_cost(0, 0);
674        assert_eq!(cost, 0.0);
675    }
676
677    #[test]
678    fn test_model_pricing_calculate_cost_large() {
679        let pricing = ModelPricing::new(3.0, 15.0);
680        let cost = pricing.calculate_cost(1_000_000, 1_000_000);
681        assert!((cost - 18.0).abs() < f64::EPSILON);
682    }
683
684    #[test]
685    fn test_model_pricing_calculate_cost_fractional() {
686        let pricing = ModelPricing::new(3.0, 15.0);
687        let cost = pricing.calculate_cost(500, 250);
688        let expected = (500.0 / 1_000_000.0) * 3.0 + (250.0 / 1_000_000.0) * 15.0;
689        assert!((cost - expected).abs() < f64::EPSILON);
690    }
691
692    #[test]
693    fn test_model_pricing_clone() {
694        let pricing = ModelPricing::new(3.0, 15.0);
695        let cloned = pricing.clone();
696        assert_eq!(cloned.input_per_million, 3.0);
697        assert_eq!(cloned.output_per_million, 15.0);
698    }
699
700    #[test]
701    fn test_model_pricing_debug() {
702        let pricing = ModelPricing::new(3.0, 15.0);
703        let debug_str = format!("{:?}", pricing);
704        assert!(debug_str.contains("ModelPricing"));
705        assert!(debug_str.contains("3.0"));
706        assert!(debug_str.contains("15.0"));
707    }
708
709    #[test]
710    fn test_default_model_pricing_all_positive() {
711        let pricing = default_model_pricing();
712        for (model, price) in pricing.iter() {
713            assert!(
714                price.input_per_million > 0.0,
715                "Model {} has non-positive input cost",
716                model
717            );
718            assert!(
719                price.output_per_million > 0.0,
720                "Model {} has non-positive output cost",
721                model
722            );
723        }
724    }
725
726    #[test]
727    fn test_default_model_pricing_output_greater_than_input() {
728        let pricing = default_model_pricing();
729        for (model, price) in pricing.iter() {
730            assert!(
731                price.output_per_million > price.input_per_million,
732                "Model {} output cost should be greater than input cost",
733                model
734            );
735        }
736    }
737
738    #[test]
739    fn test_default_model_pricing_claude_sonnet_4() {
740        let pricing = default_model_pricing();
741        let sonnet = pricing.get("claude-sonnet-4-20250514").unwrap();
742        assert_eq!(sonnet.input_per_million, 3.0);
743        assert_eq!(sonnet.output_per_million, 15.0);
744    }
745
746    #[test]
747    fn test_default_model_pricing_claude_haiku() {
748        let pricing = default_model_pricing();
749        let haiku = pricing.get("claude-3-haiku-20240307").unwrap();
750        assert_eq!(haiku.input_per_million, 0.25);
751        assert_eq!(haiku.output_per_million, 1.25);
752    }
753
754    #[test]
755    fn test_default_model_pricing_gpt4o() {
756        let pricing = default_model_pricing();
757        let gpt4o = pricing.get("gpt-4o").unwrap();
758        assert_eq!(gpt4o.input_per_million, 2.5);
759        assert_eq!(gpt4o.output_per_million, 10.0);
760    }
761
762    #[test]
763    fn test_llm_cost_record_with_cost() {
764        let record = LlmCostRecord {
765            model: "claude-sonnet-4-20250514".to_string(),
766            provider: "anthropic".to_string(),
767            prompt_tokens: 1000,
768            completion_tokens: 500,
769            total_tokens: 1500,
770            cost_usd: Some(0.0105),
771            timestamp: chrono::Utc::now(),
772            session_id: Some("sess-123".to_string()),
773        };
774        assert_eq!(record.cost_usd, Some(0.0105));
775    }
776
777    #[test]
778    fn test_llm_cost_record_without_cost() {
779        let record = LlmCostRecord {
780            model: "unknown-model".to_string(),
781            provider: "unknown".to_string(),
782            prompt_tokens: 100,
783            completion_tokens: 50,
784            total_tokens: 150,
785            cost_usd: None,
786            timestamp: chrono::Utc::now(),
787            session_id: None,
788        };
789        assert!(record.cost_usd.is_none());
790    }
791
792    #[test]
793    fn test_llm_cost_record_with_session() {
794        let record = LlmCostRecord {
795            model: "gpt-4o".to_string(),
796            provider: "openai".to_string(),
797            prompt_tokens: 500,
798            completion_tokens: 200,
799            total_tokens: 700,
800            cost_usd: Some(0.003),
801            timestamp: chrono::Utc::now(),
802            session_id: Some("session-abc".to_string()),
803        };
804        assert_eq!(record.session_id, Some("session-abc".to_string()));
805    }
806
807    #[test]
808    fn test_llm_cost_record_without_session() {
809        let record = LlmCostRecord {
810            model: "gpt-4o-mini".to_string(),
811            provider: "openai".to_string(),
812            prompt_tokens: 100,
813            completion_tokens: 50,
814            total_tokens: 150,
815            cost_usd: Some(0.00006),
816            timestamp: chrono::Utc::now(),
817            session_id: None,
818        };
819        assert!(record.session_id.is_none());
820    }
821
822    #[test]
823    fn test_llm_cost_record_serialization() {
824        let record = LlmCostRecord {
825            model: "claude-sonnet-4-20250514".to_string(),
826            provider: "anthropic".to_string(),
827            prompt_tokens: 1000,
828            completion_tokens: 500,
829            total_tokens: 1500,
830            cost_usd: Some(0.0105),
831            timestamp: chrono::Utc::now(),
832            session_id: Some("sess-123".to_string()),
833        };
834        let json = serde_json::to_string(&record).unwrap();
835        assert!(json.contains("claude-sonnet-4-20250514"));
836        assert!(json.contains("anthropic"));
837        assert!(json.contains("1000"));
838        assert!(json.contains("500"));
839    }
840
841    #[test]
842    fn test_llm_cost_record_zero_tokens() {
843        let record = LlmCostRecord {
844            model: "test-model".to_string(),
845            provider: "test".to_string(),
846            prompt_tokens: 0,
847            completion_tokens: 0,
848            total_tokens: 0,
849            cost_usd: Some(0.0),
850            timestamp: chrono::Utc::now(),
851            session_id: None,
852        };
853        assert_eq!(record.prompt_tokens, 0);
854        assert_eq!(record.completion_tokens, 0);
855        assert_eq!(record.total_tokens, 0);
856    }
857
858    #[test]
859    fn test_llm_cost_record_clone() {
860        let record = LlmCostRecord {
861            model: "gpt-4o".to_string(),
862            provider: "openai".to_string(),
863            prompt_tokens: 100,
864            completion_tokens: 50,
865            total_tokens: 150,
866            cost_usd: Some(0.001),
867            timestamp: chrono::Utc::now(),
868            session_id: Some("sess-xyz".to_string()),
869        };
870        let cloned = record.clone();
871        assert_eq!(cloned.model, "gpt-4o");
872        assert_eq!(cloned.provider, "openai");
873        assert_eq!(cloned.prompt_tokens, 100);
874    }
875
876    #[test]
877    fn test_timed_span_new() {
878        let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
879        assert!(timer.elapsed_ms() < 100);
880    }
881
882    #[test]
883    fn test_timed_span_elapsed_sleep() {
884        let timer = TimedSpan::new(ATTR_TOOL_DURATION_MS);
885        std::thread::sleep(std::time::Duration::from_millis(10));
886        assert!(timer.elapsed_ms() >= 10);
887    }
888
889    #[test]
890    fn test_service_name_constant() {
891        assert_eq!(SERVICE_NAME, "a3s-code");
892    }
893
894    #[test]
895    fn test_span_constants() {
896        assert_eq!(SPAN_AGENT_EXECUTE, "a3s.agent.execute");
897        assert_eq!(SPAN_AGENT_TURN, "a3s.agent.turn");
898        assert_eq!(SPAN_LLM_COMPLETION, "a3s.llm.completion");
899        assert_eq!(SPAN_TOOL_EXECUTE, "a3s.tool.execute");
900        assert_eq!(SPAN_CONTEXT_RESOLVE, "a3s.agent.context_resolve");
901    }
902
903    #[test]
904    fn test_attribute_constants_session() {
905        assert_eq!(ATTR_SESSION_ID, "a3s.session.id");
906        assert_eq!(ATTR_TURN_NUMBER, "a3s.agent.turn_number");
907        assert_eq!(ATTR_MAX_TURNS, "a3s.agent.max_turns");
908        assert_eq!(ATTR_TOOL_CALLS_COUNT, "a3s.agent.tool_calls_count");
909    }
910
911    #[test]
912    fn test_attribute_constants_llm() {
913        assert_eq!(ATTR_LLM_MODEL, "a3s.llm.model");
914        assert_eq!(ATTR_LLM_PROVIDER, "a3s.llm.provider");
915        assert_eq!(ATTR_LLM_STREAMING, "a3s.llm.streaming");
916        assert_eq!(ATTR_LLM_PROMPT_TOKENS, "a3s.llm.prompt_tokens");
917        assert_eq!(ATTR_LLM_COMPLETION_TOKENS, "a3s.llm.completion_tokens");
918        assert_eq!(ATTR_LLM_TOTAL_TOKENS, "a3s.llm.total_tokens");
919        assert_eq!(ATTR_LLM_STOP_REASON, "a3s.llm.stop_reason");
920    }
921
922    #[test]
923    fn test_attribute_constants_tool() {
924        assert_eq!(ATTR_TOOL_NAME, "a3s.tool.name");
925        assert_eq!(ATTR_TOOL_ID, "a3s.tool.id");
926        assert_eq!(ATTR_TOOL_EXIT_CODE, "a3s.tool.exit_code");
927        assert_eq!(ATTR_TOOL_SUCCESS, "a3s.tool.success");
928        assert_eq!(ATTR_TOOL_DURATION_MS, "a3s.tool.duration_ms");
929        assert_eq!(ATTR_TOOL_PERMISSION, "a3s.tool.permission");
930    }
931
932    #[test]
933    fn test_attribute_constants_context() {
934        assert_eq!(ATTR_CONTEXT_PROVIDERS, "a3s.context.providers");
935        assert_eq!(ATTR_CONTEXT_ITEMS, "a3s.context.items");
936        assert_eq!(ATTR_CONTEXT_TOKENS, "a3s.context.tokens");
937    }
938
939    #[test]
940    fn test_record_llm_usage_basic() {
941        record_llm_usage(100, 50, 150, Some("end_turn"));
942    }
943
944    #[test]
945    fn test_record_llm_usage_no_stop_reason() {
946        record_llm_usage(100, 50, 150, None);
947    }
948
949    #[test]
950    fn test_record_tool_result_success() {
951        record_tool_result(0, std::time::Duration::from_millis(100));
952    }
953
954    #[test]
955    fn test_record_tool_result_failure() {
956        record_tool_result(1, std::time::Duration::from_secs(5));
957    }
958}