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}
132
133/// Fluent builder for MetricEvent. Reduces repetitive struct literal boilerplate.
134#[derive(Debug, Default)]
135pub(crate) struct MetricEventBuilder {
136    ts: u64,
137    tool: &'static str,
138    duration_ms: u64,
139    output_chars: usize,
140    param_path_depth: usize,
141    max_depth: Option<u32>,
142    result: &'static str,
143    error_type: Option<String>,
144    error_subtype: Option<String>,
145    session_id: Option<String>,
146    seq: Option<u32>,
147    cache_hit: Option<bool>,
148    cache_write_failure: Option<bool>,
149    cache_tier: Option<&'static str>,
150    exit_code: Option<i32>,
151    timed_out: bool,
152    output_truncated: Option<bool>,
153    chars_threshold_breach: bool,
154    file_ext: Option<&'static str>,
155    filter_applied: Option<String>,
156    language: Option<String>,
157    git_ref_used: bool,
158    summary_mode: bool,
159    is_paginated: bool,
160    fields_projected: bool,
161    match_mode: Option<String>,
162    follow_depth: Option<u32>,
163    import_lookup: bool,
164    def_use: bool,
165    impl_only: bool,
166    stdin_provided: bool,
167    timeout_configured_ms: Option<i64>,
168    drain_timeout_ms: Option<i64>,
169    working_dir_used: bool,
170    l1_eviction_count: Option<u64>,
171    l2_entry_count: Option<u64>,
172    l2_size_bytes: Option<u64>,
173}
174
175#[allow(clippy::too_many_arguments)]
176impl MetricEventBuilder {
177    #[must_use]
178    pub(crate) fn new(tool: &'static str, result: &'static str, duration_ms: u64) -> Self {
179        Self {
180            ts: unix_ms(),
181            tool,
182            result,
183            duration_ms,
184            ..Self::default()
185        }
186    }
187
188    #[must_use]
189    pub(crate) fn output_chars(mut self, v: usize) -> Self {
190        self.output_chars = v;
191        self
192    }
193    #[must_use]
194    pub(crate) fn param_path_depth(mut self, v: usize) -> Self {
195        self.param_path_depth = v;
196        self
197    }
198    #[must_use]
199    pub(crate) fn max_depth(mut self, v: Option<u32>) -> Self {
200        self.max_depth = v;
201        self
202    }
203    #[must_use]
204    pub(crate) fn error_type(mut self, v: Option<String>) -> Self {
205        self.error_type = v;
206        self
207    }
208    #[must_use]
209    pub(crate) fn error_subtype(mut self, v: Option<String>) -> Self {
210        self.error_subtype = v;
211        self
212    }
213    #[must_use]
214    pub(crate) fn session_id(mut self, v: Option<String>) -> Self {
215        self.session_id = v;
216        self
217    }
218    #[must_use]
219    pub(crate) fn seq(mut self, v: Option<u32>) -> Self {
220        self.seq = v;
221        self
222    }
223    #[must_use]
224    pub(crate) fn cache_hit(mut self, v: Option<bool>) -> Self {
225        self.cache_hit = v;
226        self
227    }
228    #[must_use]
229    pub(crate) fn cache_tier(mut self, v: Option<&'static str>) -> Self {
230        self.cache_tier = v;
231        self
232    }
233    #[must_use]
234    pub(crate) fn cache_write_failure(mut self, v: Option<bool>) -> Self {
235        self.cache_write_failure = v;
236        self
237    }
238    #[must_use]
239    pub(crate) fn exit_code(mut self, v: Option<i32>) -> Self {
240        self.exit_code = v;
241        self
242    }
243    #[must_use]
244    pub(crate) fn timed_out(mut self, v: bool) -> Self {
245        self.timed_out = v;
246        self
247    }
248    #[must_use]
249    pub(crate) fn output_truncated(mut self, v: Option<bool>) -> Self {
250        self.output_truncated = v;
251        self
252    }
253    #[must_use]
254    pub(crate) fn chars_threshold_breach(mut self, v: bool) -> Self {
255        self.chars_threshold_breach = v;
256        self
257    }
258    #[must_use]
259    pub(crate) fn file_ext(mut self, v: Option<&'static str>) -> Self {
260        self.file_ext = v;
261        self
262    }
263    #[must_use]
264    pub(crate) fn filter_applied(mut self, v: Option<String>) -> Self {
265        self.filter_applied = v;
266        self
267    }
268    #[must_use]
269    pub(crate) fn language(mut self, v: Option<String>) -> Self {
270        self.language = v;
271        self
272    }
273    #[must_use]
274    pub(crate) fn git_ref_used(mut self, v: bool) -> Self {
275        self.git_ref_used = v;
276        self
277    }
278    #[must_use]
279    pub(crate) fn summary_mode(mut self, v: bool) -> Self {
280        self.summary_mode = v;
281        self
282    }
283    #[must_use]
284    #[allow(clippy::wrong_self_convention)]
285    pub(crate) fn is_paginated(mut self, v: bool) -> Self {
286        self.is_paginated = v;
287        self
288    }
289    #[must_use]
290    pub(crate) fn fields_projected(mut self, v: bool) -> Self {
291        self.fields_projected = v;
292        self
293    }
294    #[must_use]
295    pub(crate) fn match_mode(mut self, v: Option<String>) -> Self {
296        self.match_mode = v;
297        self
298    }
299    #[must_use]
300    pub(crate) fn follow_depth(mut self, v: Option<u32>) -> Self {
301        self.follow_depth = v;
302        self
303    }
304    #[must_use]
305    pub(crate) fn import_lookup(mut self, v: bool) -> Self {
306        self.import_lookup = v;
307        self
308    }
309    #[must_use]
310    pub(crate) fn def_use(mut self, v: bool) -> Self {
311        self.def_use = v;
312        self
313    }
314    #[must_use]
315    pub(crate) fn impl_only(mut self, v: bool) -> Self {
316        self.impl_only = v;
317        self
318    }
319    #[must_use]
320    pub(crate) fn stdin_provided(mut self, v: bool) -> Self {
321        self.stdin_provided = v;
322        self
323    }
324    #[must_use]
325    pub(crate) fn timeout_configured_ms(mut self, v: Option<i64>) -> Self {
326        self.timeout_configured_ms = v;
327        self
328    }
329    #[must_use]
330    pub(crate) fn drain_timeout_ms(mut self, v: Option<i64>) -> Self {
331        self.drain_timeout_ms = v;
332        self
333    }
334    #[must_use]
335    pub(crate) fn working_dir_used(mut self, v: bool) -> Self {
336        self.working_dir_used = v;
337        self
338    }
339    #[must_use]
340    pub(crate) fn l1_eviction_count(mut self, v: Option<u64>) -> Self {
341        self.l1_eviction_count = v;
342        self
343    }
344    #[must_use]
345    pub(crate) fn l2_entry_count(mut self, v: Option<u64>) -> Self {
346        self.l2_entry_count = v;
347        self
348    }
349    #[must_use]
350    pub(crate) fn l2_size_bytes(mut self, v: Option<u64>) -> Self {
351        self.l2_size_bytes = v;
352        self
353    }
354    #[must_use]
355    pub(crate) fn build(self) -> MetricEvent {
356        MetricEvent {
357            ts: self.ts,
358            tool: self.tool,
359            duration_ms: self.duration_ms,
360            output_chars: self.output_chars,
361            param_path_depth: self.param_path_depth,
362            max_depth: self.max_depth,
363            result: self.result,
364            error_type: self.error_type,
365            error_subtype: self.error_subtype,
366            session_id: self.session_id,
367            seq: self.seq,
368            cache_hit: self.cache_hit,
369            cache_write_failure: self.cache_write_failure,
370            cache_tier: self.cache_tier,
371            exit_code: self.exit_code,
372            timed_out: self.timed_out,
373            output_truncated: self.output_truncated,
374            chars_threshold_breach: self.chars_threshold_breach,
375            file_ext: self.file_ext,
376            filter_applied: self.filter_applied,
377            language: self.language,
378            git_ref_used: self.git_ref_used,
379            summary_mode: self.summary_mode,
380            is_paginated: self.is_paginated,
381            fields_projected: self.fields_projected,
382            match_mode: self.match_mode,
383            follow_depth: self.follow_depth,
384            import_lookup: self.import_lookup,
385            def_use: self.def_use,
386            impl_only: self.impl_only,
387            stdin_provided: self.stdin_provided,
388            timeout_configured_ms: self.timeout_configured_ms,
389            drain_timeout_ms: self.drain_timeout_ms,
390            working_dir_used: self.working_dir_used,
391            l1_eviction_count: self.l1_eviction_count,
392            l2_entry_count: self.l2_entry_count,
393            l2_size_bytes: self.l2_size_bytes,
394        }
395    }
396}
397
398/// Sender half of the metrics channel; cloned and passed to tools for event emission.
399#[derive(Clone)]
400pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
401
402impl MetricsSender {
403    pub fn send(&self, event: MetricEvent) {
404        let _ = self.0.send(event);
405    }
406}
407
408/// Accumulated metrics for a single tool.
409#[derive(Default, Debug)]
410pub(crate) struct ToolMetrics {
411    pub(crate) count: u64,
412    pub(crate) duration_ms: u64,
413    pub(crate) output_chars: u64,
414}
415
416/// RAII guard that releases an exclusive lock on a metrics .lock file when dropped.
417/// Lock release happens implicitly when the underlying `std::fs::File` is closed.
418#[allow(dead_code)]
419pub(crate) struct MetricsLockGuard(pub(crate) std::fs::File);
420
421/// Record a metric event to OTel metrics if the global meter provider is available.
422///
423/// Records:
424/// - Histogram: mcp.server.operation.duration (in milliseconds)
425/// - Counter: mcp.server.tool.calls (incremented by 1)
426///
427/// Labels: gen_ai.tool.name, error.type (or "none" if no error)
428///
429/// Instruments are initialized once via OnceLock to avoid rebuilding them on every call.
430pub(crate) fn record_otel_metrics(event: &MetricEvent) {
431    // Skip OTEL recording for "received" events (duration_ms=0 would pollute latency histograms)
432    if event.result == "received" {
433        return;
434    }
435
436    static DURATION_HISTOGRAM: OnceLock<Histogram<f64>> = OnceLock::new();
437    static CALL_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
438    static CACHE_HITS_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
439    static CACHE_WRITE_FAILURES_COUNTER: OnceLock<Counter<u64>> = OnceLock::new();
440
441    let histogram = DURATION_HISTOGRAM.get_or_init(|| {
442        global::meter("aptu-coder")
443            .f64_histogram("mcp.server.operation.duration")
444            .with_unit("s")
445            .with_boundaries(vec![
446                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,
447            ])
448            .build()
449    });
450
451    let counter = CALL_COUNTER.get_or_init(|| {
452        global::meter("aptu-coder")
453            .u64_counter("mcp.server.tool.calls")
454            .build()
455    });
456
457    let cache_hits_counter = CACHE_HITS_COUNTER.get_or_init(|| {
458        global::meter("aptu-coder")
459            .u64_counter("mcp.server.tool.cache_hits_total")
460            .with_description("Number of tool responses served from cache (l1_memory or l2_disk)")
461            .build()
462    });
463
464    let cache_write_failures_counter = CACHE_WRITE_FAILURES_COUNTER.get_or_init(|| {
465        global::meter("aptu-coder")
466            .u64_counter("mcp.server.tool.cache_write_failures_total")
467            .with_description(
468                "Number of L2 disk cache write failures (dir, tempfile, write, rename)",
469            )
470            .build()
471    });
472
473    let error_type = event.error_type.as_deref().unwrap_or("success");
474    let attributes = [
475        KeyValue::new("gen_ai.tool.name", event.tool),
476        KeyValue::new("error.type", error_type.to_string()),
477        KeyValue::new("mcp.method.name", "tools/call"),
478        KeyValue::new("mcp.protocol.version", "2025-11-25"),
479        KeyValue::new("network.transport", "pipe"),
480    ];
481
482    histogram.record(event.duration_ms as f64 / 1000.0, &attributes);
483    counter.add(1, &attributes);
484
485    if event.cache_hit == Some(true) {
486        let tier = event.cache_tier.unwrap_or("unknown");
487        cache_hits_counter.add(
488            1,
489            &[
490                KeyValue::new("gen_ai.tool.name", event.tool),
491                KeyValue::new("cache_tier", tier),
492            ],
493        );
494    }
495
496    if event.cache_write_failure == Some(true) {
497        cache_write_failures_counter.add(1, &[KeyValue::new("gen_ai.tool.name", event.tool)]);
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_metric_event_serialization() {
507        let event = MetricEvent {
508            ts: 1_700_000_000_000,
509            tool: "analyze_directory",
510            duration_ms: 100,
511            output_chars: 500,
512            param_path_depth: 1,
513            max_depth: None,
514            result: "ok",
515            error_type: None,
516            error_subtype: None,
517            session_id: Some("1742468880123-42".to_string()),
518            seq: Some(5),
519            cache_hit: None,
520            cache_write_failure: None,
521            cache_tier: None,
522            exit_code: Some(0),
523            timed_out: false,
524            output_truncated: None,
525            chars_threshold_breach: false,
526            file_ext: None,
527            ..Default::default()
528        };
529        let serialized = serde_json::to_string(&event).unwrap();
530        assert!(serialized.contains(r#""ts":1700000000000"#));
531        assert!(serialized.contains(r#""tool":"analyze_directory""#));
532        assert!(serialized.contains(r#""session_id":"1742468880123-42""#));
533        assert!(serialized.contains(r#""exit_code":0"#));
534    }
535
536    #[test]
537    fn test_metric_event_serialization_error() {
538        let event = MetricEvent {
539            ts: 1_700_000_000_000,
540            tool: "edit_replace",
541            duration_ms: 10,
542            output_chars: 0,
543            param_path_depth: 2,
544            max_depth: None,
545            result: "error",
546            error_type: Some("invalid_params".to_string()),
547            session_id: None,
548            seq: None,
549            cache_hit: None,
550            cache_write_failure: None,
551            exit_code: None,
552            timed_out: false,
553            cache_tier: None,
554            output_truncated: None,
555            chars_threshold_breach: false,
556            file_ext: None,
557            ..Default::default()
558        };
559        let json = serde_json::to_string(&event).unwrap();
560        assert!(json.contains(r#""error_type":"invalid_params""#));
561    }
562
563    #[test]
564    fn test_metric_event_error_subtype_some_serializes() {
565        let event = MetricEvent {
566            ts: 1_700_000_000_000,
567            tool: "edit_replace",
568            duration_ms: 10,
569            output_chars: 0,
570            param_path_depth: 2,
571            max_depth: None,
572            result: "error",
573            error_type: Some("invalid_params".to_string()),
574            error_subtype: Some("not_found".to_string()),
575            session_id: None,
576            seq: None,
577            cache_hit: None,
578            cache_write_failure: None,
579            exit_code: None,
580            timed_out: false,
581            cache_tier: None,
582            output_truncated: None,
583            chars_threshold_breach: false,
584            file_ext: None,
585            ..Default::default()
586        };
587        let json = serde_json::to_string(&event).unwrap();
588        assert!(json.contains(r#""error_subtype":"not_found""#));
589    }
590
591    #[test]
592    fn test_metric_event_error_subtype_ambiguous() {
593        let event = MetricEvent {
594            ts: 1_700_000_000_000,
595            tool: "edit_replace",
596            duration_ms: 10,
597            output_chars: 0,
598            param_path_depth: 2,
599            max_depth: None,
600            result: "error",
601            error_type: Some("invalid_params".to_string()),
602            error_subtype: Some("ambiguous".to_string()),
603            session_id: None,
604            seq: None,
605            cache_hit: None,
606            cache_write_failure: None,
607            exit_code: None,
608            timed_out: false,
609            cache_tier: None,
610            output_truncated: None,
611            chars_threshold_breach: false,
612            file_ext: None,
613            ..Default::default()
614        };
615        let json = serde_json::to_string(&event).unwrap();
616        assert!(json.contains(r#""error_subtype":"ambiguous""#));
617    }
618
619    #[test]
620    fn test_metric_event_new_fields_round_trip() {
621        let event = MetricEvent {
622            ts: 1_700_000_000_000,
623            tool: "analyze_file",
624            duration_ms: 100,
625            output_chars: 500,
626            param_path_depth: 2,
627            max_depth: Some(3),
628            result: "ok",
629            error_type: None,
630            error_subtype: None,
631            session_id: Some("1742468880123-42".to_string()),
632            seq: Some(5),
633            cache_hit: None,
634            cache_write_failure: None,
635            exit_code: None,
636            timed_out: false,
637            cache_tier: None,
638            output_truncated: None,
639            chars_threshold_breach: false,
640            file_ext: None,
641            filter_applied: None,
642            language: None,
643            git_ref_used: false,
644            summary_mode: false,
645            is_paginated: false,
646            fields_projected: false,
647            match_mode: None,
648            follow_depth: None,
649            import_lookup: false,
650            def_use: false,
651            impl_only: false,
652            stdin_provided: false,
653            timeout_configured_ms: None,
654            drain_timeout_ms: None,
655            working_dir_used: false,
656            l1_eviction_count: None,
657            l2_entry_count: None,
658            l2_size_bytes: None,
659        };
660        let serialized = serde_json::to_string(&event).unwrap();
661        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}"#;
662        assert_eq!(serialized, json_str);
663    }
664}