Skip to main content

aptu_coder/
metrics.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Metrics collection and daily-rotating JSONL emission.
4//!
5//! Provides a channel-based pipeline: callers emit [`MetricEvent`] values via [`MetricsSender`],
6//! and [`MetricsWriter`] drains the channel and appends events to a daily-rotated JSONL file
7//! under the XDG data directory (`~/.local/share/aptu-coder/metrics-YYYY-MM-DD.jsonl`).
8//! Files older than 30 days are deleted on startup.
9
10// Re-export types from metrics_export so the lib.rs re-export chain stays intact.
11pub use crate::metrics_export::MetricsWriter;
12pub use crate::metrics_export::migrate_legacy_metrics_dir;
13// Re-export helpers used by tool handlers via crate::metrics::*
14pub(crate) use crate::metrics_export::{
15    path_component_count, path_file_ext, path_language, unix_ms,
16};
17
18use opentelemetry::metrics::{Counter, Histogram};
19use opentelemetry::{KeyValue, global};
20use rmcp::model::ProtocolVersion;
21use serde::{Deserialize, Serialize};
22use std::sync::OnceLock;
23
24/// A single metric event emitted by a tool invocation.
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26#[serde(default)]
27pub struct MetricEvent {
28    pub ts: u64,
29    pub tool: &'static str,
30    pub duration_ms: u64,
31    pub output_chars: usize,
32    pub param_path_depth: usize,
33    pub max_depth: Option<u32>,
34    pub result: &'static str,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub error_type: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub error_subtype: Option<String>,
39    #[serde(default)]
40    pub session_id: Option<String>,
41    #[serde(default)]
42    pub seq: Option<u32>,
43    #[serde(default)]
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub cache_hit: Option<bool>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub cache_tier: Option<&'static str>,
48    /// Set to Some(true) when an L2 disk cache write fails (dir, tempfile, write, or rename).
49    /// Drives the cache_write_failures_total OTEL counter.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub cache_write_failure: Option<bool>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub exit_code: Option<i32>,
54    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55    pub timed_out: bool,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub output_truncated: Option<bool>,
58    /// True when `output_chars > 30_000`; fires for the top ~0.33% of exec_command calls
59    /// (p99.7 of 27,981 observed calls). Early-warning signal for responses approaching
60    /// the per-stream byte-cap threshold.
61    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62    pub chars_threshold_breach: bool,
63    /// File extension of the analyzed path, lowercased. `Some("rs")` for known extensions,
64    /// `Some("other")` for unrecognized extensions, `None` when the path has no extension.
65    /// Only populated for `analyze_file` and `analyze_module`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub file_ext: Option<&'static str>,
68    /// Name of the filter rule that matched and transformed exec_command output.
69    /// `None` when no filter fired or for non-`exec_command` tools.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub filter_applied: Option<String>,
72    /// Human-readable programming language name derived from the file extension
73    /// (e.g., `Some("Rust")` for `.rs` files). `None` when the path has no extension
74    /// or the extension is not recognized. Only populated for `analyze_file` and
75    /// `analyze_module`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub language: Option<String>,
78    /// Whether the tool call used a `git_ref` parameter. Populated by `analyze_directory`
79    /// and `analyze_symbol`.
80    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
81    pub git_ref_used: bool,
82    /// Whether the tool call used `summary=true` or was auto-summarized.
83    /// Populated by `analyze_directory`, `analyze_file`, and `analyze_symbol`.
84    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
85    pub summary_mode: bool,
86    /// Whether the tool call used pagination (`cursor` was provided).
87    /// Populated by `analyze_directory`, `analyze_file`, and `analyze_symbol`.
88    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
89    pub is_paginated: bool,
90    /// Whether the `fields` parameter was provided to `analyze_file`.
91    /// Populated by `analyze_file`.
92    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93    pub fields_projected: bool,
94    /// Symbol matching mode used by `analyze_symbol` (e.g., "exact", "insensitive").
95    /// `None` when not an `analyze_symbol` call.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub match_mode: Option<String>,
98    /// Call graph traversal depth for `analyze_symbol` (default 1).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub follow_depth: Option<u32>,
101    /// Whether `import_lookup=true` was set on `analyze_symbol`.
102    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103    pub import_lookup: bool,
104    /// Whether `def_use=true` was set on `analyze_symbol`.
105    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
106    pub def_use: bool,
107    /// Whether `impl_only=true` was set on `analyze_symbol`.
108    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109    pub impl_only: bool,
110    /// Whether `stdin` was provided to `exec_command`.
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub stdin_provided: bool,
113    /// Configured timeout in milliseconds for `exec_command`. `None` means no limit.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub timeout_configured_ms: Option<i64>,
116    /// Drain timeout in milliseconds for `exec_command`. `None` means default (500ms).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub drain_timeout_ms: Option<i64>,
119    /// Whether a `working_dir` parameter was provided. Populated by `edit_overwrite`,
120    /// `edit_replace`, and `exec_command`.
121    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122    pub working_dir_used: bool,
123    /// L1 cache eviction count at the time of metric emission. Only populated for cache-related metrics.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub l1_eviction_count: Option<u64>,
126    /// L2 disk cache entry count at the time of metric emission. Only populated for cache-related metrics.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub l2_entry_count: Option<u64>,
129    /// L2 disk cache total size in bytes at the time of metric emission. Only populated for cache-related metrics.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub l2_size_bytes: Option<u64>,
132    /// Approximate stdout bytes read before any truncation, counted as `line.len() + 1`
133    /// per `LinesStream` line (last line and CRLF not exact). Only populated for
134    /// `exec_command` when `output_truncated=true`, `timed_out=false`, and no drain-abort.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub stdout_bytes_raw: Option<u64>,
137    /// Approximate stderr bytes read before any truncation, counted as `line.len() + 1`
138    /// per `LinesStream` line (last line and CRLF not exact). Only populated for
139    /// `exec_command` when `output_truncated=true`, `timed_out=false`, and no drain-abort.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub stderr_bytes_raw: Option<u64>,
142}
143
144/// Fluent builder for MetricEvent. Reduces repetitive struct literal boilerplate.
145#[derive(Debug, Default)]
146pub(crate) struct MetricEventBuilder {
147    ts: u64,
148    tool: &'static str,
149    duration_ms: u64,
150    output_chars: usize,
151    param_path_depth: usize,
152    max_depth: Option<u32>,
153    result: &'static str,
154    error_type: Option<String>,
155    error_subtype: Option<String>,
156    session_id: Option<String>,
157    seq: Option<u32>,
158    cache_hit: Option<bool>,
159    cache_write_failure: Option<bool>,
160    cache_tier: Option<&'static str>,
161    exit_code: Option<i32>,
162    timed_out: bool,
163    output_truncated: Option<bool>,
164    chars_threshold_breach: bool,
165    file_ext: Option<&'static str>,
166    filter_applied: Option<String>,
167    language: Option<String>,
168    git_ref_used: bool,
169    summary_mode: bool,
170    is_paginated: bool,
171    fields_projected: bool,
172    match_mode: Option<String>,
173    follow_depth: Option<u32>,
174    import_lookup: bool,
175    def_use: bool,
176    impl_only: bool,
177    stdin_provided: bool,
178    timeout_configured_ms: Option<i64>,
179    drain_timeout_ms: Option<i64>,
180    working_dir_used: bool,
181    l1_eviction_count: Option<u64>,
182    l2_entry_count: Option<u64>,
183    l2_size_bytes: Option<u64>,
184    stdout_bytes_raw: Option<u64>,
185    stderr_bytes_raw: Option<u64>,
186}
187
188#[allow(clippy::too_many_arguments)]
189impl MetricEventBuilder {
190    #[must_use]
191    pub(crate) fn new(tool: &'static str, result: &'static str, duration_ms: u64) -> Self {
192        Self {
193            ts: unix_ms(),
194            tool,
195            result,
196            duration_ms,
197            ..Self::default()
198        }
199    }
200
201    #[must_use]
202    pub(crate) fn output_chars(mut self, v: usize) -> Self {
203        self.output_chars = v;
204        self
205    }
206    #[must_use]
207    pub(crate) fn param_path_depth(mut self, v: usize) -> Self {
208        self.param_path_depth = v;
209        self
210    }
211    #[must_use]
212    pub(crate) fn max_depth(mut self, v: Option<u32>) -> Self {
213        self.max_depth = v;
214        self
215    }
216    #[must_use]
217    pub(crate) fn error_type(mut self, v: Option<String>) -> Self {
218        self.error_type = v;
219        self
220    }
221    #[must_use]
222    pub(crate) fn error_subtype(mut self, v: Option<String>) -> Self {
223        self.error_subtype = v;
224        self
225    }
226    #[must_use]
227    pub(crate) fn session_id(mut self, v: Option<String>) -> Self {
228        self.session_id = v;
229        self
230    }
231    #[must_use]
232    pub(crate) fn seq(mut self, v: Option<u32>) -> Self {
233        self.seq = v;
234        self
235    }
236    #[must_use]
237    pub(crate) fn cache_hit(mut self, v: Option<bool>) -> Self {
238        self.cache_hit = v;
239        self
240    }
241    #[must_use]
242    pub(crate) fn cache_tier(mut self, v: Option<&'static str>) -> Self {
243        self.cache_tier = v;
244        self
245    }
246    #[must_use]
247    pub(crate) fn cache_write_failure(mut self, v: Option<bool>) -> Self {
248        self.cache_write_failure = v;
249        self
250    }
251    #[must_use]
252    pub(crate) fn exit_code(mut self, v: Option<i32>) -> Self {
253        self.exit_code = v;
254        self
255    }
256    #[must_use]
257    pub(crate) fn timed_out(mut self, v: bool) -> Self {
258        self.timed_out = v;
259        self
260    }
261    #[must_use]
262    pub(crate) fn output_truncated(mut self, v: Option<bool>) -> Self {
263        self.output_truncated = v;
264        self
265    }
266    #[must_use]
267    pub(crate) fn chars_threshold_breach(mut self, v: bool) -> Self {
268        self.chars_threshold_breach = v;
269        self
270    }
271    #[must_use]
272    pub(crate) fn file_ext(mut self, v: Option<&'static str>) -> Self {
273        self.file_ext = v;
274        self
275    }
276    #[must_use]
277    pub(crate) fn filter_applied(mut self, v: Option<String>) -> Self {
278        self.filter_applied = v;
279        self
280    }
281    #[must_use]
282    pub(crate) fn language(mut self, v: Option<String>) -> Self {
283        self.language = v;
284        self
285    }
286    #[must_use]
287    pub(crate) fn git_ref_used(mut self, v: bool) -> Self {
288        self.git_ref_used = v;
289        self
290    }
291    #[must_use]
292    pub(crate) fn summary_mode(mut self, v: bool) -> Self {
293        self.summary_mode = v;
294        self
295    }
296    #[must_use]
297    #[allow(clippy::wrong_self_convention)]
298    pub(crate) fn is_paginated(mut self, v: bool) -> Self {
299        self.is_paginated = v;
300        self
301    }
302    #[must_use]
303    pub(crate) fn fields_projected(mut self, v: bool) -> Self {
304        self.fields_projected = v;
305        self
306    }
307    #[must_use]
308    pub(crate) fn match_mode(mut self, v: Option<String>) -> Self {
309        self.match_mode = v;
310        self
311    }
312    #[must_use]
313    pub(crate) fn follow_depth(mut self, v: Option<u32>) -> Self {
314        self.follow_depth = v;
315        self
316    }
317    #[must_use]
318    pub(crate) fn import_lookup(mut self, v: bool) -> Self {
319        self.import_lookup = v;
320        self
321    }
322    #[must_use]
323    pub(crate) fn def_use(mut self, v: bool) -> Self {
324        self.def_use = v;
325        self
326    }
327    #[must_use]
328    pub(crate) fn impl_only(mut self, v: bool) -> Self {
329        self.impl_only = v;
330        self
331    }
332    #[must_use]
333    pub(crate) fn stdin_provided(mut self, v: bool) -> Self {
334        self.stdin_provided = v;
335        self
336    }
337    #[must_use]
338    pub(crate) fn timeout_configured_ms(mut self, v: Option<i64>) -> Self {
339        self.timeout_configured_ms = v;
340        self
341    }
342    #[must_use]
343    pub(crate) fn drain_timeout_ms(mut self, v: Option<i64>) -> Self {
344        self.drain_timeout_ms = v;
345        self
346    }
347    #[must_use]
348    pub(crate) fn working_dir_used(mut self, v: bool) -> Self {
349        self.working_dir_used = v;
350        self
351    }
352    #[must_use]
353    pub(crate) fn l1_eviction_count(mut self, v: Option<u64>) -> Self {
354        self.l1_eviction_count = v;
355        self
356    }
357    #[must_use]
358    pub(crate) fn l2_entry_count(mut self, v: Option<u64>) -> Self {
359        self.l2_entry_count = v;
360        self
361    }
362    #[must_use]
363    pub(crate) fn l2_size_bytes(mut self, v: Option<u64>) -> Self {
364        self.l2_size_bytes = v;
365        self
366    }
367    #[must_use]
368    pub(crate) fn stdout_bytes_raw(mut self, v: u64) -> Self {
369        self.stdout_bytes_raw = Some(v);
370        self
371    }
372    #[must_use]
373    pub(crate) fn stderr_bytes_raw(mut self, v: u64) -> Self {
374        self.stderr_bytes_raw = Some(v);
375        self
376    }
377    #[must_use]
378    pub(crate) fn build(self) -> MetricEvent {
379        MetricEvent {
380            ts: self.ts,
381            tool: self.tool,
382            duration_ms: self.duration_ms,
383            output_chars: self.output_chars,
384            param_path_depth: self.param_path_depth,
385            max_depth: self.max_depth,
386            result: self.result,
387            error_type: self.error_type,
388            error_subtype: self.error_subtype,
389            session_id: self.session_id,
390            seq: self.seq,
391            cache_hit: self.cache_hit,
392            cache_write_failure: self.cache_write_failure,
393            cache_tier: self.cache_tier,
394            exit_code: self.exit_code,
395            timed_out: self.timed_out,
396            output_truncated: self.output_truncated,
397            chars_threshold_breach: self.chars_threshold_breach,
398            file_ext: self.file_ext,
399            filter_applied: self.filter_applied,
400            language: self.language,
401            git_ref_used: self.git_ref_used,
402            summary_mode: self.summary_mode,
403            is_paginated: self.is_paginated,
404            fields_projected: self.fields_projected,
405            match_mode: self.match_mode,
406            follow_depth: self.follow_depth,
407            import_lookup: self.import_lookup,
408            def_use: self.def_use,
409            impl_only: self.impl_only,
410            stdin_provided: self.stdin_provided,
411            timeout_configured_ms: self.timeout_configured_ms,
412            drain_timeout_ms: self.drain_timeout_ms,
413            working_dir_used: self.working_dir_used,
414            l1_eviction_count: self.l1_eviction_count,
415            l2_entry_count: self.l2_entry_count,
416            l2_size_bytes: self.l2_size_bytes,
417            stdout_bytes_raw: self.stdout_bytes_raw,
418            stderr_bytes_raw: self.stderr_bytes_raw,
419        }
420    }
421}
422
423/// Sender half of the metrics channel; cloned and passed to tools for event emission.
424#[derive(Clone)]
425pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
426
427impl MetricsSender {
428    pub fn send(&self, event: MetricEvent) {
429        let _ = self.0.send(event);
430    }
431}
432
433/// Accumulated metrics for a single tool.
434#[derive(Default, Debug)]
435pub(crate) struct ToolMetrics {
436    pub(crate) count: u64,
437    pub(crate) duration_ms: u64,
438    pub(crate) output_chars: u64,
439}
440
441/// RAII guard that releases an exclusive lock on a metrics .lock file when dropped.
442/// Lock release happens implicitly when the underlying `std::fs::File` is closed.
443#[allow(dead_code)]
444pub(crate) struct MetricsLockGuard(pub(crate) std::fs::File);
445
446/// Record a metric event to OTel metrics if the global meter provider is available.
447///
448/// Records:
449/// - Histogram: mcp.server.operation.duration (in milliseconds)
450/// - Counter: mcp.server.tool.calls (incremented by 1)
451///
452/// Labels: gen_ai.tool.name, error.type (or "none" if no error)
453///
454/// Instruments are initialized once via OnceLock to avoid rebuilding them on every call.
455pub(crate) fn record_otel_metrics(event: &MetricEvent) {
456    // Skip OTEL recording for "received" events (duration_ms=0 would pollute latency histograms)
457    if event.result == "received" {
458        return;
459    }
460
461    static DURATION_HISTOGRAM: OnceLock<Histogram<f64>> = OnceLock::new();
462    static CALL_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
463    static CACHE_HITS_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
464    static CACHE_WRITE_FAILURES_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
465
466    let histogram = DURATION_HISTOGRAM.get_or_init(|| {
467        global::meter("aptu-coder")
468            .f64_histogram("mcp.server.operation.duration")
469            .with_unit("s")
470            .with_boundaries(vec![
471                0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
472            ])
473            .build()
474    });
475
476    let counter = CALL_COUNTER.get_or_init(|| {
477        global::meter("aptu-coder")
478            .u64_counter("mcp.server.tool.calls")
479            .build()
480    });
481
482    let cache_hits_counter = CACHE_HITS_COUNTER.get_or_init(|| {
483        global::meter("aptu-coder")
484            .u64_counter("mcp.server.tool.cache_hits_total")
485            .with_description("Number of tool responses served from cache (l1_memory or l2_disk)")
486            .build()
487    });
488
489    let cache_write_failures_counter = CACHE_WRITE_FAILURES_COUNTER.get_or_init(|| {
490        global::meter("aptu-coder")
491            .u64_counter("mcp.server.tool.cache_write_failures_total")
492            .with_description(
493                "Number of L2 disk cache write failures (dir, tempfile, write, rename)",
494            )
495            .build()
496    });
497
498    let error_type = event.error_type.as_deref().unwrap_or("success");
499    let attributes = [
500        KeyValue::new("gen_ai.tool.name", event.tool),
501        KeyValue::new("error.type", error_type.to_string()),
502        KeyValue::new("mcp.method.name", "tools/call"),
503        KeyValue::new("mcp.protocol.version", ProtocolVersion::LATEST.as_str()),
504        KeyValue::new("network.transport", "pipe"),
505    ];
506
507    histogram.record(event.duration_ms as f64 / 1000.0, &attributes);
508    counter.add(1, &attributes);
509
510    if event.cache_hit == Some(true) {
511        let tier = event.cache_tier.unwrap_or("unknown");
512        cache_hits_counter.add(
513            1,
514            &[
515                KeyValue::new("gen_ai.tool.name", event.tool),
516                KeyValue::new("cache_tier", tier),
517            ],
518        );
519    }
520
521    if event.cache_write_failure == Some(true) {
522        cache_write_failures_counter.add(1, &[KeyValue::new("gen_ai.tool.name", event.tool)]);
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_metric_event_serialization() {
532        let event = MetricEvent {
533            ts: 1_700_000_000_000,
534            tool: "analyze_directory",
535            duration_ms: 100,
536            output_chars: 500,
537            param_path_depth: 1,
538            max_depth: None,
539            result: "ok",
540            error_type: None,
541            error_subtype: None,
542            session_id: Some("1742468880123-42".to_string()),
543            seq: Some(5),
544            cache_hit: None,
545            cache_write_failure: None,
546            cache_tier: None,
547            exit_code: Some(0),
548            timed_out: false,
549            output_truncated: None,
550            chars_threshold_breach: false,
551            file_ext: None,
552            ..Default::default()
553        };
554        let serialized = serde_json::to_string(&event).unwrap();
555        assert!(serialized.contains(r#""ts":1700000000000"#));
556        assert!(serialized.contains(r#""tool":"analyze_directory""#));
557        assert!(serialized.contains(r#""session_id":"1742468880123-42""#));
558        assert!(serialized.contains(r#""exit_code":0"#));
559    }
560
561    #[test]
562    fn test_metric_event_serialization_error() {
563        let event = MetricEvent {
564            ts: 1_700_000_000_000,
565            tool: "edit_replace",
566            duration_ms: 10,
567            output_chars: 0,
568            param_path_depth: 2,
569            max_depth: None,
570            result: "error",
571            error_type: Some("invalid_params".to_string()),
572            session_id: None,
573            seq: None,
574            cache_hit: None,
575            cache_write_failure: None,
576            exit_code: None,
577            timed_out: false,
578            cache_tier: None,
579            output_truncated: None,
580            chars_threshold_breach: false,
581            file_ext: None,
582            ..Default::default()
583        };
584        let json = serde_json::to_string(&event).unwrap();
585        assert!(json.contains(r#""error_type":"invalid_params""#));
586    }
587
588    #[test]
589    fn test_metric_event_error_subtype_some_serializes() {
590        let event = MetricEvent {
591            ts: 1_700_000_000_000,
592            tool: "edit_replace",
593            duration_ms: 10,
594            output_chars: 0,
595            param_path_depth: 2,
596            max_depth: None,
597            result: "error",
598            error_type: Some("invalid_params".to_string()),
599            error_subtype: Some("not_found".to_string()),
600            session_id: None,
601            seq: None,
602            cache_hit: None,
603            cache_write_failure: None,
604            exit_code: None,
605            timed_out: false,
606            cache_tier: None,
607            output_truncated: None,
608            chars_threshold_breach: false,
609            file_ext: None,
610            ..Default::default()
611        };
612        let json = serde_json::to_string(&event).unwrap();
613        assert!(json.contains(r#""error_subtype":"not_found""#));
614    }
615
616    #[test]
617    fn test_metric_event_error_subtype_ambiguous() {
618        let event = MetricEvent {
619            ts: 1_700_000_000_000,
620            tool: "edit_replace",
621            duration_ms: 10,
622            output_chars: 0,
623            param_path_depth: 2,
624            max_depth: None,
625            result: "error",
626            error_type: Some("invalid_params".to_string()),
627            error_subtype: Some("ambiguous".to_string()),
628            session_id: None,
629            seq: None,
630            cache_hit: None,
631            cache_write_failure: None,
632            exit_code: None,
633            timed_out: false,
634            cache_tier: None,
635            output_truncated: None,
636            chars_threshold_breach: false,
637            file_ext: None,
638            ..Default::default()
639        };
640        let json = serde_json::to_string(&event).unwrap();
641        assert!(json.contains(r#""error_subtype":"ambiguous""#));
642    }
643
644    #[test]
645    fn test_metric_event_new_fields_round_trip() {
646        let event = MetricEvent {
647            ts: 1_700_000_000_000,
648            tool: "analyze_file",
649            duration_ms: 100,
650            output_chars: 500,
651            param_path_depth: 2,
652            max_depth: Some(3),
653            result: "ok",
654            error_type: None,
655            error_subtype: None,
656            session_id: Some("1742468880123-42".to_string()),
657            seq: Some(5),
658            cache_hit: None,
659            cache_write_failure: None,
660            exit_code: None,
661            timed_out: false,
662            cache_tier: None,
663            output_truncated: None,
664            chars_threshold_breach: false,
665            file_ext: None,
666            filter_applied: None,
667            language: None,
668            git_ref_used: false,
669            summary_mode: false,
670            is_paginated: false,
671            fields_projected: false,
672            match_mode: None,
673            follow_depth: None,
674            import_lookup: false,
675            def_use: false,
676            impl_only: false,
677            stdin_provided: false,
678            timeout_configured_ms: None,
679            drain_timeout_ms: None,
680            working_dir_used: false,
681            l1_eviction_count: None,
682            l2_entry_count: None,
683            l2_size_bytes: None,
684            stdout_bytes_raw: None,
685            stderr_bytes_raw: None,
686        };
687        let serialized = serde_json::to_string(&event).unwrap();
688        let json_str = r#"{"ts":1700000000000,"tool":"analyze_file","duration_ms":100,"output_chars":500,"param_path_depth":2,"max_depth":3,"result":"ok","session_id":"1742468880123-42","seq":5}"#;
689        assert_eq!(serialized, json_str);
690    }
691}
692
693#[test]
694fn test_metric_event_builder_raw_bytes_serialize() {
695    // Happy path: MetricEventBuilder stdout_bytes_raw and stderr_bytes_raw
696    // methods emit correct JSON with skip_serializing_if when set.
697    let event = MetricEventBuilder::new("exec_command", "ok", 100)
698        .stdout_bytes_raw(12345)
699        .stderr_bytes_raw(6789)
700        .build();
701    let json = serde_json::to_string(&event).unwrap();
702    assert!(json.contains(r#""stdout_bytes_raw":12345"#));
703    assert!(json.contains(r#""stderr_bytes_raw":6789"#));
704}
705
706#[test]
707fn test_metric_event_builder_raw_bytes_skip_when_none() {
708    // Happy path: when stdout_bytes_raw/stderr_bytes_raw are None,
709    // they are omitted from JSON (skip_serializing_if).
710    let event = MetricEventBuilder::new("exec_command", "ok", 100).build();
711    let json = serde_json::to_string(&event).unwrap();
712    assert!(!json.contains("stdout_bytes_raw"));
713    assert!(!json.contains("stderr_bytes_raw"));
714}