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