1#![allow(clippy::doc_markdown)]
6
7use crate::error::EventSinkError;
15use async_trait::async_trait;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::sync::{Arc, RwLock};
19use thiserror::Error;
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct RazeriEvent {
27 event_id: String,
28 timestamp: DateTime<Utc>,
29 category: EventCategory,
30 severity: EventSeverity,
31 workflow: Option<WorkflowContext>,
32 subject: EventSubject,
33 payload: EventPayload,
34}
35
36#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39#[non_exhaustive]
40pub enum EventCategory {
41 Profiling,
43 Materialization,
45 Design,
47 Plugin,
49 Storage,
51 Ai,
53}
54
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum EventSeverity {
60 Info,
62 Warning,
64 Error,
66 Critical,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[non_exhaustive]
73pub struct WorkflowContext {
74 pub workflow_id: String,
76 pub request_id: Option<String>,
78 pub actor: Option<String>,
80 pub surface: Option<String>,
82}
83
84impl WorkflowContext {
85 pub fn new(workflow_id: impl Into<String>) -> Result<Self, EventValidationError> {
91 let workflow_id = workflow_id.into();
92 if workflow_id.trim().is_empty() {
93 return Err(EventValidationError::WorkflowIdRequired);
94 }
95
96 Ok(Self { workflow_id, request_id: None, actor: None, surface: None })
97 }
98
99 #[must_use]
101 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
102 self.request_id = Some(request_id.into());
103 self
104 }
105
106 #[must_use]
108 pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
109 self.actor = Some(actor.into());
110 self
111 }
112
113 #[must_use]
115 pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
116 self.surface = Some(surface.into());
117 self
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[non_exhaustive]
124pub struct EventSubject {
125 pub kind: EventSubjectKind,
127 pub name: String,
129}
130
131impl EventSubject {
132 pub fn new(kind: EventSubjectKind, name: impl Into<String>) -> Self {
134 Self { kind, name: name.into() }
135 }
136
137 pub fn source(name: impl Into<String>) -> Self {
139 Self::new(EventSubjectKind::Source, name)
140 }
141
142 pub fn entity(name: impl Into<String>) -> Self {
144 Self::new(EventSubjectKind::Entity, name)
145 }
146
147 pub fn design(name: impl Into<String>) -> Self {
149 Self::new(EventSubjectKind::Design, name)
150 }
151
152 pub fn plugin(name: impl Into<String>) -> Self {
154 Self::new(EventSubjectKind::Plugin, name)
155 }
156
157 pub fn storage(name: impl Into<String>) -> Self {
159 Self::new(EventSubjectKind::StoragePath, name)
160 }
161
162 pub fn llm_model(name: impl Into<String>) -> Self {
164 Self::new(EventSubjectKind::LlmModel, name)
165 }
166}
167
168#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(rename_all = "snake_case")]
171#[non_exhaustive]
172pub enum EventSubjectKind {
173 Source,
175 Entity,
177 Design,
179 Plugin,
181 StoragePath,
183 LlmModel,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(tag = "kind", rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum EventPayload {
192 ProfilingStarted {
194 source: String,
196 },
197 ProfilingCompleted {
199 source: String,
201 fields: usize,
203 rows: usize,
205 duration_ms: u64,
207 },
208 MaterializationStarted {
210 entity: String,
212 platform: String,
214 },
215 MaterializationCompleted {
217 entity: String,
219 platform: String,
221 artifacts: usize,
223 duration_ms: u64,
225 },
226 MaterializationFailed {
228 design: String,
230 platform: String,
232 message: String,
234 },
235 DesignValidated {
237 design: String,
239 errors: usize,
241 warnings: usize,
243 },
244 DesignLoaded {
246 path: String,
248 entities: usize,
250 },
251 PluginLoaded {
253 name: String,
255 plugin_type: String,
257 source: String,
259 },
260 StorageOperationCompleted {
262 operation: String,
264 path: String,
266 bytes: u64,
268 duration_ms: u64,
270 },
271 StorageWarning {
274 entity: String,
276 feature: String,
278 platform: String,
280 recommendation: String,
282 },
283 LlmRequestCompleted {
285 provider: String,
287 model: String,
289 tokens: u64,
291 duration_ms: u64,
293 },
294 FileRouted {
296 path: String,
298 destination: String,
300 rule: String,
302 bytes: u64,
304 },
305 FileSetAside {
308 path: String,
310 destination: String,
312 reason: String,
314 },
315 FileNotMoved {
318 path: String,
320 reason: String,
322 },
323 ScanCompleted {
325 location: String,
327 files: usize,
329 routed: usize,
331 set_aside: usize,
333 not_moved: usize,
335 duration_ms: u64,
337 },
338}
339
340impl EventPayload {
341 #[must_use]
343 pub fn category(&self) -> EventCategory {
344 match self {
345 Self::ProfilingStarted { .. } | Self::ProfilingCompleted { .. } => {
346 EventCategory::Profiling
347 }
348 Self::MaterializationStarted { .. } | Self::MaterializationCompleted { .. } => {
349 EventCategory::Materialization
350 }
351 Self::MaterializationFailed { .. } => EventCategory::Materialization,
352 Self::DesignValidated { .. } | Self::DesignLoaded { .. } => EventCategory::Design,
353 Self::PluginLoaded { .. } => EventCategory::Plugin,
354 Self::StorageOperationCompleted { .. } | Self::StorageWarning { .. } => {
355 EventCategory::Storage
356 }
357 Self::LlmRequestCompleted { .. } => EventCategory::Ai,
358 Self::FileRouted { .. }
359 | Self::FileSetAside { .. }
360 | Self::FileNotMoved { .. }
361 | Self::ScanCompleted { .. } => EventCategory::Storage,
362 }
363 }
364
365 #[must_use]
367 pub fn severity(&self) -> EventSeverity {
368 match self {
369 Self::MaterializationFailed { .. } | Self::FileNotMoved { .. } => EventSeverity::Error,
370 Self::DesignValidated { errors, .. } if *errors > 0 => EventSeverity::Error,
371 Self::DesignValidated { warnings, .. } if *warnings > 0 => EventSeverity::Warning,
372 Self::StorageWarning { .. } | Self::FileSetAside { .. } => EventSeverity::Warning,
373 _ => EventSeverity::Info,
374 }
375 }
376
377 #[must_use]
379 pub fn requires_workflow_context(&self) -> bool {
380 match self {
381 Self::ProfilingStarted { .. }
382 | Self::ProfilingCompleted { .. }
383 | Self::MaterializationStarted { .. }
384 | Self::MaterializationCompleted { .. }
385 | Self::MaterializationFailed { .. }
386 | Self::DesignValidated { .. }
387 | Self::StorageOperationCompleted { .. }
388 | Self::LlmRequestCompleted { .. } => true,
389 Self::DesignLoaded { .. }
390 | Self::PluginLoaded { .. }
391 | Self::StorageWarning { .. }
392 | Self::FileRouted { .. }
393 | Self::FileSetAside { .. }
394 | Self::FileNotMoved { .. }
395 | Self::ScanCompleted { .. } => false,
396 }
397 }
398
399 pub(crate) fn name(&self) -> &'static str {
400 match self {
401 Self::ProfilingStarted { .. } => "profiling_started",
402 Self::ProfilingCompleted { .. } => "profiling_completed",
403 Self::MaterializationStarted { .. } => "materialization_started",
404 Self::MaterializationCompleted { .. } => "materialization_completed",
405 Self::MaterializationFailed { .. } => "materialization_failed",
406 Self::DesignValidated { .. } => "design_validated",
407 Self::DesignLoaded { .. } => "design_loaded",
408 Self::PluginLoaded { .. } => "plugin_loaded",
409 Self::StorageOperationCompleted { .. } => "storage_operation_completed",
410 Self::StorageWarning { .. } => "storage_warning",
411 Self::LlmRequestCompleted { .. } => "llm_request_completed",
412 Self::FileRouted { .. } => "file_routed",
413 Self::FileSetAside { .. } => "file_set_aside",
414 Self::FileNotMoved { .. } => "file_not_moved",
415 Self::ScanCompleted { .. } => "scan_completed",
416 }
417 }
418
419 fn summary(&self) -> EventSummary {
420 match self {
421 Self::ProfilingStarted { source } => profiling_started_summary(source),
422 Self::ProfilingCompleted { source, fields, rows, duration_ms } => {
423 profiling_completed_summary(source, *fields, *rows, *duration_ms)
424 }
425 Self::MaterializationStarted { entity, platform } => {
426 materialization_started_summary(entity, platform)
427 }
428 Self::MaterializationCompleted { entity, platform, artifacts, duration_ms } => {
429 materialization_completed_summary(entity, platform, *artifacts, *duration_ms)
430 }
431 Self::MaterializationFailed { design, platform, message } => {
432 materialization_failed_summary(design, platform, message)
433 }
434 Self::DesignValidated { design, errors, warnings } => {
435 design_validated_summary(design, *errors, *warnings)
436 }
437 Self::DesignLoaded { path, entities } => design_loaded_summary(path, *entities),
438 Self::PluginLoaded { name, plugin_type, source } => {
439 plugin_loaded_summary(name, plugin_type, source)
440 }
441 Self::StorageOperationCompleted { operation, path, bytes, duration_ms } => {
442 storage_operation_completed_summary(operation, path, *bytes, *duration_ms)
443 }
444 Self::StorageWarning { entity, feature, platform, recommendation } => {
445 storage_warning_summary(entity, feature, platform, recommendation)
446 }
447 Self::LlmRequestCompleted { provider, model, tokens, duration_ms } => {
448 llm_request_completed_summary(provider, model, *tokens, *duration_ms)
449 }
450 Self::FileRouted { path, destination, rule, bytes } => EventSummary {
451 title: "File routed".to_string(),
452 detail: format!(
453 "'{path}' moved to '{destination}' by rule '{rule}', {}",
454 counted(*bytes, "byte", "bytes")
455 ),
456 },
457 Self::FileSetAside { path, destination, reason } => EventSummary {
458 title: "File set aside".to_string(),
459 detail: format!("'{path}' set aside in '{destination}': {reason}"),
460 },
461 Self::FileNotMoved { path, reason } => EventSummary {
462 title: "File not moved".to_string(),
463 detail: format!("'{path}' was not moved: {reason}"),
464 },
465 Self::ScanCompleted { location, files, routed, set_aside, not_moved, duration_ms } => {
466 EventSummary {
467 title: "Scan completed".to_string(),
468 detail: format!(
469 "'{location}' held {}: {routed} routed, {set_aside} set aside, \
470 {not_moved} not moved, in {duration_ms} ms",
471 counted(*files as u64, "file", "files")
472 ),
473 }
474 }
475 }
476 }
477}
478
479fn counted(n: u64, one: &str, many: &str) -> String {
481 format!("{n} {}", if n == 1 { one } else { many })
482}
483
484fn profiling_started_summary(source: &str) -> EventSummary {
488 EventSummary {
489 title: "Profiling started".to_string(),
490 detail: format!("Profiling started for source '{source}'"),
491 }
492}
493
494fn profiling_completed_summary(
501 source: &str,
502 fields: usize,
503 rows: usize,
504 duration_ms: u64,
505) -> EventSummary {
506 EventSummary {
507 title: "Profiling completed".to_string(),
508 detail: format!(
509 "Source '{source}' profiled with {}, {} in {duration_ms} ms",
510 counted(fields as u64, "field", "fields"),
511 counted(rows as u64, "row", "rows"),
512 ),
513 }
514}
515
516fn materialization_started_summary(entity: &str, platform: &str) -> EventSummary {
521 EventSummary {
522 title: "Materialization started".to_string(),
523 detail: format!("Entity '{entity}' started materialization for {platform}"),
524 }
525}
526
527fn materialization_completed_summary(
534 entity: &str,
535 platform: &str,
536 artifacts: usize,
537 duration_ms: u64,
538) -> EventSummary {
539 EventSummary {
540 title: "Materialization completed".to_string(),
541 detail: format!(
542 "Entity '{entity}' produced {} for {platform} in {duration_ms} ms",
543 counted(artifacts as u64, "artifact", "artifacts"),
544 ),
545 }
546}
547
548fn materialization_failed_summary(design: &str, platform: &str, message: &str) -> EventSummary {
554 EventSummary {
555 title: "Materialization failed".to_string(),
556 detail: format!("Design '{design}' failed materialization for {platform}: {message}"),
557 }
558}
559
560fn design_validated_summary(design: &str, errors: usize, warnings: usize) -> EventSummary {
566 EventSummary {
567 title: "Design validated".to_string(),
568 detail: format!(
569 "Design '{design}' validated with {} and {}",
570 counted(errors as u64, "error", "errors"),
571 counted(warnings as u64, "warning", "warnings"),
572 ),
573 }
574}
575
576fn design_loaded_summary(path: &str, entities: usize) -> EventSummary {
581 EventSummary {
582 title: "Design loaded".to_string(),
583 detail: format!(
584 "Loaded design from '{path}' with {}",
585 counted(entities as u64, "entity", "entities")
586 ),
587 }
588}
589
590fn plugin_loaded_summary(name: &str, plugin_type: &str, source: &str) -> EventSummary {
596 EventSummary {
597 title: "Plugin loaded".to_string(),
598 detail: format!("Loaded {plugin_type} plugin '{name}' from {source}"),
599 }
600}
601
602fn storage_operation_completed_summary(
609 operation: &str,
610 path: &str,
611 bytes: u64,
612 duration_ms: u64,
613) -> EventSummary {
614 EventSummary {
615 title: "Storage operation completed".to_string(),
616 detail: format!(
617 "Operation '{operation}' processed {} at '{path}' in {duration_ms} ms",
618 counted(bytes, "byte", "bytes"),
619 ),
620 }
621}
622
623fn storage_warning_summary(
630 entity: &str,
631 feature: &str,
632 platform: &str,
633 recommendation: &str,
634) -> EventSummary {
635 EventSummary {
636 title: "Storage warning".to_string(),
637 detail: format!(
638 "Entity '{entity}': feature '{feature}' unavailable on '{platform}'; {recommendation}"
639 ),
640 }
641}
642
643fn llm_request_completed_summary(
650 provider: &str,
651 model: &str,
652 tokens: u64,
653 duration_ms: u64,
654) -> EventSummary {
655 EventSummary {
656 title: "LLM request completed".to_string(),
657 detail: format!(
658 "Provider '{provider}' model '{model}' used {} in {duration_ms} ms",
659 counted(tokens, "token", "tokens"),
660 ),
661 }
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
666#[non_exhaustive]
667pub struct EventFilterKeys {
668 pub category: EventCategory,
670 pub severity: EventSeverity,
672 pub workflow_id: Option<String>,
674 pub subject_kind: EventSubjectKind,
676 pub subject_name: String,
678 pub actor: Option<String>,
680 pub surface: Option<String>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[non_exhaustive]
687pub struct EventSummary {
688 pub title: String,
690 pub detail: String,
692}
693
694#[derive(Debug, Error, Clone, PartialEq, Eq)]
696#[non_exhaustive]
697pub enum EventValidationError {
698 #[error("workflow-scoped event payload '{payload}' requires workflow context")]
700 WorkflowContextRequired {
701 payload: String,
703 },
704 #[error("workflow context requires a non-empty workflow_id")]
706 WorkflowIdRequired,
707}
708
709impl RazeriEvent {
710 pub fn new(
716 event_id: impl Into<String>,
717 timestamp: DateTime<Utc>,
718 subject: EventSubject,
719 payload: EventPayload,
720 ) -> Result<Self, EventValidationError> {
721 Self::build(event_id.into(), timestamp, None, subject, payload)
722 }
723
724 pub fn new_with_workflow(
730 event_id: impl Into<String>,
731 timestamp: DateTime<Utc>,
732 workflow: WorkflowContext,
733 subject: EventSubject,
734 payload: EventPayload,
735 ) -> Result<Self, EventValidationError> {
736 Self::build(event_id.into(), timestamp, Some(workflow), subject, payload)
737 }
738
739 fn build(
740 event_id: String,
741 timestamp: DateTime<Utc>,
742 workflow: Option<WorkflowContext>,
743 subject: EventSubject,
744 payload: EventPayload,
745 ) -> Result<Self, EventValidationError> {
746 let event = Self {
747 event_id,
748 timestamp,
749 category: payload.category(),
750 severity: payload.severity(),
751 workflow,
752 subject,
753 payload,
754 };
755
756 event.validate()?;
757 Ok(event)
758 }
759
760 pub fn validate(&self) -> Result<(), EventValidationError> {
766 if self.payload.requires_workflow_context() && self.workflow.is_none() {
767 return Err(EventValidationError::WorkflowContextRequired {
768 payload: self.payload.name().to_string(),
769 });
770 }
771
772 if self.workflow.as_ref().is_some_and(|workflow| workflow.workflow_id.trim().is_empty()) {
773 return Err(EventValidationError::WorkflowIdRequired);
774 }
775
776 Ok(())
777 }
778
779 #[must_use]
781 pub fn event_id(&self) -> &str {
782 &self.event_id
783 }
784
785 #[must_use]
787 pub fn timestamp(&self) -> DateTime<Utc> {
788 self.timestamp
789 }
790
791 #[must_use]
793 pub fn category(&self) -> EventCategory {
794 self.category
795 }
796
797 #[must_use]
799 pub fn severity(&self) -> EventSeverity {
800 self.severity
801 }
802
803 #[must_use]
805 pub fn workflow(&self) -> Option<&WorkflowContext> {
806 self.workflow.as_ref()
807 }
808
809 #[must_use]
811 pub fn subject(&self) -> &EventSubject {
812 &self.subject
813 }
814
815 #[must_use]
817 pub fn payload(&self) -> &EventPayload {
818 &self.payload
819 }
820
821 #[must_use]
823 pub fn filter_keys(&self) -> EventFilterKeys {
824 EventFilterKeys {
825 category: self.category,
826 severity: self.severity,
827 workflow_id: self.workflow.as_ref().map(|workflow| workflow.workflow_id.clone()),
828 subject_kind: self.subject.kind,
829 subject_name: self.subject.name.clone(),
830 actor: self.workflow.as_ref().and_then(|workflow| workflow.actor.clone()),
831 surface: self.workflow.as_ref().and_then(|workflow| workflow.surface.clone()),
832 }
833 }
834
835 #[must_use]
837 pub fn summary(&self) -> EventSummary {
838 self.payload.summary()
839 }
840
841 pub fn design_validated(
847 event_id: impl Into<String>,
848 timestamp: DateTime<Utc>,
849 workflow: WorkflowContext,
850 design: impl Into<String>,
851 errors: usize,
852 warnings: usize,
853 ) -> Result<Self, EventValidationError> {
854 let design = design.into();
855 Self::new_with_workflow(
856 event_id,
857 timestamp,
858 workflow,
859 EventSubject::design(design.clone()),
860 EventPayload::DesignValidated { design, errors, warnings },
861 )
862 }
863
864 pub fn materialization_started(
870 event_id: impl Into<String>,
871 timestamp: DateTime<Utc>,
872 workflow: WorkflowContext,
873 entity: impl Into<String>,
874 platform: impl Into<String>,
875 ) -> Result<Self, EventValidationError> {
876 let entity = entity.into();
877 let platform = platform.into();
878 Self::new_with_workflow(
879 event_id,
880 timestamp,
881 workflow,
882 EventSubject::entity(entity.clone()),
883 EventPayload::MaterializationStarted { entity, platform },
884 )
885 }
886
887 pub fn materialization_completed(
893 event_id: impl Into<String>,
894 timestamp: DateTime<Utc>,
895 workflow: WorkflowContext,
896 entity: impl Into<String>,
897 platform: impl Into<String>,
898 artifacts: usize,
899 duration_ms: u64,
900 ) -> Result<Self, EventValidationError> {
901 let entity = entity.into();
902 let platform = platform.into();
903 Self::new_with_workflow(
904 event_id,
905 timestamp,
906 workflow,
907 EventSubject::entity(entity.clone()),
908 EventPayload::MaterializationCompleted { entity, platform, artifacts, duration_ms },
909 )
910 }
911
912 pub fn materialization_failed(
918 event_id: impl Into<String>,
919 timestamp: DateTime<Utc>,
920 workflow: WorkflowContext,
921 design: impl Into<String>,
922 platform: impl Into<String>,
923 message: impl Into<String>,
924 ) -> Result<Self, EventValidationError> {
925 let design = design.into();
926 let platform = platform.into();
927 let message = message.into();
928 Self::new_with_workflow(
929 event_id,
930 timestamp,
931 workflow,
932 EventSubject::design(design.clone()),
933 EventPayload::MaterializationFailed { design, platform, message },
934 )
935 }
936
937 pub fn profiling_started(
943 event_id: impl Into<String>,
944 timestamp: DateTime<Utc>,
945 workflow: WorkflowContext,
946 source: impl Into<String>,
947 ) -> Result<Self, EventValidationError> {
948 let source = source.into();
949 Self::new_with_workflow(
950 event_id,
951 timestamp,
952 workflow,
953 EventSubject::source(source.clone()),
954 EventPayload::ProfilingStarted { source },
955 )
956 }
957
958 pub fn profiling_completed(
964 event_id: impl Into<String>,
965 timestamp: DateTime<Utc>,
966 workflow: WorkflowContext,
967 source: impl Into<String>,
968 fields: usize,
969 rows: usize,
970 duration_ms: u64,
971 ) -> Result<Self, EventValidationError> {
972 let source = source.into();
973 Self::new_with_workflow(
974 event_id,
975 timestamp,
976 workflow,
977 EventSubject::source(source.clone()),
978 EventPayload::ProfilingCompleted { source, fields, rows, duration_ms },
979 )
980 }
981
982 pub fn design_loaded(
988 event_id: impl Into<String>,
989 timestamp: DateTime<Utc>,
990 path: impl Into<String>,
991 entities: usize,
992 ) -> Result<Self, EventValidationError> {
993 let path = path.into();
994 Self::new(
995 event_id,
996 timestamp,
997 EventSubject::design(path.clone()),
998 EventPayload::DesignLoaded { path, entities },
999 )
1000 }
1001
1002 pub fn plugin_loaded(
1008 event_id: impl Into<String>,
1009 timestamp: DateTime<Utc>,
1010 name: impl Into<String>,
1011 plugin_type: impl Into<String>,
1012 source: impl Into<String>,
1013 ) -> Result<Self, EventValidationError> {
1014 let name = name.into();
1015 let plugin_type = plugin_type.into();
1016 let source = source.into();
1017 Self::new(
1018 event_id,
1019 timestamp,
1020 EventSubject::plugin(name.clone()),
1021 EventPayload::PluginLoaded { name, plugin_type, source },
1022 )
1023 }
1024
1025 pub fn storage_operation_completed(
1031 event_id: impl Into<String>,
1032 timestamp: DateTime<Utc>,
1033 workflow: WorkflowContext,
1034 operation: impl Into<String>,
1035 path: impl Into<String>,
1036 bytes: u64,
1037 duration_ms: u64,
1038 ) -> Result<Self, EventValidationError> {
1039 let operation = operation.into();
1040 let path = path.into();
1041 Self::new_with_workflow(
1042 event_id,
1043 timestamp,
1044 workflow,
1045 EventSubject::storage(path.clone()),
1046 EventPayload::StorageOperationCompleted { operation, path, bytes, duration_ms },
1047 )
1048 }
1049
1050 pub fn llm_request_completed(
1056 event_id: impl Into<String>,
1057 timestamp: DateTime<Utc>,
1058 workflow: WorkflowContext,
1059 provider: impl Into<String>,
1060 model: impl Into<String>,
1061 tokens: u64,
1062 duration_ms: u64,
1063 ) -> Result<Self, EventValidationError> {
1064 let provider = provider.into();
1065 let model = model.into();
1066 Self::new_with_workflow(
1067 event_id,
1068 timestamp,
1069 workflow,
1070 EventSubject::llm_model(model.clone()),
1071 EventPayload::LlmRequestCompleted { provider, model, tokens, duration_ms },
1072 )
1073 }
1074}
1075
1076#[async_trait]
1078pub trait EventEmitter: Send + Sync {
1079 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)>;
1081}
1082
1083#[async_trait]
1091pub trait EventSink: Send + Sync {
1092 fn name(&self) -> &str;
1094
1095 async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError>;
1101
1102 async fn flush(&self) -> Result<(), EventSinkError>;
1108}
1109
1110pub struct EventRegistry {
1122 sinks: Vec<Arc<dyn EventSink>>,
1123 history: RwLock<Vec<RazeriEvent>>,
1124}
1125
1126impl EventRegistry {
1127 #[must_use]
1129 pub fn new() -> Self {
1130 Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1131 }
1132
1133 pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1135 self.sinks.push(sink);
1136 }
1137
1138 pub async fn emit(&self, event: &RazeriEvent) -> Vec<(String, EventSinkError)> {
1144 self.record(event.clone());
1145 let mut errors = Vec::new();
1146 for sink in &self.sinks {
1147 if let Err(e) = sink.on_event(event).await {
1148 errors.push((sink.name().to_string(), e));
1149 }
1150 }
1151 errors
1152 }
1153
1154 pub fn record(&self, event: RazeriEvent) {
1156 self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1157 }
1158
1159 pub fn events(&self) -> Vec<RazeriEvent> {
1161 self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1162 }
1163
1164 pub fn recent_events(&self, limit: usize) -> Vec<RazeriEvent> {
1166 let events = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1167 let start = events.len().saturating_sub(limit);
1168 events[start..].to_vec()
1169 }
1170
1171 pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<RazeriEvent> {
1173 self.history
1174 .read()
1175 .unwrap_or_else(std::sync::PoisonError::into_inner)
1176 .iter()
1177 .filter(|event| {
1178 event.workflow().is_some_and(|workflow| workflow.workflow_id == workflow_id)
1179 })
1180 .cloned()
1181 .collect()
1182 }
1183
1184 pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1186 self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1187 }
1188
1189 pub async fn flush_all(&self) -> Vec<(String, EventSinkError)> {
1191 let mut errors = Vec::new();
1192 for sink in &self.sinks {
1193 if let Err(e) = sink.flush().await {
1194 errors.push((sink.name().to_string(), e));
1195 }
1196 }
1197 errors
1198 }
1199
1200 #[must_use]
1203 pub fn sink_names(&self) -> Vec<String> {
1204 self.sinks.iter().map(|sink| sink.name().to_owned()).collect()
1205 }
1206
1207 pub fn len(&self) -> usize {
1209 self.sinks.len()
1210 }
1211
1212 pub fn is_empty(&self) -> bool {
1214 self.sinks.is_empty()
1215 }
1216
1217 pub fn query(&self, q: &EventQuery) -> Vec<RazeriEvent> {
1219 let history = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1220 let iter = history.iter().filter(|e| {
1221 q.category.is_none_or(|c| e.category() == c)
1222 && q.min_severity.is_none_or(|s| e.severity() >= s)
1223 && q.workflow_id
1224 .as_ref()
1225 .is_none_or(|wf| e.workflow().is_some_and(|w| w.workflow_id == *wf))
1226 && q.subject_name.as_ref().is_none_or(|n| e.subject().name == *n)
1227 && q.subject_kind.is_none_or(|k| e.subject().kind == k)
1228 && q.since.is_none_or(|t| e.timestamp() >= t)
1229 });
1230 match q.limit {
1231 Some(n) => iter.take(n).cloned().collect(),
1232 None => iter.cloned().collect(),
1233 }
1234 }
1235}
1236
1237#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1242pub struct EventQuery {
1243 pub category: Option<EventCategory>,
1245 pub min_severity: Option<EventSeverity>,
1247 pub workflow_id: Option<String>,
1249 pub subject_name: Option<String>,
1251 pub subject_kind: Option<EventSubjectKind>,
1253 pub since: Option<DateTime<Utc>>,
1255 pub limit: Option<usize>,
1257}
1258
1259impl Default for EventRegistry {
1260 fn default() -> Self {
1261 Self::new()
1262 }
1263}
1264
1265#[async_trait]
1266impl EventEmitter for EventRegistry {
1267 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)> {
1268 self.emit(&event).await
1269 }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275 use std::sync::atomic::{AtomicUsize, Ordering};
1276
1277 #[test]
1280 fn a_count_of_one_says_its_noun_once() {
1281 let one = [
1282 EventPayload::ProfilingCompleted {
1283 source: "s".into(),
1284 fields: 1,
1285 rows: 1,
1286 duration_ms: 1,
1287 },
1288 EventPayload::MaterializationCompleted {
1289 entity: "e".into(),
1290 platform: "p".into(),
1291 artifacts: 1,
1292 duration_ms: 1,
1293 },
1294 EventPayload::DesignValidated { design: "d".into(), errors: 1, warnings: 1 },
1295 EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 1 },
1296 EventPayload::StorageOperationCompleted {
1297 operation: "put".into(),
1298 path: "p".into(),
1299 bytes: 1,
1300 duration_ms: 1,
1301 },
1302 EventPayload::LlmRequestCompleted {
1303 provider: "p".into(),
1304 model: "m".into(),
1305 tokens: 1,
1306 duration_ms: 1,
1307 },
1308 ];
1309 for payload in one {
1310 let detail = payload.summary().detail;
1311 for plural in
1312 ["1 fields", "1 rows", "1 artifacts", "1 errors", "1 warnings", "1 entities"]
1313 {
1314 assert!(!detail.contains(plural), "{detail}");
1315 }
1316 assert!(!detail.contains("1 bytes") && !detail.contains("1 tokens"), "{detail}");
1317 }
1318 let two = EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 2 };
1319 assert!(two.summary().detail.contains("2 entities"), "{}", two.summary().detail);
1320 }
1321
1322 #[test]
1326 fn a_file_set_aside_warns_and_a_file_not_moved_is_an_error() {
1327 let aside = EventPayload::FileSetAside {
1328 path: "in/x.csv".into(),
1329 destination: "quarantine/x.csv".into(),
1330 reason: "no rule routes it".into(),
1331 };
1332 let stuck = EventPayload::FileNotMoved { path: "in/y.csv".into(), reason: "denied".into() };
1333 let routed = EventPayload::FileRouted {
1334 path: "in/z.csv".into(),
1335 destination: "raw/z.csv".into(),
1336 rule: "customers".into(),
1337 bytes: 1,
1338 };
1339 assert_eq!(aside.severity(), EventSeverity::Warning);
1340 assert_eq!(stuck.severity(), EventSeverity::Error);
1341 assert_eq!(routed.severity(), EventSeverity::Info);
1342 assert_eq!(aside.category(), EventCategory::Storage);
1343 assert_eq!(aside.name(), "file_set_aside");
1344 assert!(routed.summary().detail.ends_with("1 byte"), "{}", routed.summary().detail);
1345 }
1346
1347 fn sample_timestamp() -> DateTime<Utc> {
1348 DateTime::parse_from_rfc3339("2026-03-13T12:34:56Z")
1349 .expect("valid timestamp fixture")
1350 .with_timezone(&Utc)
1351 }
1352
1353 fn sample_workflow() -> WorkflowContext {
1354 WorkflowContext::new("workflow-123")
1355 .expect("workflow id should be valid")
1356 .with_request_id("request-456")
1357 .with_actor("analyst@example.com")
1358 .with_surface("web")
1359 }
1360
1361 struct CountingSink {
1362 event_count: AtomicUsize,
1363 flush_count: AtomicUsize,
1364 }
1365
1366 impl CountingSink {
1367 fn new() -> Self {
1368 Self { event_count: AtomicUsize::new(0), flush_count: AtomicUsize::new(0) }
1369 }
1370
1371 fn events_received(&self) -> usize {
1372 self.event_count.load(Ordering::SeqCst)
1373 }
1374
1375 fn flushes(&self) -> usize {
1376 self.flush_count.load(Ordering::SeqCst)
1377 }
1378 }
1379
1380 #[async_trait]
1381 impl EventSink for CountingSink {
1382 fn name(&self) -> &'static str {
1383 "counting"
1384 }
1385
1386 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1387 self.event_count.fetch_add(1, Ordering::SeqCst);
1388 Ok(())
1389 }
1390
1391 async fn flush(&self) -> Result<(), EventSinkError> {
1392 self.flush_count.fetch_add(1, Ordering::SeqCst);
1393 Ok(())
1394 }
1395 }
1396
1397 struct FailingSink;
1398
1399 #[async_trait]
1400 impl EventSink for FailingSink {
1401 fn name(&self) -> &'static str {
1402 "failing"
1403 }
1404
1405 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1406 Err(EventSinkError::SinkError("intentional failure".to_string()))
1407 }
1408
1409 async fn flush(&self) -> Result<(), EventSinkError> {
1410 Err(EventSinkError::SinkError("intentional failure".to_string()))
1411 }
1412 }
1413
1414 fn sample_event() -> RazeriEvent {
1415 RazeriEvent::new_with_workflow(
1416 "event-123",
1417 sample_timestamp(),
1418 sample_workflow(),
1419 EventSubject::source("test.csv"),
1420 EventPayload::ProfilingCompleted {
1421 source: "test.csv".to_string(),
1422 fields: 10,
1423 rows: 1000,
1424 duration_ms: 42,
1425 },
1426 )
1427 .expect("workflow-scoped sample event should be valid")
1428 }
1429
1430 fn plugin_event() -> RazeriEvent {
1431 RazeriEvent::new(
1432 "event-plugin-1",
1433 sample_timestamp(),
1434 EventSubject::plugin("builtin-logger"),
1435 EventPayload::PluginLoaded {
1436 name: "builtin-logger".to_string(),
1437 plugin_type: "event_sink".to_string(),
1438 source: "builtin".to_string(),
1439 },
1440 )
1441 .expect("plugin events are not workflow scoped")
1442 }
1443
1444 #[tokio::test]
1445 async fn event_dispatched_to_all_sinks() {
1446 let sink1 = Arc::new(CountingSink::new());
1447 let sink2 = Arc::new(CountingSink::new());
1448
1449 let mut registry = EventRegistry::new();
1450 registry.register(sink1.clone());
1451 registry.register(sink2.clone());
1452
1453 let errors = registry.emit(&sample_event()).await;
1454 assert!(errors.is_empty());
1455 assert_eq!(sink1.events_received(), 1);
1456 assert_eq!(sink2.events_received(), 1);
1457 }
1458
1459 #[tokio::test]
1460 async fn sink_failure_does_not_halt_dispatch() {
1461 let good_sink = Arc::new(CountingSink::new());
1462 let bad_sink: Arc<dyn EventSink> = Arc::new(FailingSink);
1463
1464 let mut registry = EventRegistry::new();
1465 registry.register(bad_sink);
1466 registry.register(good_sink.clone());
1467
1468 let errors = registry.emit(&sample_event()).await;
1469 assert_eq!(errors.len(), 1);
1471 assert_eq!(errors[0].0, "failing");
1472 assert_eq!(good_sink.events_received(), 1);
1473 }
1474
1475 #[tokio::test]
1476 async fn flush_all_flushes_every_sink() {
1477 let sink1 = Arc::new(CountingSink::new());
1478 let sink2 = Arc::new(CountingSink::new());
1479
1480 let mut registry = EventRegistry::new();
1481 registry.register(sink1.clone());
1482 registry.register(sink2.clone());
1483
1484 let errors = registry.flush_all().await;
1485 assert!(errors.is_empty());
1486 assert_eq!(sink1.flushes(), 1);
1487 assert_eq!(sink2.flushes(), 1);
1488 }
1489
1490 #[tokio::test]
1491 async fn emitted_events_are_recorded_and_queryable() {
1492 let registry = EventRegistry::new();
1493 registry.emit_event(sample_event()).await;
1494 registry.emit_event(plugin_event()).await;
1495
1496 let recorded = registry.events();
1497 assert_eq!(recorded.len(), 2);
1498
1499 let workflow_events = registry.events_for_workflow("workflow-123");
1500 assert_eq!(workflow_events.len(), 1);
1501 assert_eq!(workflow_events[0].category(), EventCategory::Profiling);
1502
1503 let summaries = registry.recent_summaries(2);
1504 assert_eq!(summaries.len(), 2);
1505 assert_eq!(summaries[0].title, "Profiling completed");
1506 assert_eq!(summaries[1].title, "Plugin loaded");
1507 }
1508
1509 #[test]
1510 fn materialization_failure_has_error_severity_and_summary() {
1511 let event = RazeriEvent::materialization_failed(
1512 "event-materialization-failed",
1513 sample_timestamp(),
1514 sample_workflow(),
1515 "sales_design",
1516 "duckdb",
1517 "dialect mismatch",
1518 )
1519 .expect("materialization failure event should be valid");
1520
1521 assert_eq!(event.category(), EventCategory::Materialization);
1522 assert_eq!(event.severity(), EventSeverity::Error);
1523 assert!(event.summary().detail.contains("dialect mismatch"));
1524 }
1525
1526 #[test]
1527 fn event_serializes_to_json() {
1528 let event = sample_event();
1529 let json = serde_json::to_string(&event);
1530 assert!(json.is_ok());
1531 let json_str = json.unwrap();
1532 assert!(json_str.contains("profiling_completed"));
1533 assert!(json_str.contains("test.csv"));
1534 assert!(json_str.contains("workflow-123"));
1535 }
1536
1537 #[test]
1538 fn workflow_scoped_event_requires_workflow_context() {
1539 let event = RazeriEvent::new(
1540 "event-missing-workflow",
1541 sample_timestamp(),
1542 EventSubject::source("missing.csv"),
1543 EventPayload::ProfilingStarted { source: "missing.csv".to_string() },
1544 );
1545
1546 assert_eq!(
1547 event,
1548 Err(EventValidationError::WorkflowContextRequired {
1549 payload: "profiling_started".to_string(),
1550 })
1551 );
1552 }
1553
1554 #[test]
1555 fn event_round_trips_through_json_without_losing_meaning() {
1556 let event = sample_event();
1557 let json = serde_json::to_string(&event).expect("event should serialize");
1558 let round_trip: RazeriEvent =
1559 serde_json::from_str(&json).expect("event should deserialize");
1560
1561 assert_eq!(event, round_trip);
1562 assert_eq!(round_trip.validate(), Ok(()));
1563 }
1564
1565 #[test]
1566 fn summaries_and_filter_keys_are_derived_from_canonical_event() {
1567 let event = sample_event();
1568
1569 let keys = event.filter_keys();
1570 assert_eq!(keys.category, EventCategory::Profiling);
1571 assert_eq!(keys.severity, EventSeverity::Info);
1572 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1573 assert_eq!(keys.subject_kind, EventSubjectKind::Source);
1574 assert_eq!(keys.subject_name, "test.csv");
1575 assert_eq!(keys.actor.as_deref(), Some("analyst@example.com"));
1576 assert_eq!(keys.surface.as_deref(), Some("web"));
1577
1578 let summary = event.summary();
1579 assert_eq!(summary.title, "Profiling completed");
1580 assert!(summary.detail.contains("10 fields"));
1581 assert!(summary.detail.contains("1000 rows"));
1582 }
1583
1584 #[test]
1585 fn non_workflow_scoped_events_can_be_created_without_workflow_context() {
1586 let event = plugin_event();
1587
1588 assert_eq!(event.category(), EventCategory::Plugin);
1589 assert_eq!(event.severity(), EventSeverity::Info);
1590 assert!(event.workflow().is_none());
1591 }
1592
1593 #[test]
1594 fn profiling_started_constructs_with_source_subject() {
1595 let event = RazeriEvent::profiling_started(
1596 "ps-1",
1597 sample_timestamp(),
1598 sample_workflow(),
1599 "customers.csv",
1600 )
1601 .unwrap();
1602 assert_eq!(event.category(), EventCategory::Profiling);
1603 assert_eq!(event.subject().kind, EventSubjectKind::Source);
1604 assert_eq!(event.summary().title, "Profiling started");
1605 }
1606
1607 #[test]
1608 fn profiling_completed_constructs_with_stats() {
1609 let event = RazeriEvent::profiling_completed(
1610 "pc-1",
1611 sample_timestamp(),
1612 sample_workflow(),
1613 "customers.csv",
1614 10,
1615 5000,
1616 123,
1617 )
1618 .unwrap();
1619 assert_eq!(event.category(), EventCategory::Profiling);
1620 assert!(event.summary().detail.contains("10 fields"));
1621 assert!(event.summary().detail.contains("5000 rows"));
1622 }
1623
1624 #[test]
1625 fn design_loaded_constructs_without_workflow() {
1626 let event =
1627 RazeriEvent::design_loaded("dl-1", sample_timestamp(), "designs/retail.yaml", 3)
1628 .unwrap();
1629 assert_eq!(event.category(), EventCategory::Design);
1630 assert!(event.workflow().is_none());
1631 assert!(event.summary().detail.contains("3 entities"));
1632 }
1633
1634 #[test]
1635 fn plugin_loaded_constructs_without_workflow() {
1636 let event = RazeriEvent::plugin_loaded(
1637 "pl-1",
1638 sample_timestamp(),
1639 "PostgresDialect",
1640 "dialect",
1641 "inventory",
1642 )
1643 .unwrap();
1644 assert_eq!(event.category(), EventCategory::Plugin);
1645 assert_eq!(event.subject().kind, EventSubjectKind::Plugin);
1646 assert!(event.summary().detail.contains("dialect"));
1647 }
1648
1649 #[test]
1650 fn storage_operation_completed_constructs_with_workflow() {
1651 let event = RazeriEvent::storage_operation_completed(
1652 "so-1",
1653 sample_timestamp(),
1654 sample_workflow(),
1655 "move",
1656 "/data/file.csv",
1657 1024,
1658 50,
1659 )
1660 .unwrap();
1661 assert_eq!(event.category(), EventCategory::Storage);
1662 assert_eq!(event.subject().kind, EventSubjectKind::StoragePath);
1663 }
1664
1665 #[test]
1666 fn llm_request_completed_constructs_with_workflow() {
1667 let event = RazeriEvent::llm_request_completed(
1668 "lr-1",
1669 sample_timestamp(),
1670 sample_workflow(),
1671 "ollama",
1672 "llama3",
1673 500,
1674 3000,
1675 )
1676 .unwrap();
1677 assert_eq!(event.category(), EventCategory::Ai);
1678 assert_eq!(event.subject().kind, EventSubjectKind::LlmModel);
1679 assert!(event.summary().detail.contains("500 tokens"));
1680 }
1681
1682 #[test]
1683 fn query_returns_all_events_when_empty() {
1684 let registry = EventRegistry::new();
1685 registry.record(sample_event());
1686 registry.record(plugin_event());
1687
1688 let results = registry.query(&EventQuery::default());
1689 assert_eq!(results.len(), 2);
1690 }
1691
1692 #[test]
1693 fn query_filters_by_category() {
1694 let registry = EventRegistry::new();
1695 registry.record(sample_event());
1696 registry.record(plugin_event());
1697
1698 let results = registry
1699 .query(&EventQuery { category: Some(EventCategory::Plugin), ..Default::default() });
1700 assert_eq!(results.len(), 1);
1701 assert_eq!(results[0].category(), EventCategory::Plugin);
1702 }
1703
1704 #[test]
1705 fn query_filters_by_min_severity() {
1706 let registry = EventRegistry::new();
1707 registry.record(sample_event()); let error_event = RazeriEvent::materialization_failed(
1709 "err-1",
1710 sample_timestamp(),
1711 sample_workflow(),
1712 "design-a",
1713 "PostgreSQL",
1714 "connection error",
1715 )
1716 .unwrap();
1717 registry.record(error_event);
1718
1719 let results = registry
1720 .query(&EventQuery { min_severity: Some(EventSeverity::Error), ..Default::default() });
1721 assert_eq!(results.len(), 1);
1722 assert_eq!(results[0].severity(), EventSeverity::Error);
1723 }
1724
1725 #[test]
1726 fn query_filters_by_workflow_id() {
1727 let registry = EventRegistry::new();
1728 registry.record(sample_event()); registry.record(plugin_event()); let results = registry.query(&EventQuery {
1732 workflow_id: Some("workflow-123".to_string()),
1733 ..Default::default()
1734 });
1735 assert_eq!(results.len(), 1);
1736 }
1737
1738 #[test]
1739 fn query_respects_limit() {
1740 let registry = EventRegistry::new();
1741 registry.record(sample_event());
1742 registry.record(plugin_event());
1743
1744 let results = registry.query(&EventQuery { limit: Some(1), ..Default::default() });
1745 assert_eq!(results.len(), 1);
1746 }
1747
1748 #[test]
1751 fn snapshot_profiling_completed_json_shape() {
1752 let event = sample_event();
1753 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1754 let obj = val.as_object().unwrap();
1755
1756 assert!(obj.contains_key("event_id"));
1758 assert!(obj.contains_key("timestamp"));
1759 assert!(obj.contains_key("category"));
1760 assert!(obj.contains_key("severity"));
1761 assert!(obj.contains_key("workflow"));
1762 assert!(obj.contains_key("subject"));
1763 assert!(obj.contains_key("payload"));
1764
1765 let payload = obj["payload"].as_object().unwrap();
1767 assert_eq!(payload["kind"], "profiling_completed");
1768 assert!(payload.contains_key("source"));
1769 assert!(payload.contains_key("fields"));
1770 assert!(payload.contains_key("rows"));
1771 assert!(payload.contains_key("duration_ms"));
1772
1773 let subject = obj["subject"].as_object().unwrap();
1775 assert_eq!(subject["kind"], "source");
1776 assert!(subject.contains_key("name"));
1777 }
1778
1779 #[test]
1780 fn snapshot_event_round_trip_preserves_identity() {
1781 let event = sample_event();
1782 let json = serde_json::to_string(&event).unwrap();
1783 let roundtrip: RazeriEvent = serde_json::from_str(&json).unwrap();
1784 assert_eq!(roundtrip.event_id(), event.event_id());
1785 assert_eq!(roundtrip.category(), event.category());
1786 assert_eq!(roundtrip.severity(), event.severity());
1787 assert_eq!(roundtrip.subject().name, event.subject().name);
1788 }
1789
1790 #[test]
1791 fn snapshot_materialization_failed_json_shape() {
1792 let event = RazeriEvent::materialization_failed(
1793 "snap-mat-fail",
1794 sample_timestamp(),
1795 sample_workflow(),
1796 "sales_design",
1797 "duckdb",
1798 "type mismatch",
1799 )
1800 .unwrap();
1801 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1802 let payload = val["payload"].as_object().unwrap();
1803 assert_eq!(payload["kind"], "materialization_failed");
1804 assert_eq!(payload["design"], "sales_design");
1805 assert_eq!(payload["platform"], "duckdb");
1806 assert_eq!(payload["message"], "type mismatch");
1807 }
1808
1809 #[test]
1810 fn snapshot_plugin_loaded_json_shape() {
1811 let event = plugin_event();
1812 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1813 let payload = val["payload"].as_object().unwrap();
1814 assert_eq!(payload["kind"], "plugin_loaded");
1815 assert!(payload.contains_key("name"));
1816 assert!(payload.contains_key("plugin_type"));
1817 assert!(payload.contains_key("source"));
1818 }
1819
1820 #[test]
1821 fn snapshot_event_summary_format_is_stable() {
1822 let event = sample_event();
1823 let summary = event.summary();
1824 assert_eq!(summary.title, "Profiling completed");
1825 assert!(summary.detail.contains("test.csv"));
1826 assert!(summary.detail.contains("10"));
1827 assert!(summary.detail.contains("100"));
1828 }
1829
1830 #[test]
1831 fn snapshot_filter_keys_are_stable() {
1832 let event = sample_event();
1833 let keys = event.filter_keys();
1834 assert_eq!(keys.category, EventCategory::Profiling);
1835 assert_eq!(keys.severity, EventSeverity::Info);
1836 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1837 assert_eq!(keys.subject_name, "test.csv");
1838 }
1839}