1use std::collections::VecDeque;
5
6use tokio::sync::watch;
7
8pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SecurityEventCategory {
13 InjectionFlag,
14 ExfiltrationBlock,
15 Quarantine,
16 Truncation,
17 RateLimit,
18 MemoryValidation,
19 PreExecutionBlock,
20 PreExecutionWarn,
21 ResponseVerification,
22}
23
24impl SecurityEventCategory {
25 #[must_use]
26 pub fn as_str(self) -> &'static str {
27 match self {
28 Self::InjectionFlag => "injection",
29 Self::ExfiltrationBlock => "exfil",
30 Self::Quarantine => "quarantine",
31 Self::Truncation => "truncation",
32 Self::RateLimit => "rate_limit",
33 Self::MemoryValidation => "memory_validation",
34 Self::PreExecutionBlock => "pre_exec_block",
35 Self::PreExecutionWarn => "pre_exec_warn",
36 Self::ResponseVerification => "response_verify",
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct SecurityEvent {
44 pub timestamp: u64,
46 pub category: SecurityEventCategory,
47 pub source: String,
49 pub detail: String,
51}
52
53impl SecurityEvent {
54 #[must_use]
55 pub fn new(
56 category: SecurityEventCategory,
57 source: impl Into<String>,
58 detail: impl Into<String>,
59 ) -> Self {
60 let source: String = source
62 .into()
63 .chars()
64 .filter(|c| !c.is_ascii_control())
65 .take(64)
66 .collect();
67 let detail = detail.into();
69 let detail = if detail.len() > 128 {
70 let end = detail.floor_char_boundary(127);
71 format!("{}…", &detail[..end])
72 } else {
73 detail
74 };
75 Self {
76 timestamp: std::time::SystemTime::now()
77 .duration_since(std::time::UNIX_EPOCH)
78 .unwrap_or_default()
79 .as_secs(),
80 category,
81 source,
82 detail,
83 }
84 }
85}
86
87pub const SECURITY_EVENT_CAP: usize = 100;
89
90#[derive(Debug, Clone)]
94pub struct TaskSnapshotRow {
95 pub id: u32,
96 pub title: String,
97 pub status: String,
99 pub agent: Option<String>,
100 pub duration_ms: u64,
101 pub error: Option<String>,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct TaskGraphSnapshot {
108 pub graph_id: String,
109 pub goal: String,
110 pub status: String,
112 pub tasks: Vec<TaskSnapshotRow>,
113 pub completed_at: Option<std::time::Instant>,
114}
115
116impl TaskGraphSnapshot {
117 #[must_use]
120 pub fn is_stale(&self) -> bool {
121 self.completed_at
122 .is_some_and(|t| t.elapsed().as_secs() > 30)
123 }
124}
125
126#[derive(Debug, Clone, Default)]
130pub struct OrchestrationMetrics {
131 pub plans_total: u64,
132 pub tasks_total: u64,
133 pub tasks_completed: u64,
134 pub tasks_failed: u64,
135 pub tasks_skipped: u64,
136}
137
138#[derive(Debug, Clone, Default)]
140pub struct SkillConfidence {
141 pub name: String,
142 pub posterior: f64,
143 pub total_uses: u32,
144}
145
146#[derive(Debug, Clone, Default)]
148pub struct SubAgentMetrics {
149 pub id: String,
150 pub name: String,
151 pub state: String,
153 pub turns_used: u32,
154 pub max_turns: u32,
155 pub background: bool,
156 pub elapsed_secs: u64,
157 pub permission_mode: String,
160}
161
162#[derive(Debug, Clone, Default)]
163#[allow(clippy::struct_excessive_bools)]
164pub struct MetricsSnapshot {
165 pub prompt_tokens: u64,
166 pub completion_tokens: u64,
167 pub total_tokens: u64,
168 pub context_tokens: u64,
169 pub api_calls: u64,
170 pub active_skills: Vec<String>,
171 pub total_skills: usize,
172 pub mcp_server_count: usize,
173 pub mcp_tool_count: usize,
174 pub active_mcp_tools: Vec<String>,
175 pub sqlite_message_count: u64,
176 pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
177 pub qdrant_available: bool,
178 pub vector_backend: String,
179 pub embeddings_generated: u64,
180 pub last_llm_latency_ms: u64,
181 pub uptime_seconds: u64,
182 pub provider_name: String,
183 pub model_name: String,
184 pub summaries_count: u64,
185 pub context_compactions: u64,
186 pub compaction_hard_count: u64,
189 pub compaction_turns_after_hard: Vec<u64>,
193 pub compression_events: u64,
194 pub compression_tokens_saved: u64,
195 pub tool_output_prunes: u64,
196 pub compaction_probe_passes: u64,
198 pub compaction_probe_soft_failures: u64,
200 pub compaction_probe_failures: u64,
202 pub compaction_probe_errors: u64,
204 pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
206 pub last_probe_score: Option<f32>,
209 pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
211 pub compaction_probe_threshold: f32,
213 pub compaction_probe_hard_fail_threshold: f32,
215 pub cache_read_tokens: u64,
216 pub cache_creation_tokens: u64,
217 pub cost_spent_cents: f64,
218 pub filter_raw_tokens: u64,
219 pub filter_saved_tokens: u64,
220 pub filter_applications: u64,
221 pub filter_total_commands: u64,
222 pub filter_filtered_commands: u64,
223 pub filter_confidence_full: u64,
224 pub filter_confidence_partial: u64,
225 pub filter_confidence_fallback: u64,
226 pub cancellations: u64,
227 pub server_compaction_events: u64,
228 pub sanitizer_runs: u64,
229 pub sanitizer_injection_flags: u64,
230 pub sanitizer_truncations: u64,
231 pub quarantine_invocations: u64,
232 pub quarantine_failures: u64,
233 pub exfiltration_images_blocked: u64,
234 pub exfiltration_tool_urls_flagged: u64,
235 pub exfiltration_memory_guards: u64,
236 pub pii_scrub_count: u64,
237 pub memory_validation_failures: u64,
238 pub rate_limit_trips: u64,
239 pub pre_execution_blocks: u64,
240 pub pre_execution_warnings: u64,
241 #[cfg(feature = "guardrail")]
243 pub guardrail_enabled: bool,
244 #[cfg(feature = "guardrail")]
246 pub guardrail_warn_mode: bool,
247 pub sub_agents: Vec<SubAgentMetrics>,
248 pub skill_confidence: Vec<SkillConfidence>,
249 pub scheduled_tasks: Vec<[String; 4]>,
251 pub router_thompson_stats: Vec<(String, f64, f64)>,
253 pub security_events: VecDeque<SecurityEvent>,
255 pub orchestration: OrchestrationMetrics,
256 pub orchestration_graph: Option<TaskGraphSnapshot>,
258 pub graph_community_detection_failures: u64,
259 pub graph_entities_total: u64,
260 pub graph_edges_total: u64,
261 pub graph_communities_total: u64,
262 pub graph_extraction_count: u64,
263 pub graph_extraction_failures: u64,
264 pub extended_context: bool,
267 pub guidelines_version: u32,
269 pub guidelines_updated_at: String,
271 pub tool_cache_hits: u64,
272 pub tool_cache_misses: u64,
273 pub tool_cache_entries: usize,
274 pub semantic_fact_count: u64,
276 pub stt_model: Option<String>,
278 pub compaction_model: Option<String>,
280 pub provider_temperature: Option<f32>,
282 pub provider_top_p: Option<f32>,
284 pub embedding_model: String,
286 pub token_budget: Option<u64>,
288 pub compaction_threshold: Option<u32>,
290 pub vault_backend: String,
292 pub active_channel: String,
294 pub self_learning_enabled: bool,
296 pub semantic_cache_enabled: bool,
298 pub cache_enabled: bool,
300 pub autosave_enabled: bool,
302}
303
304fn strip_ctrl(s: &str) -> String {
310 let mut out = String::with_capacity(s.len());
311 let mut chars = s.chars().peekable();
312 while let Some(c) = chars.next() {
313 if c == '\x1b' {
314 if chars.peek() == Some(&'[') {
316 chars.next(); for inner in chars.by_ref() {
318 if ('\x40'..='\x7e').contains(&inner) {
319 break;
320 }
321 }
322 }
323 } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
325 } else {
327 out.push(c);
328 }
329 }
330 out
331}
332
333impl From<&crate::orchestration::TaskGraph> for TaskGraphSnapshot {
335 fn from(graph: &crate::orchestration::TaskGraph) -> Self {
336 let tasks = graph
337 .tasks
338 .iter()
339 .map(|t| {
340 let error = t
341 .result
342 .as_ref()
343 .filter(|_| t.status == crate::orchestration::TaskStatus::Failed)
344 .and_then(|r| {
345 if r.output.is_empty() {
346 None
347 } else {
348 let s = strip_ctrl(&r.output);
350 if s.len() > 80 {
351 let end = s.floor_char_boundary(79);
352 Some(format!("{}…", &s[..end]))
353 } else {
354 Some(s)
355 }
356 }
357 });
358 let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
359 TaskSnapshotRow {
360 id: t.id.as_u32(),
361 title: strip_ctrl(&t.title),
362 status: t.status.to_string(),
363 agent: t.assigned_agent.as_deref().map(strip_ctrl),
364 duration_ms,
365 error,
366 }
367 })
368 .collect();
369 Self {
370 graph_id: graph.id.to_string(),
371 goal: strip_ctrl(&graph.goal),
372 status: graph.status.to_string(),
373 tasks,
374 completed_at: None,
375 }
376 }
377}
378
379pub struct MetricsCollector {
380 tx: watch::Sender<MetricsSnapshot>,
381}
382
383impl MetricsCollector {
384 #[must_use]
385 pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
386 let (tx, rx) = watch::channel(MetricsSnapshot::default());
387 (Self { tx }, rx)
388 }
389
390 pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
391 self.tx.send_modify(f);
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 #![allow(clippy::field_reassign_with_default)]
398
399 use super::*;
400
401 #[test]
402 fn default_metrics_snapshot() {
403 let m = MetricsSnapshot::default();
404 assert_eq!(m.total_tokens, 0);
405 assert_eq!(m.api_calls, 0);
406 assert!(m.active_skills.is_empty());
407 assert!(m.active_mcp_tools.is_empty());
408 assert_eq!(m.mcp_tool_count, 0);
409 assert_eq!(m.mcp_server_count, 0);
410 assert!(m.provider_name.is_empty());
411 assert_eq!(m.summaries_count, 0);
412 assert!(m.stt_model.is_none());
414 assert!(m.compaction_model.is_none());
415 assert!(m.provider_temperature.is_none());
416 assert!(m.provider_top_p.is_none());
417 assert!(m.active_channel.is_empty());
418 assert!(m.embedding_model.is_empty());
419 assert!(m.token_budget.is_none());
420 assert!(!m.self_learning_enabled);
421 assert!(!m.semantic_cache_enabled);
422 }
423
424 #[test]
425 fn metrics_collector_update_phase2_fields() {
426 let (collector, rx) = MetricsCollector::new();
427 collector.update(|m| {
428 m.stt_model = Some("whisper-1".into());
429 m.compaction_model = Some("haiku".into());
430 m.provider_temperature = Some(0.7);
431 m.provider_top_p = Some(0.95);
432 m.active_channel = "tui".into();
433 m.embedding_model = "nomic-embed-text".into();
434 m.token_budget = Some(200_000);
435 m.self_learning_enabled = true;
436 m.semantic_cache_enabled = true;
437 });
438 let s = rx.borrow();
439 assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
440 assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
441 assert_eq!(s.provider_temperature, Some(0.7));
442 assert_eq!(s.provider_top_p, Some(0.95));
443 assert_eq!(s.active_channel, "tui");
444 assert_eq!(s.embedding_model, "nomic-embed-text");
445 assert_eq!(s.token_budget, Some(200_000));
446 assert!(s.self_learning_enabled);
447 assert!(s.semantic_cache_enabled);
448 }
449
450 #[test]
451 fn metrics_collector_update() {
452 let (collector, rx) = MetricsCollector::new();
453 collector.update(|m| {
454 m.api_calls = 5;
455 m.total_tokens = 1000;
456 });
457 let snapshot = rx.borrow().clone();
458 assert_eq!(snapshot.api_calls, 5);
459 assert_eq!(snapshot.total_tokens, 1000);
460 }
461
462 #[test]
463 fn metrics_collector_multiple_updates() {
464 let (collector, rx) = MetricsCollector::new();
465 collector.update(|m| m.api_calls = 1);
466 collector.update(|m| m.api_calls += 1);
467 assert_eq!(rx.borrow().api_calls, 2);
468 }
469
470 #[test]
471 fn metrics_snapshot_clone() {
472 let mut m = MetricsSnapshot::default();
473 m.provider_name = "ollama".into();
474 let cloned = m.clone();
475 assert_eq!(cloned.provider_name, "ollama");
476 }
477
478 #[test]
479 fn filter_metrics_tracking() {
480 let (collector, rx) = MetricsCollector::new();
481 collector.update(|m| {
482 m.filter_raw_tokens += 250;
483 m.filter_saved_tokens += 200;
484 m.filter_applications += 1;
485 });
486 collector.update(|m| {
487 m.filter_raw_tokens += 100;
488 m.filter_saved_tokens += 80;
489 m.filter_applications += 1;
490 });
491 let s = rx.borrow();
492 assert_eq!(s.filter_raw_tokens, 350);
493 assert_eq!(s.filter_saved_tokens, 280);
494 assert_eq!(s.filter_applications, 2);
495 }
496
497 #[test]
498 fn filter_confidence_and_command_metrics() {
499 let (collector, rx) = MetricsCollector::new();
500 collector.update(|m| {
501 m.filter_total_commands += 1;
502 m.filter_filtered_commands += 1;
503 m.filter_confidence_full += 1;
504 });
505 collector.update(|m| {
506 m.filter_total_commands += 1;
507 m.filter_confidence_partial += 1;
508 });
509 let s = rx.borrow();
510 assert_eq!(s.filter_total_commands, 2);
511 assert_eq!(s.filter_filtered_commands, 1);
512 assert_eq!(s.filter_confidence_full, 1);
513 assert_eq!(s.filter_confidence_partial, 1);
514 assert_eq!(s.filter_confidence_fallback, 0);
515 }
516
517 #[test]
518 fn summaries_count_tracks_summarizations() {
519 let (collector, rx) = MetricsCollector::new();
520 collector.update(|m| m.summaries_count += 1);
521 collector.update(|m| m.summaries_count += 1);
522 assert_eq!(rx.borrow().summaries_count, 2);
523 }
524
525 #[test]
526 fn cancellations_counter_increments() {
527 let (collector, rx) = MetricsCollector::new();
528 assert_eq!(rx.borrow().cancellations, 0);
529 collector.update(|m| m.cancellations += 1);
530 collector.update(|m| m.cancellations += 1);
531 assert_eq!(rx.borrow().cancellations, 2);
532 }
533
534 #[test]
535 fn security_event_detail_exact_128_not_truncated() {
536 let s = "a".repeat(128);
537 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
538 assert_eq!(ev.detail, s, "128-char string must not be truncated");
539 }
540
541 #[test]
542 fn security_event_detail_129_is_truncated() {
543 let s = "a".repeat(129);
544 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
545 assert!(
546 ev.detail.ends_with('…'),
547 "129-char string must end with ellipsis"
548 );
549 assert!(
550 ev.detail.len() <= 130,
551 "truncated detail must be at most 130 bytes"
552 );
553 }
554
555 #[test]
556 fn security_event_detail_multibyte_utf8_no_panic() {
557 let s = "中".repeat(43);
559 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
560 assert!(ev.detail.ends_with('…'));
561 }
562
563 #[test]
564 fn security_event_source_capped_at_64_chars() {
565 let long_source = "x".repeat(200);
566 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
567 assert_eq!(ev.source.len(), 64);
568 }
569
570 #[test]
571 fn security_event_source_strips_control_chars() {
572 let source = "tool\x00name\x1b[31m";
573 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
574 assert!(!ev.source.contains('\x00'));
575 assert!(!ev.source.contains('\x1b'));
576 }
577
578 #[test]
579 fn security_event_category_as_str() {
580 assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
581 assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
582 assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
583 assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
584 }
585
586 #[test]
587 fn ring_buffer_respects_cap_via_update() {
588 let (collector, rx) = MetricsCollector::new();
589 for i in 0..110u64 {
590 let event = SecurityEvent::new(
591 SecurityEventCategory::InjectionFlag,
592 "src",
593 format!("event {i}"),
594 );
595 collector.update(|m| {
596 if m.security_events.len() >= SECURITY_EVENT_CAP {
597 m.security_events.pop_front();
598 }
599 m.security_events.push_back(event);
600 });
601 }
602 let snap = rx.borrow();
603 assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
604 assert!(snap.security_events.back().unwrap().detail.contains("109"));
606 }
607
608 #[test]
609 fn security_events_empty_by_default() {
610 let m = MetricsSnapshot::default();
611 assert!(m.security_events.is_empty());
612 }
613
614 #[test]
615 fn orchestration_metrics_default_zero() {
616 let m = OrchestrationMetrics::default();
617 assert_eq!(m.plans_total, 0);
618 assert_eq!(m.tasks_total, 0);
619 assert_eq!(m.tasks_completed, 0);
620 assert_eq!(m.tasks_failed, 0);
621 assert_eq!(m.tasks_skipped, 0);
622 }
623
624 #[test]
625 fn metrics_snapshot_includes_orchestration_default_zero() {
626 let m = MetricsSnapshot::default();
627 assert_eq!(m.orchestration.plans_total, 0);
628 assert_eq!(m.orchestration.tasks_total, 0);
629 assert_eq!(m.orchestration.tasks_completed, 0);
630 }
631
632 #[test]
633 fn orchestration_metrics_update_via_collector() {
634 let (collector, rx) = MetricsCollector::new();
635 collector.update(|m| {
636 m.orchestration.plans_total += 1;
637 m.orchestration.tasks_total += 5;
638 m.orchestration.tasks_completed += 3;
639 m.orchestration.tasks_failed += 1;
640 m.orchestration.tasks_skipped += 1;
641 });
642 let s = rx.borrow();
643 assert_eq!(s.orchestration.plans_total, 1);
644 assert_eq!(s.orchestration.tasks_total, 5);
645 assert_eq!(s.orchestration.tasks_completed, 3);
646 assert_eq!(s.orchestration.tasks_failed, 1);
647 assert_eq!(s.orchestration.tasks_skipped, 1);
648 }
649
650 #[test]
651 fn strip_ctrl_removes_escape_sequences() {
652 let input = "hello\x1b[31mworld\x00end";
653 let result = strip_ctrl(input);
654 assert_eq!(result, "helloworldend");
655 }
656
657 #[test]
658 fn strip_ctrl_allows_tab_lf_cr() {
659 let input = "a\tb\nc\rd";
660 let result = strip_ctrl(input);
661 assert_eq!(result, "a\tb\nc\rd");
662 }
663
664 #[test]
665 fn task_graph_snapshot_is_stale_after_30s() {
666 let mut snap = TaskGraphSnapshot::default();
667 assert!(!snap.is_stale());
669 snap.completed_at = Some(std::time::Instant::now());
671 assert!(!snap.is_stale());
672 snap.completed_at = Some(
674 std::time::Instant::now()
675 .checked_sub(std::time::Duration::from_secs(31))
676 .unwrap(),
677 );
678 assert!(snap.is_stale());
679 }
680
681 #[test]
683 fn task_graph_snapshot_from_task_graph_maps_fields() {
684 use crate::orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
685
686 let mut graph = TaskGraph::new("My goal");
687 let mut task = TaskNode::new(0, "Do work", "description");
688 task.status = TaskStatus::Failed;
689 task.assigned_agent = Some("agent-1".into());
690 task.result = Some(TaskResult {
691 output: "error occurred here".into(),
692 artifacts: vec![],
693 duration_ms: 1234,
694 agent_id: None,
695 agent_def: None,
696 });
697 graph.tasks.push(task);
698 graph.status = GraphStatus::Failed;
699
700 let snap = TaskGraphSnapshot::from(&graph);
701 assert_eq!(snap.goal, "My goal");
702 assert_eq!(snap.status, "failed");
703 assert_eq!(snap.tasks.len(), 1);
704 let row = &snap.tasks[0];
705 assert_eq!(row.title, "Do work");
706 assert_eq!(row.status, "failed");
707 assert_eq!(row.agent.as_deref(), Some("agent-1"));
708 assert_eq!(row.duration_ms, 1234);
709 assert!(row.error.as_deref().unwrap().contains("error occurred"));
710 }
711
712 #[test]
714 fn task_graph_snapshot_from_compiles_with_feature() {
715 use crate::orchestration::TaskGraph;
716 let graph = TaskGraph::new("feature flag test");
717 let snap = TaskGraphSnapshot::from(&graph);
718 assert_eq!(snap.goal, "feature flag test");
719 assert!(snap.tasks.is_empty());
720 assert!(!snap.is_stale());
721 }
722
723 #[test]
725 fn task_graph_snapshot_error_truncated_at_80_chars() {
726 use crate::orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
727
728 let mut graph = TaskGraph::new("goal");
729 let mut task = TaskNode::new(0, "t", "d");
730 task.status = TaskStatus::Failed;
731 task.result = Some(TaskResult {
732 output: "e".repeat(100),
733 artifacts: vec![],
734 duration_ms: 0,
735 agent_id: None,
736 agent_def: None,
737 });
738 graph.tasks.push(task);
739
740 let snap = TaskGraphSnapshot::from(&graph);
741 let err = snap.tasks[0].error.as_ref().unwrap();
742 assert!(err.ends_with('…'), "truncated error must end with ellipsis");
743 assert!(
744 err.len() <= 83,
745 "truncated error must not exceed 80 chars + ellipsis"
746 );
747 }
748
749 #[test]
751 fn task_graph_snapshot_strips_control_chars_from_title() {
752 use crate::orchestration::{TaskGraph, TaskNode};
753
754 let mut graph = TaskGraph::new("goal\x1b[31m");
755 let task = TaskNode::new(0, "title\x00injected", "d");
756 graph.tasks.push(task);
757
758 let snap = TaskGraphSnapshot::from(&graph);
759 assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
760 assert!(
761 !snap.tasks[0].title.contains('\x00'),
762 "title must not contain null byte"
763 );
764 }
765
766 #[test]
767 fn graph_metrics_default_zero() {
768 let m = MetricsSnapshot::default();
769 assert_eq!(m.graph_entities_total, 0);
770 assert_eq!(m.graph_edges_total, 0);
771 assert_eq!(m.graph_communities_total, 0);
772 assert_eq!(m.graph_extraction_count, 0);
773 assert_eq!(m.graph_extraction_failures, 0);
774 }
775
776 #[test]
777 fn graph_metrics_update_via_collector() {
778 let (collector, rx) = MetricsCollector::new();
779 collector.update(|m| {
780 m.graph_entities_total = 5;
781 m.graph_edges_total = 10;
782 m.graph_communities_total = 2;
783 m.graph_extraction_count = 7;
784 m.graph_extraction_failures = 1;
785 });
786 let snapshot = rx.borrow().clone();
787 assert_eq!(snapshot.graph_entities_total, 5);
788 assert_eq!(snapshot.graph_edges_total, 10);
789 assert_eq!(snapshot.graph_communities_total, 2);
790 assert_eq!(snapshot.graph_extraction_count, 7);
791 assert_eq!(snapshot.graph_extraction_failures, 1);
792 }
793}