1use crate::error::EtherNetIpError;
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6use std::time::{Duration, Instant, SystemTime};
7use tokio::sync::RwLock;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MonitoringMetrics {
12 pub connections: ConnectionMetrics,
14 pub operations: OperationMetrics,
16 pub performance: PerformanceMetrics,
18 pub errors: ErrorMetrics,
20 pub health: HealthMetrics,
22}
23
24impl MonitoringMetrics {
25 pub fn system_metrics_are_placeholders(&self) -> bool {
27 true
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ConnectionMetrics {
34 pub active_connections: u32,
36 pub total_connections: u64,
38 pub failed_connections: u64,
40 pub connection_uptime_avg: Duration,
42 pub last_connection_time: Option<SystemTime>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct OperationMetrics {
49 pub total_reads: u64,
51 pub total_writes: u64,
53 pub successful_reads: u64,
55 pub successful_writes: u64,
57 pub failed_reads: u64,
59 pub failed_writes: u64,
61 pub batch_operations: u64,
63 pub subscription_updates: u64,
65 pub partial_batch_failures: u64,
67 pub last_successful_read_time: Option<SystemTime>,
69 pub last_failed_read_time: Option<SystemTime>,
71 pub last_successful_write_time: Option<SystemTime>,
73 pub last_failed_write_time: Option<SystemTime>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PerformanceMetrics {
80 pub avg_read_latency_ms: f64,
82 pub avg_write_latency_ms: f64,
84 pub max_read_latency_ms: f64,
86 pub max_write_latency_ms: f64,
88 pub reads_per_second: f64,
90 pub writes_per_second: f64,
92 pub memory_usage_mb: f64,
94 pub cpu_usage_percent: f64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ErrorMetrics {
101 pub network_errors: u64,
103 pub protocol_errors: u64,
105 pub timeout_errors: u64,
107 pub tag_not_found_errors: u64,
109 pub data_type_errors: u64,
111 pub session_errors: u64,
113 pub route_path_errors: u64,
115 pub embedded_service_errors: u64,
117 pub known_controller_limitation_errors: u64,
119 pub retriable_errors: u64,
121 pub non_retriable_errors: u64,
123 pub last_error_time: Option<SystemTime>,
125 pub last_error_message: Option<String>,
127 pub last_error_category: Option<ErrorCategory>,
129 pub last_retriable_error_time: Option<SystemTime>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct HealthMetrics {
136 pub overall_health: HealthStatus,
138 pub last_health_check: SystemTime,
140 pub health_mode: HealthCheckMode,
142 pub last_verified_health_check: Option<SystemTime>,
144 pub consecutive_failures: u32,
146 pub recovery_attempts: u32,
148 pub system_uptime: Duration,
150 pub last_success_time: Option<SystemTime>,
152 pub last_failure_time: Option<SystemTime>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158#[non_exhaustive]
159pub enum HealthStatus {
160 Healthy,
162 Warning,
164 Critical,
166 Unknown,
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
172#[non_exhaustive]
173pub enum HealthCheckMode {
174 Passive,
176 Verified,
178}
179
180#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum ErrorCategory {
184 Network,
186 Timeout,
188 Session,
190 RoutePath,
192 CipProtocol,
194 BatchEmbeddedService,
196 KnownControllerLimitation,
198 DataType,
200 NotFound,
202 Unknown,
204}
205
206impl ErrorCategory {
207 pub fn is_retriable(self) -> bool {
209 matches!(
210 self,
211 ErrorCategory::Network | ErrorCategory::Timeout | ErrorCategory::Session
212 )
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct DiagnosticsSnapshot {
219 pub captured_at: SystemTime,
221 pub connections: ConnectionMetrics,
223 pub operations: OperationMetrics,
225 pub performance: PerformanceMetrics,
227 pub errors: ErrorMetrics,
229 pub health: HealthMetrics,
231 pub system_metrics_are_placeholders: bool,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
237pub struct SchemaCacheMetrics {
238 pub generation: u64,
240 pub refreshes: u64,
242 pub array_classification_hits: u64,
244 pub array_classification_misses: u64,
246 pub array_classification_evictions: u64,
248 pub datatype_contradictions: u64,
250 pub successful_read_recoveries: u64,
252 pub failed_read_recoveries: u64,
254}
255
256#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn get_metrics(&self) -> MonitoringMetrics {
576 let mut metrics = self.metrics.read().await.clone();
577
578 metrics.health.system_uptime = self.start_time.elapsed();
580
581 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 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 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 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 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 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 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}