Skip to main content

rust_ethernet_ip/
monitoring.rs

1//! Diagnostic metric models and the deprecated standalone monitor.
2
3use crate::error::EtherNetIpError;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use std::time::{Duration, Instant, SystemTime};
7use tokio::sync::RwLock;
8
9/// Production monitoring metrics for the EtherNet/IP library
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MonitoringMetrics {
12    /// Connection statistics
13    pub connections: ConnectionMetrics,
14    /// Operation statistics
15    pub operations: OperationMetrics,
16    /// Performance statistics
17    pub performance: PerformanceMetrics,
18    /// Error statistics
19    pub errors: ErrorMetrics,
20    /// System health
21    pub health: HealthMetrics,
22}
23
24impl MonitoringMetrics {
25    /// Returns true because CPU/memory metrics in this legacy monitor are placeholders.
26    pub fn system_metrics_are_placeholders(&self) -> bool {
27        true
28    }
29}
30
31/// Connection lifecycle counters.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ConnectionMetrics {
34    /// Connections currently considered active.
35    pub active_connections: u32,
36    /// Successful connections recorded since startup.
37    pub total_connections: u64,
38    /// Failed connection attempts recorded since startup.
39    pub failed_connections: u64,
40    /// Average connection uptime when supplied by the caller.
41    pub connection_uptime_avg: Duration,
42    /// Time of the most recent successful connection.
43    pub last_connection_time: Option<SystemTime>,
44}
45
46/// Tag-operation counters and timestamps.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct OperationMetrics {
49    /// Total read attempts.
50    pub total_reads: u64,
51    /// Total write attempts.
52    pub total_writes: u64,
53    /// Successful reads.
54    pub successful_reads: u64,
55    /// Successful writes.
56    pub successful_writes: u64,
57    /// Failed reads.
58    pub failed_reads: u64,
59    /// Failed writes.
60    pub failed_writes: u64,
61    /// Batch operations recorded.
62    pub batch_operations: u64,
63    /// Subscription updates recorded.
64    pub subscription_updates: u64,
65    /// Batches containing at least one failed item.
66    pub partial_batch_failures: u64,
67    /// Time of the most recent successful read.
68    pub last_successful_read_time: Option<SystemTime>,
69    /// Time of the most recent failed read.
70    pub last_failed_read_time: Option<SystemTime>,
71    /// Time of the most recent successful write.
72    pub last_successful_write_time: Option<SystemTime>,
73    /// Time of the most recent failed write.
74    pub last_failed_write_time: Option<SystemTime>,
75}
76
77/// Aggregate latency and throughput measurements.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PerformanceMetrics {
80    /// Arithmetic mean read latency in milliseconds.
81    pub avg_read_latency_ms: f64,
82    /// Arithmetic mean write latency in milliseconds.
83    pub avg_write_latency_ms: f64,
84    /// Highest observed read latency in milliseconds.
85    pub max_read_latency_ms: f64,
86    /// Highest observed write latency in milliseconds.
87    pub max_write_latency_ms: f64,
88    /// Successful reads divided by monitor uptime.
89    pub reads_per_second: f64,
90    /// Successful writes divided by monitor uptime.
91    pub writes_per_second: f64,
92    /// Legacy placeholder; not measured by this monitor.
93    pub memory_usage_mb: f64,
94    /// Legacy placeholder; not measured by this monitor.
95    pub cpu_usage_percent: f64,
96}
97
98/// Error counters grouped by actionable category.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ErrorMetrics {
101    /// Network I/O errors.
102    pub network_errors: u64,
103    /// CIP or other protocol errors.
104    pub protocol_errors: u64,
105    /// Operation timeouts.
106    pub timeout_errors: u64,
107    /// Missing-tag errors.
108    pub tag_not_found_errors: u64,
109    /// Data type or encoding errors.
110    pub data_type_errors: u64,
111    /// Session or connection-loss errors.
112    pub session_errors: u64,
113    /// Route-path errors.
114    pub route_path_errors: u64,
115    /// Multiple Service Packet item failures.
116    pub embedded_service_errors: u64,
117    /// Rejections classified as known controller limitations.
118    pub known_controller_limitation_errors: u64,
119    /// Errors safe to retry according to [`ErrorCategory::is_retriable`].
120    pub retriable_errors: u64,
121    /// Errors that should not be retried automatically.
122    pub non_retriable_errors: u64,
123    /// Time of the most recent error.
124    pub last_error_time: Option<SystemTime>,
125    /// Message from the most recent error.
126    pub last_error_message: Option<String>,
127    /// Category of the most recent error.
128    pub last_error_category: Option<ErrorCategory>,
129    /// Time of the most recent retriable error.
130    pub last_retriable_error_time: Option<SystemTime>,
131}
132
133/// Current health assessment and recovery history.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct HealthMetrics {
136    /// Derived overall health state.
137    pub overall_health: HealthStatus,
138    /// Time the health state was last calculated.
139    pub last_health_check: SystemTime,
140    /// Whether health is passive or confirmed by an active request.
141    pub health_mode: HealthCheckMode,
142    /// Time of the most recent active health check.
143    pub last_verified_health_check: Option<SystemTime>,
144    /// Consecutive failures since the last success.
145    pub consecutive_failures: u32,
146    /// Recovery resets requested by the caller.
147    pub recovery_attempts: u32,
148    /// Elapsed time since monitoring began.
149    pub system_uptime: Duration,
150    /// Time of the most recent successful operation.
151    pub last_success_time: Option<SystemTime>,
152    /// Time of the most recent failed operation.
153    pub last_failure_time: Option<SystemTime>,
154}
155
156/// Coarse health state derived from connection and error metrics.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[non_exhaustive]
159pub enum HealthStatus {
160    /// Connected with no significant recent failure rate.
161    Healthy,
162    /// Elevated failure rate or repeated failures.
163    Warning,
164    /// High failure rate or sustained failures.
165    Critical,
166    /// Health cannot be established, commonly because there is no connection.
167    Unknown,
168}
169
170/// Source of the current health assessment.
171#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
172#[non_exhaustive]
173pub enum HealthCheckMode {
174    /// Inferred from ordinary operation results.
175    Passive,
176    /// Confirmed by an explicit health request.
177    Verified,
178}
179
180/// Stable error classification used by diagnostics and retry decisions.
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum ErrorCategory {
184    /// Socket or other network I/O failure.
185    Network,
186    /// Request deadline exceeded.
187    Timeout,
188    /// EtherNet/IP session or connection failure.
189    Session,
190    /// Invalid or unreachable CIP route.
191    RoutePath,
192    /// General CIP protocol rejection.
193    CipProtocol,
194    /// Failure reported by an embedded batch service.
195    BatchEmbeddedService,
196    /// Recognized controller/firmware restriction.
197    KnownControllerLimitation,
198    /// Value type or encoding mismatch.
199    DataType,
200    /// Requested tag or path was not found.
201    NotFound,
202    /// Error did not match a stable category.
203    Unknown,
204}
205
206impl ErrorCategory {
207    /// Returns whether retrying may succeed without changing the request.
208    pub fn is_retriable(self) -> bool {
209        matches!(
210            self,
211            ErrorCategory::Network | ErrorCategory::Timeout | ErrorCategory::Session
212        )
213    }
214}
215
216/// Point-in-time diagnostic data suitable for serialization by wrappers.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct DiagnosticsSnapshot {
219    /// Time at which the snapshot was assembled.
220    pub captured_at: SystemTime,
221    /// Connection counters.
222    pub connections: ConnectionMetrics,
223    /// Operation counters.
224    pub operations: OperationMetrics,
225    /// Latency and throughput measurements.
226    pub performance: PerformanceMetrics,
227    /// Error counters and last-error details.
228    pub errors: ErrorMetrics,
229    /// Current health assessment.
230    pub health: HealthMetrics,
231    /// Whether CPU and memory fields are placeholders.
232    pub system_metrics_are_placeholders: bool,
233}
234
235/// Diagnostics for controller-schema caching and bounded drift recovery.
236#[derive(Debug, Clone, Default, Serialize, Deserialize)]
237pub struct SchemaCacheMetrics {
238    /// Current clone-shared schema generation.
239    pub generation: u64,
240    /// Number of explicit comprehensive schema refreshes.
241    pub refreshes: u64,
242    /// Array-classification cache hits.
243    pub array_classification_hits: u64,
244    /// Array-classification cache misses.
245    pub array_classification_misses: u64,
246    /// Array-classification entries evicted by refresh or contradiction.
247    pub array_classification_evictions: u64,
248    /// Responses that contradicted cached schema assumptions.
249    pub datatype_contradictions: u64,
250    /// One-time read recoveries that succeeded.
251    pub successful_read_recoveries: u64,
252    /// One-time read recoveries that still failed.
253    pub failed_read_recoveries: u64,
254}
255
256/// Production monitoring system for EtherNet/IP operations
257#[deprecated(
258    since = "1.2.0",
259    note = "ProductionMonitor is a standalone placeholder not wired into EipClient; use EipClient diagnostics snapshots instead. The type will be removed in 2.0."
260)]
261pub struct ProductionMonitor {
262    metrics: Arc<RwLock<MonitoringMetrics>>,
263    start_time: Instant,
264}
265
266#[expect(
267    deprecated,
268    reason = "CODEX-AQ keeps ProductionMonitor compatibility until 2.0 removal"
269)]
270impl Default for ProductionMonitor {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276#[expect(
277    deprecated,
278    reason = "CODEX-AQ keeps ProductionMonitor compatibility until 2.0 removal"
279)]
280impl ProductionMonitor {
281    /// Creates a zeroed standalone monitor.
282    pub fn new() -> Self {
283        Self {
284            metrics: Arc::new(RwLock::new(MonitoringMetrics {
285                connections: ConnectionMetrics {
286                    active_connections: 0,
287                    total_connections: 0,
288                    failed_connections: 0,
289                    connection_uptime_avg: Duration::ZERO,
290                    last_connection_time: None,
291                },
292                operations: OperationMetrics {
293                    total_reads: 0,
294                    total_writes: 0,
295                    successful_reads: 0,
296                    successful_writes: 0,
297                    failed_reads: 0,
298                    failed_writes: 0,
299                    batch_operations: 0,
300                    subscription_updates: 0,
301                    partial_batch_failures: 0,
302                    last_successful_read_time: None,
303                    last_failed_read_time: None,
304                    last_successful_write_time: None,
305                    last_failed_write_time: None,
306                },
307                performance: PerformanceMetrics {
308                    avg_read_latency_ms: 0.0,
309                    avg_write_latency_ms: 0.0,
310                    max_read_latency_ms: 0.0,
311                    max_write_latency_ms: 0.0,
312                    reads_per_second: 0.0,
313                    writes_per_second: 0.0,
314                    memory_usage_mb: 0.0,
315                    cpu_usage_percent: 0.0,
316                },
317                errors: ErrorMetrics {
318                    network_errors: 0,
319                    protocol_errors: 0,
320                    timeout_errors: 0,
321                    tag_not_found_errors: 0,
322                    data_type_errors: 0,
323                    session_errors: 0,
324                    route_path_errors: 0,
325                    embedded_service_errors: 0,
326                    known_controller_limitation_errors: 0,
327                    retriable_errors: 0,
328                    non_retriable_errors: 0,
329                    last_error_time: None,
330                    last_error_message: None,
331                    last_error_category: None,
332                    last_retriable_error_time: None,
333                },
334                health: HealthMetrics {
335                    overall_health: HealthStatus::Unknown,
336                    last_health_check: SystemTime::now(),
337                    health_mode: HealthCheckMode::Passive,
338                    last_verified_health_check: None,
339                    consecutive_failures: 0,
340                    recovery_attempts: 0,
341                    system_uptime: Duration::ZERO,
342                    last_success_time: None,
343                    last_failure_time: None,
344                },
345            })),
346            start_time: Instant::now(),
347        }
348    }
349
350    /// Record a successful read operation
351    pub async fn record_read_success(&self, latency: Duration) {
352        let mut metrics = self.metrics.write().await;
353        metrics.operations.total_reads += 1;
354        metrics.operations.successful_reads += 1;
355        let now = SystemTime::now();
356        metrics.operations.last_successful_read_time = Some(now);
357        metrics.health.last_success_time = Some(now);
358        metrics.health.consecutive_failures = 0;
359
360        // Update latency metrics
361        let latency_ms = latency.as_millis() as f64;
362        metrics.performance.avg_read_latency_ms = (metrics.performance.avg_read_latency_ms
363            * (metrics.operations.successful_reads - 1) as f64
364            + latency_ms)
365            / metrics.operations.successful_reads as f64;
366
367        if latency_ms > metrics.performance.max_read_latency_ms {
368            metrics.performance.max_read_latency_ms = latency_ms;
369        }
370    }
371
372    /// Record a failed read operation
373    pub async fn record_read_failure(&self, error_type: &str) {
374        let mut metrics = self.metrics.write().await;
375        metrics.operations.total_reads += 1;
376        metrics.operations.failed_reads += 1;
377        metrics.operations.last_failed_read_time = Some(SystemTime::now());
378        self.record_error(&mut metrics, error_type);
379    }
380
381    /// Record a successful write operation
382    pub async fn record_write_success(&self, latency: Duration) {
383        let mut metrics = self.metrics.write().await;
384        metrics.operations.total_writes += 1;
385        metrics.operations.successful_writes += 1;
386        let now = SystemTime::now();
387        metrics.operations.last_successful_write_time = Some(now);
388        metrics.health.last_success_time = Some(now);
389        metrics.health.consecutive_failures = 0;
390
391        // Update latency metrics
392        let latency_ms = latency.as_millis() as f64;
393        metrics.performance.avg_write_latency_ms = (metrics.performance.avg_write_latency_ms
394            * (metrics.operations.successful_writes - 1) as f64
395            + latency_ms)
396            / metrics.operations.successful_writes as f64;
397
398        if latency_ms > metrics.performance.max_write_latency_ms {
399            metrics.performance.max_write_latency_ms = latency_ms;
400        }
401    }
402
403    /// Record a failed write operation
404    pub async fn record_write_failure(&self, error_type: &str) {
405        let mut metrics = self.metrics.write().await;
406        metrics.operations.total_writes += 1;
407        metrics.operations.failed_writes += 1;
408        metrics.operations.last_failed_write_time = Some(SystemTime::now());
409        self.record_error(&mut metrics, error_type);
410    }
411
412    /// Record a partial batch failure without losing successful values.
413    pub async fn record_partial_batch_failure(&self, error_type: &str) {
414        let mut metrics = self.metrics.write().await;
415        metrics.operations.batch_operations += 1;
416        metrics.operations.partial_batch_failures += 1;
417        self.record_error(&mut metrics, error_type);
418    }
419
420    /// Record a connection event
421    pub async fn record_connection(&self, success: bool) {
422        let mut metrics = self.metrics.write().await;
423        if success {
424            metrics.connections.total_connections += 1;
425            metrics.connections.active_connections += 1;
426            metrics.connections.last_connection_time = Some(SystemTime::now());
427        } else {
428            metrics.connections.failed_connections += 1;
429        }
430    }
431
432    /// Record a disconnection event
433    pub async fn record_disconnection(&self) {
434        let mut metrics = self.metrics.write().await;
435        if metrics.connections.active_connections > 0 {
436            metrics.connections.active_connections -= 1;
437        }
438    }
439
440    /// Record an error
441    fn record_error(&self, metrics: &mut MonitoringMetrics, error_type: &str) {
442        let category = Self::classify_error_type(error_type);
443        let now = SystemTime::now();
444
445        match category {
446            ErrorCategory::Network => metrics.errors.network_errors += 1,
447            ErrorCategory::Timeout => metrics.errors.timeout_errors += 1,
448            ErrorCategory::Session => metrics.errors.session_errors += 1,
449            ErrorCategory::RoutePath => metrics.errors.route_path_errors += 1,
450            ErrorCategory::CipProtocol => metrics.errors.protocol_errors += 1,
451            ErrorCategory::BatchEmbeddedService => {
452                metrics.errors.protocol_errors += 1;
453                metrics.errors.embedded_service_errors += 1;
454            }
455            ErrorCategory::KnownControllerLimitation => {
456                metrics.errors.protocol_errors += 1;
457                metrics.errors.known_controller_limitation_errors += 1;
458            }
459            ErrorCategory::DataType => metrics.errors.data_type_errors += 1,
460            ErrorCategory::NotFound => metrics.errors.tag_not_found_errors += 1,
461            ErrorCategory::Unknown => {}
462        }
463
464        if category.is_retriable() {
465            metrics.errors.retriable_errors += 1;
466            metrics.errors.last_retriable_error_time = Some(now);
467        } else {
468            metrics.errors.non_retriable_errors += 1;
469        }
470
471        metrics.errors.last_error_time = Some(now);
472        metrics.errors.last_error_message = Some(error_type.to_string());
473        metrics.errors.last_error_category = Some(category);
474        metrics.health.consecutive_failures += 1;
475        metrics.health.last_failure_time = Some(now);
476    }
477
478    /// Classifies a structured library error for diagnostics and retries.
479    pub fn classify_error(error: &EtherNetIpError) -> ErrorCategory {
480        match error {
481            EtherNetIpError::Io(_) => ErrorCategory::Network,
482            EtherNetIpError::Timeout(_) => ErrorCategory::Timeout,
483            EtherNetIpError::Connection(_) | EtherNetIpError::ConnectionLost(_) => {
484                ErrorCategory::Session
485            }
486            EtherNetIpError::TagNotFound(_) => ErrorCategory::NotFound,
487            EtherNetIpError::DataTypeMismatch { .. } => ErrorCategory::DataType,
488            EtherNetIpError::CipError { code, message }
489            | EtherNetIpError::ReadError {
490                status: code,
491                message,
492            }
493            | EtherNetIpError::WriteError {
494                status: code,
495                message,
496            } => Self::classify_status_and_message(Some(*code), message),
497            EtherNetIpError::Protocol(message)
498            | EtherNetIpError::InvalidResponse { reason: message }
499            | EtherNetIpError::Other(message)
500            | EtherNetIpError::Tag(message)
501            | EtherNetIpError::Subscription(message)
502            | EtherNetIpError::Udt(message)
503            | EtherNetIpError::Permission(message)
504            | EtherNetIpError::InvalidString { reason: message } => {
505                Self::classify_status_and_message(None, message)
506            }
507            EtherNetIpError::Unsupported { .. } => ErrorCategory::CipProtocol,
508            EtherNetIpError::StringTooLong { .. } => ErrorCategory::DataType,
509            EtherNetIpError::Utf8(_) => ErrorCategory::DataType,
510        }
511    }
512
513    /// Classifies a legacy string error name or message.
514    pub fn classify_error_type(error_type: &str) -> ErrorCategory {
515        match error_type {
516            "network" => ErrorCategory::Network,
517            "timeout" => ErrorCategory::Timeout,
518            "tag_not_found" => ErrorCategory::NotFound,
519            "data_type" => ErrorCategory::DataType,
520            "session" => ErrorCategory::Session,
521            "route_path" => ErrorCategory::RoutePath,
522            "embedded_service" => ErrorCategory::BatchEmbeddedService,
523            "known_controller_limitation" => ErrorCategory::KnownControllerLimitation,
524            "protocol" => ErrorCategory::CipProtocol,
525            other => Self::classify_status_and_message(None, other),
526        }
527    }
528
529    fn classify_status_and_message(status: Option<u8>, message: &str) -> ErrorCategory {
530        let lower = message.to_ascii_lowercase();
531
532        if status == Some(0x1E) || lower.contains("embedded service error") {
533            return ErrorCategory::BatchEmbeddedService;
534        }
535        if lower.contains("controller rejected")
536            || lower.contains("does not support writing to udt array element members")
537        {
538            return ErrorCategory::KnownControllerLimitation;
539        }
540        if status == Some(0x04) || lower.contains("path segment error") || lower.contains("route") {
541            return ErrorCategory::RoutePath;
542        }
543        if lower.contains("timed out") || lower.contains("timeout") {
544            return ErrorCategory::Timeout;
545        }
546        if lower.contains("connection lost")
547            || lower.contains("plc unreachable")
548            || lower.contains("session")
549            || lower.contains("keep-alive")
550        {
551            return ErrorCategory::Session;
552        }
553        if lower.contains("tag not found") {
554            return ErrorCategory::NotFound;
555        }
556        if lower.contains("data type")
557            || lower.contains("data-type")
558            || lower.contains("0x2107")
559            || lower.contains("invalid string")
560            || lower.contains("utf-8")
561        {
562            return ErrorCategory::DataType;
563        }
564        if lower.contains("io error") || lower.contains("network") {
565            return ErrorCategory::Network;
566        }
567        if status.is_some() || lower.contains("cip error") || lower.contains("protocol") {
568            return ErrorCategory::CipProtocol;
569        }
570
571        ErrorCategory::Unknown
572    }
573
574    /// Get current metrics
575    pub async fn get_metrics(&self) -> MonitoringMetrics {
576        let mut metrics = self.metrics.read().await.clone();
577
578        // Update system uptime
579        metrics.health.system_uptime = self.start_time.elapsed();
580
581        // Calculate operations per second
582        let total_time = metrics.health.system_uptime.as_secs_f64();
583        if total_time > 0.0 {
584            metrics.performance.reads_per_second =
585                metrics.operations.successful_reads as f64 / total_time;
586            metrics.performance.writes_per_second =
587                metrics.operations.successful_writes as f64 / total_time;
588        }
589
590        // Update health status
591        metrics.health.overall_health = self.calculate_health_status(&metrics);
592        metrics.health.last_health_check = SystemTime::now();
593        if metrics.health.last_verified_health_check.is_none() {
594            metrics.health.health_mode = HealthCheckMode::Passive;
595        }
596
597        metrics
598    }
599
600    /// Get a stable diagnostics snapshot for wrappers and service layers.
601    pub async fn get_diagnostics_snapshot(&self) -> DiagnosticsSnapshot {
602        let metrics = self.get_metrics().await;
603        DiagnosticsSnapshot {
604            captured_at: SystemTime::now(),
605            connections: metrics.connections,
606            operations: metrics.operations,
607            performance: metrics.performance,
608            errors: metrics.errors,
609            health: metrics.health,
610            system_metrics_are_placeholders: true,
611        }
612    }
613
614    /// Calculate overall health status
615    fn calculate_health_status(&self, metrics: &MonitoringMetrics) -> HealthStatus {
616        let error_rate = if metrics.operations.total_reads + metrics.operations.total_writes > 0 {
617            (metrics.operations.failed_reads + metrics.operations.failed_writes) as f64
618                / (metrics.operations.total_reads + metrics.operations.total_writes) as f64
619        } else {
620            0.0
621        };
622
623        if error_rate > 0.1 || metrics.health.consecutive_failures > 10 {
624            HealthStatus::Critical
625        } else if error_rate > 0.05 || metrics.health.consecutive_failures > 5 {
626            HealthStatus::Warning
627        } else if metrics.connections.active_connections > 0 {
628            HealthStatus::Healthy
629        } else {
630            HealthStatus::Unknown
631        }
632    }
633
634    /// Start monitoring background tasks
635    pub async fn start_monitoring(&self) {
636        tracing::warn!(
637            "ProductionMonitor::start_monitoring is deprecated and no longer spawns a placeholder metrics task"
638        );
639    }
640
641    /// Reset consecutive failures (call after successful recovery)
642    pub async fn reset_consecutive_failures(&self) {
643        let mut metrics = self.metrics.write().await;
644        metrics.health.consecutive_failures = 0;
645        metrics.health.recovery_attempts += 1;
646    }
647
648    /// Record the outcome of an active, verified health check.
649    pub async fn record_verified_health_check(&self, is_healthy: bool) {
650        let mut metrics = self.metrics.write().await;
651        let now = SystemTime::now();
652        metrics.health.health_mode = HealthCheckMode::Verified;
653        metrics.health.last_verified_health_check = Some(now);
654        metrics.health.last_health_check = now;
655
656        if is_healthy {
657            metrics.health.last_success_time = Some(now);
658            metrics.health.consecutive_failures = 0;
659        } else {
660            metrics.health.last_failure_time = Some(now);
661            metrics.health.consecutive_failures += 1;
662        }
663    }
664}
665
666#[expect(
667    deprecated,
668    reason = "CODEX-AQ keeps ProductionMonitor compatibility until 2.0 removal"
669)]
670impl Clone for ProductionMonitor {
671    fn clone(&self) -> Self {
672        Self {
673            metrics: Arc::clone(&self.metrics),
674            start_time: self.start_time,
675        }
676    }
677}
678
679#[cfg(test)]
680#[expect(
681    deprecated,
682    reason = "CODEX-AQ keeps ProductionMonitor unit coverage until 2.0 removal"
683)]
684mod tests {
685    use super::*;
686    use crate::error::EtherNetIpError;
687
688    #[test]
689    fn classify_timeout_and_route_path_errors() {
690        assert_eq!(
691            ProductionMonitor::classify_error(&EtherNetIpError::Timeout(Duration::from_secs(1))),
692            ErrorCategory::Timeout
693        );
694        assert_eq!(
695            ProductionMonitor::classify_error(&EtherNetIpError::Protocol(
696                "Path segment error while resolving route".to_string()
697            )),
698            ErrorCategory::RoutePath
699        );
700    }
701
702    #[test]
703    fn classify_known_controller_limitation_and_embedded_service() {
704        assert_eq!(
705            ProductionMonitor::classify_error(&EtherNetIpError::Protocol(
706                "Read/Write Tag data-type mismatch extended error: 0x2107".to_string()
707            )),
708            ErrorCategory::DataType
709        );
710        assert_eq!(
711            ProductionMonitor::classify_error(&EtherNetIpError::WriteError {
712                status: 0x1E,
713                message: "Embedded service error".to_string(),
714            }),
715            ErrorCategory::BatchEmbeddedService
716        );
717    }
718
719    #[tokio::test]
720    async fn diagnostics_snapshot_distinguishes_verified_health() {
721        let monitor = ProductionMonitor::new();
722        monitor.record_read_success(Duration::from_millis(10)).await;
723
724        let passive = monitor.get_diagnostics_snapshot().await;
725        assert_eq!(passive.health.health_mode, HealthCheckMode::Passive);
726        assert!(passive.health.last_verified_health_check.is_none());
727        assert!(passive.operations.last_successful_read_time.is_some());
728
729        monitor.record_verified_health_check(true).await;
730        let verified = monitor.get_diagnostics_snapshot().await;
731        assert_eq!(verified.health.health_mode, HealthCheckMode::Verified);
732        assert!(verified.health.last_verified_health_check.is_some());
733        assert!(verified.system_metrics_are_placeholders);
734    }
735}