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 {
1117 sinks: Vec<Arc<dyn EventSink>>,
1118 history: RwLock<Vec<RazeriEvent>>,
1119}
1120
1121impl EventRegistry {
1122 #[must_use]
1124 pub fn new() -> Self {
1125 Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1126 }
1127
1128 pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1130 self.sinks.push(sink);
1131 }
1132
1133 pub async fn emit(&self, event: &RazeriEvent) -> Vec<(String, EventSinkError)> {
1138 self.record(event.clone());
1139 let mut errors = Vec::new();
1140 for sink in &self.sinks {
1141 if let Err(e) = sink.on_event(event).await {
1142 errors.push((sink.name().to_string(), e));
1143 }
1144 }
1145 errors
1146 }
1147
1148 pub fn record(&self, event: RazeriEvent) {
1150 self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1151 }
1152
1153 pub fn events(&self) -> Vec<RazeriEvent> {
1155 self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1156 }
1157
1158 pub fn recent_events(&self, limit: usize) -> Vec<RazeriEvent> {
1160 let events = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1161 let start = events.len().saturating_sub(limit);
1162 events[start..].to_vec()
1163 }
1164
1165 pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<RazeriEvent> {
1167 self.history
1168 .read()
1169 .unwrap_or_else(std::sync::PoisonError::into_inner)
1170 .iter()
1171 .filter(|event| {
1172 event.workflow().is_some_and(|workflow| workflow.workflow_id == workflow_id)
1173 })
1174 .cloned()
1175 .collect()
1176 }
1177
1178 pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1180 self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1181 }
1182
1183 pub async fn flush_all(&self) -> Vec<(String, EventSinkError)> {
1185 let mut errors = Vec::new();
1186 for sink in &self.sinks {
1187 if let Err(e) = sink.flush().await {
1188 errors.push((sink.name().to_string(), e));
1189 }
1190 }
1191 errors
1192 }
1193
1194 #[must_use]
1197 pub fn sink_names(&self) -> Vec<String> {
1198 self.sinks.iter().map(|sink| sink.name().to_owned()).collect()
1199 }
1200
1201 pub fn len(&self) -> usize {
1203 self.sinks.len()
1204 }
1205
1206 pub fn is_empty(&self) -> bool {
1208 self.sinks.is_empty()
1209 }
1210
1211 pub fn query(&self, q: &EventQuery) -> Vec<RazeriEvent> {
1213 let history = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1214 let iter = history.iter().filter(|e| {
1215 q.category.is_none_or(|c| e.category() == c)
1216 && q.min_severity.is_none_or(|s| e.severity() >= s)
1217 && q.workflow_id
1218 .as_ref()
1219 .is_none_or(|wf| e.workflow().is_some_and(|w| w.workflow_id == *wf))
1220 && q.subject_name.as_ref().is_none_or(|n| e.subject().name == *n)
1221 && q.subject_kind.is_none_or(|k| e.subject().kind == k)
1222 && q.since.is_none_or(|t| e.timestamp() >= t)
1223 });
1224 match q.limit {
1225 Some(n) => iter.take(n).cloned().collect(),
1226 None => iter.cloned().collect(),
1227 }
1228 }
1229}
1230
1231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1236pub struct EventQuery {
1237 pub category: Option<EventCategory>,
1239 pub min_severity: Option<EventSeverity>,
1241 pub workflow_id: Option<String>,
1243 pub subject_name: Option<String>,
1245 pub subject_kind: Option<EventSubjectKind>,
1247 pub since: Option<DateTime<Utc>>,
1249 pub limit: Option<usize>,
1251}
1252
1253impl Default for EventRegistry {
1254 fn default() -> Self {
1255 Self::new()
1256 }
1257}
1258
1259#[async_trait]
1260impl EventEmitter for EventRegistry {
1261 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)> {
1262 self.emit(&event).await
1263 }
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268 use super::*;
1269 use std::sync::atomic::{AtomicUsize, Ordering};
1270
1271 #[test]
1274 fn a_count_of_one_says_its_noun_once() {
1275 let one = [
1276 EventPayload::ProfilingCompleted {
1277 source: "s".into(),
1278 fields: 1,
1279 rows: 1,
1280 duration_ms: 1,
1281 },
1282 EventPayload::MaterializationCompleted {
1283 entity: "e".into(),
1284 platform: "p".into(),
1285 artifacts: 1,
1286 duration_ms: 1,
1287 },
1288 EventPayload::DesignValidated { design: "d".into(), errors: 1, warnings: 1 },
1289 EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 1 },
1290 EventPayload::StorageOperationCompleted {
1291 operation: "put".into(),
1292 path: "p".into(),
1293 bytes: 1,
1294 duration_ms: 1,
1295 },
1296 EventPayload::LlmRequestCompleted {
1297 provider: "p".into(),
1298 model: "m".into(),
1299 tokens: 1,
1300 duration_ms: 1,
1301 },
1302 ];
1303 for payload in one {
1304 let detail = payload.summary().detail;
1305 for plural in
1306 ["1 fields", "1 rows", "1 artifacts", "1 errors", "1 warnings", "1 entities"]
1307 {
1308 assert!(!detail.contains(plural), "{detail}");
1309 }
1310 assert!(!detail.contains("1 bytes") && !detail.contains("1 tokens"), "{detail}");
1311 }
1312 let two = EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 2 };
1313 assert!(two.summary().detail.contains("2 entities"), "{}", two.summary().detail);
1314 }
1315
1316 #[test]
1320 fn a_file_set_aside_warns_and_a_file_not_moved_is_an_error() {
1321 let aside = EventPayload::FileSetAside {
1322 path: "in/x.csv".into(),
1323 destination: "quarantine/x.csv".into(),
1324 reason: "no rule routes it".into(),
1325 };
1326 let stuck = EventPayload::FileNotMoved { path: "in/y.csv".into(), reason: "denied".into() };
1327 let routed = EventPayload::FileRouted {
1328 path: "in/z.csv".into(),
1329 destination: "raw/z.csv".into(),
1330 rule: "customers".into(),
1331 bytes: 1,
1332 };
1333 assert_eq!(aside.severity(), EventSeverity::Warning);
1334 assert_eq!(stuck.severity(), EventSeverity::Error);
1335 assert_eq!(routed.severity(), EventSeverity::Info);
1336 assert_eq!(aside.category(), EventCategory::Storage);
1337 assert_eq!(aside.name(), "file_set_aside");
1338 assert!(routed.summary().detail.ends_with("1 byte"), "{}", routed.summary().detail);
1339 }
1340
1341 fn sample_timestamp() -> DateTime<Utc> {
1342 DateTime::parse_from_rfc3339("2026-03-13T12:34:56Z")
1343 .expect("valid timestamp fixture")
1344 .with_timezone(&Utc)
1345 }
1346
1347 fn sample_workflow() -> WorkflowContext {
1348 WorkflowContext::new("workflow-123")
1349 .expect("workflow id should be valid")
1350 .with_request_id("request-456")
1351 .with_actor("analyst@example.com")
1352 .with_surface("web")
1353 }
1354
1355 struct CountingSink {
1356 event_count: AtomicUsize,
1357 flush_count: AtomicUsize,
1358 }
1359
1360 impl CountingSink {
1361 fn new() -> Self {
1362 Self { event_count: AtomicUsize::new(0), flush_count: AtomicUsize::new(0) }
1363 }
1364
1365 fn events_received(&self) -> usize {
1366 self.event_count.load(Ordering::SeqCst)
1367 }
1368
1369 fn flushes(&self) -> usize {
1370 self.flush_count.load(Ordering::SeqCst)
1371 }
1372 }
1373
1374 #[async_trait]
1375 impl EventSink for CountingSink {
1376 fn name(&self) -> &'static str {
1377 "counting"
1378 }
1379
1380 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1381 self.event_count.fetch_add(1, Ordering::SeqCst);
1382 Ok(())
1383 }
1384
1385 async fn flush(&self) -> Result<(), EventSinkError> {
1386 self.flush_count.fetch_add(1, Ordering::SeqCst);
1387 Ok(())
1388 }
1389 }
1390
1391 struct FailingSink;
1392
1393 #[async_trait]
1394 impl EventSink for FailingSink {
1395 fn name(&self) -> &'static str {
1396 "failing"
1397 }
1398
1399 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1400 Err(EventSinkError::SinkError("intentional failure".to_string()))
1401 }
1402
1403 async fn flush(&self) -> Result<(), EventSinkError> {
1404 Err(EventSinkError::SinkError("intentional failure".to_string()))
1405 }
1406 }
1407
1408 fn sample_event() -> RazeriEvent {
1409 RazeriEvent::new_with_workflow(
1410 "event-123",
1411 sample_timestamp(),
1412 sample_workflow(),
1413 EventSubject::source("test.csv"),
1414 EventPayload::ProfilingCompleted {
1415 source: "test.csv".to_string(),
1416 fields: 10,
1417 rows: 1000,
1418 duration_ms: 42,
1419 },
1420 )
1421 .expect("workflow-scoped sample event should be valid")
1422 }
1423
1424 fn plugin_event() -> RazeriEvent {
1425 RazeriEvent::new(
1426 "event-plugin-1",
1427 sample_timestamp(),
1428 EventSubject::plugin("builtin-logger"),
1429 EventPayload::PluginLoaded {
1430 name: "builtin-logger".to_string(),
1431 plugin_type: "event_sink".to_string(),
1432 source: "builtin".to_string(),
1433 },
1434 )
1435 .expect("plugin events are not workflow scoped")
1436 }
1437
1438 #[tokio::test]
1439 async fn event_dispatched_to_all_sinks() {
1440 let sink1 = Arc::new(CountingSink::new());
1441 let sink2 = Arc::new(CountingSink::new());
1442
1443 let mut registry = EventRegistry::new();
1444 registry.register(sink1.clone());
1445 registry.register(sink2.clone());
1446
1447 let errors = registry.emit(&sample_event()).await;
1448 assert!(errors.is_empty());
1449 assert_eq!(sink1.events_received(), 1);
1450 assert_eq!(sink2.events_received(), 1);
1451 }
1452
1453 #[tokio::test]
1454 async fn sink_failure_does_not_halt_dispatch() {
1455 let good_sink = Arc::new(CountingSink::new());
1456 let bad_sink: Arc<dyn EventSink> = Arc::new(FailingSink);
1457
1458 let mut registry = EventRegistry::new();
1459 registry.register(bad_sink);
1460 registry.register(good_sink.clone());
1461
1462 let errors = registry.emit(&sample_event()).await;
1463 assert_eq!(errors.len(), 1);
1465 assert_eq!(errors[0].0, "failing");
1466 assert_eq!(good_sink.events_received(), 1);
1467 }
1468
1469 #[tokio::test]
1470 async fn flush_all_flushes_every_sink() {
1471 let sink1 = Arc::new(CountingSink::new());
1472 let sink2 = Arc::new(CountingSink::new());
1473
1474 let mut registry = EventRegistry::new();
1475 registry.register(sink1.clone());
1476 registry.register(sink2.clone());
1477
1478 let errors = registry.flush_all().await;
1479 assert!(errors.is_empty());
1480 assert_eq!(sink1.flushes(), 1);
1481 assert_eq!(sink2.flushes(), 1);
1482 }
1483
1484 #[tokio::test]
1485 async fn emitted_events_are_recorded_and_queryable() {
1486 let registry = EventRegistry::new();
1487 registry.emit_event(sample_event()).await;
1488 registry.emit_event(plugin_event()).await;
1489
1490 let recorded = registry.events();
1491 assert_eq!(recorded.len(), 2);
1492
1493 let workflow_events = registry.events_for_workflow("workflow-123");
1494 assert_eq!(workflow_events.len(), 1);
1495 assert_eq!(workflow_events[0].category(), EventCategory::Profiling);
1496
1497 let summaries = registry.recent_summaries(2);
1498 assert_eq!(summaries.len(), 2);
1499 assert_eq!(summaries[0].title, "Profiling completed");
1500 assert_eq!(summaries[1].title, "Plugin loaded");
1501 }
1502
1503 #[test]
1504 fn materialization_failure_has_error_severity_and_summary() {
1505 let event = RazeriEvent::materialization_failed(
1506 "event-materialization-failed",
1507 sample_timestamp(),
1508 sample_workflow(),
1509 "sales_design",
1510 "duckdb",
1511 "dialect mismatch",
1512 )
1513 .expect("materialization failure event should be valid");
1514
1515 assert_eq!(event.category(), EventCategory::Materialization);
1516 assert_eq!(event.severity(), EventSeverity::Error);
1517 assert!(event.summary().detail.contains("dialect mismatch"));
1518 }
1519
1520 #[test]
1521 fn event_serializes_to_json() {
1522 let event = sample_event();
1523 let json = serde_json::to_string(&event);
1524 assert!(json.is_ok());
1525 let json_str = json.unwrap();
1526 assert!(json_str.contains("profiling_completed"));
1527 assert!(json_str.contains("test.csv"));
1528 assert!(json_str.contains("workflow-123"));
1529 }
1530
1531 #[test]
1532 fn workflow_scoped_event_requires_workflow_context() {
1533 let event = RazeriEvent::new(
1534 "event-missing-workflow",
1535 sample_timestamp(),
1536 EventSubject::source("missing.csv"),
1537 EventPayload::ProfilingStarted { source: "missing.csv".to_string() },
1538 );
1539
1540 assert_eq!(
1541 event,
1542 Err(EventValidationError::WorkflowContextRequired {
1543 payload: "profiling_started".to_string(),
1544 })
1545 );
1546 }
1547
1548 #[test]
1549 fn event_round_trips_through_json_without_losing_meaning() {
1550 let event = sample_event();
1551 let json = serde_json::to_string(&event).expect("event should serialize");
1552 let round_trip: RazeriEvent =
1553 serde_json::from_str(&json).expect("event should deserialize");
1554
1555 assert_eq!(event, round_trip);
1556 assert_eq!(round_trip.validate(), Ok(()));
1557 }
1558
1559 #[test]
1560 fn summaries_and_filter_keys_are_derived_from_canonical_event() {
1561 let event = sample_event();
1562
1563 let keys = event.filter_keys();
1564 assert_eq!(keys.category, EventCategory::Profiling);
1565 assert_eq!(keys.severity, EventSeverity::Info);
1566 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1567 assert_eq!(keys.subject_kind, EventSubjectKind::Source);
1568 assert_eq!(keys.subject_name, "test.csv");
1569 assert_eq!(keys.actor.as_deref(), Some("analyst@example.com"));
1570 assert_eq!(keys.surface.as_deref(), Some("web"));
1571
1572 let summary = event.summary();
1573 assert_eq!(summary.title, "Profiling completed");
1574 assert!(summary.detail.contains("10 fields"));
1575 assert!(summary.detail.contains("1000 rows"));
1576 }
1577
1578 #[test]
1579 fn non_workflow_scoped_events_can_be_created_without_workflow_context() {
1580 let event = plugin_event();
1581
1582 assert_eq!(event.category(), EventCategory::Plugin);
1583 assert_eq!(event.severity(), EventSeverity::Info);
1584 assert!(event.workflow().is_none());
1585 }
1586
1587 #[test]
1588 fn profiling_started_constructs_with_source_subject() {
1589 let event = RazeriEvent::profiling_started(
1590 "ps-1",
1591 sample_timestamp(),
1592 sample_workflow(),
1593 "customers.csv",
1594 )
1595 .unwrap();
1596 assert_eq!(event.category(), EventCategory::Profiling);
1597 assert_eq!(event.subject().kind, EventSubjectKind::Source);
1598 assert_eq!(event.summary().title, "Profiling started");
1599 }
1600
1601 #[test]
1602 fn profiling_completed_constructs_with_stats() {
1603 let event = RazeriEvent::profiling_completed(
1604 "pc-1",
1605 sample_timestamp(),
1606 sample_workflow(),
1607 "customers.csv",
1608 10,
1609 5000,
1610 123,
1611 )
1612 .unwrap();
1613 assert_eq!(event.category(), EventCategory::Profiling);
1614 assert!(event.summary().detail.contains("10 fields"));
1615 assert!(event.summary().detail.contains("5000 rows"));
1616 }
1617
1618 #[test]
1619 fn design_loaded_constructs_without_workflow() {
1620 let event =
1621 RazeriEvent::design_loaded("dl-1", sample_timestamp(), "designs/retail.yaml", 3)
1622 .unwrap();
1623 assert_eq!(event.category(), EventCategory::Design);
1624 assert!(event.workflow().is_none());
1625 assert!(event.summary().detail.contains("3 entities"));
1626 }
1627
1628 #[test]
1629 fn plugin_loaded_constructs_without_workflow() {
1630 let event = RazeriEvent::plugin_loaded(
1631 "pl-1",
1632 sample_timestamp(),
1633 "PostgresDialect",
1634 "dialect",
1635 "inventory",
1636 )
1637 .unwrap();
1638 assert_eq!(event.category(), EventCategory::Plugin);
1639 assert_eq!(event.subject().kind, EventSubjectKind::Plugin);
1640 assert!(event.summary().detail.contains("dialect"));
1641 }
1642
1643 #[test]
1644 fn storage_operation_completed_constructs_with_workflow() {
1645 let event = RazeriEvent::storage_operation_completed(
1646 "so-1",
1647 sample_timestamp(),
1648 sample_workflow(),
1649 "move",
1650 "/data/file.csv",
1651 1024,
1652 50,
1653 )
1654 .unwrap();
1655 assert_eq!(event.category(), EventCategory::Storage);
1656 assert_eq!(event.subject().kind, EventSubjectKind::StoragePath);
1657 }
1658
1659 #[test]
1660 fn llm_request_completed_constructs_with_workflow() {
1661 let event = RazeriEvent::llm_request_completed(
1662 "lr-1",
1663 sample_timestamp(),
1664 sample_workflow(),
1665 "ollama",
1666 "llama3",
1667 500,
1668 3000,
1669 )
1670 .unwrap();
1671 assert_eq!(event.category(), EventCategory::Ai);
1672 assert_eq!(event.subject().kind, EventSubjectKind::LlmModel);
1673 assert!(event.summary().detail.contains("500 tokens"));
1674 }
1675
1676 #[test]
1677 fn query_returns_all_events_when_empty() {
1678 let registry = EventRegistry::new();
1679 registry.record(sample_event());
1680 registry.record(plugin_event());
1681
1682 let results = registry.query(&EventQuery::default());
1683 assert_eq!(results.len(), 2);
1684 }
1685
1686 #[test]
1687 fn query_filters_by_category() {
1688 let registry = EventRegistry::new();
1689 registry.record(sample_event());
1690 registry.record(plugin_event());
1691
1692 let results = registry
1693 .query(&EventQuery { category: Some(EventCategory::Plugin), ..Default::default() });
1694 assert_eq!(results.len(), 1);
1695 assert_eq!(results[0].category(), EventCategory::Plugin);
1696 }
1697
1698 #[test]
1699 fn query_filters_by_min_severity() {
1700 let registry = EventRegistry::new();
1701 registry.record(sample_event()); let error_event = RazeriEvent::materialization_failed(
1703 "err-1",
1704 sample_timestamp(),
1705 sample_workflow(),
1706 "design-a",
1707 "PostgreSQL",
1708 "connection error",
1709 )
1710 .unwrap();
1711 registry.record(error_event);
1712
1713 let results = registry
1714 .query(&EventQuery { min_severity: Some(EventSeverity::Error), ..Default::default() });
1715 assert_eq!(results.len(), 1);
1716 assert_eq!(results[0].severity(), EventSeverity::Error);
1717 }
1718
1719 #[test]
1720 fn query_filters_by_workflow_id() {
1721 let registry = EventRegistry::new();
1722 registry.record(sample_event()); registry.record(plugin_event()); let results = registry.query(&EventQuery {
1726 workflow_id: Some("workflow-123".to_string()),
1727 ..Default::default()
1728 });
1729 assert_eq!(results.len(), 1);
1730 }
1731
1732 #[test]
1733 fn query_respects_limit() {
1734 let registry = EventRegistry::new();
1735 registry.record(sample_event());
1736 registry.record(plugin_event());
1737
1738 let results = registry.query(&EventQuery { limit: Some(1), ..Default::default() });
1739 assert_eq!(results.len(), 1);
1740 }
1741
1742 #[test]
1745 fn snapshot_profiling_completed_json_shape() {
1746 let event = sample_event();
1747 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1748 let obj = val.as_object().unwrap();
1749
1750 assert!(obj.contains_key("event_id"));
1752 assert!(obj.contains_key("timestamp"));
1753 assert!(obj.contains_key("category"));
1754 assert!(obj.contains_key("severity"));
1755 assert!(obj.contains_key("workflow"));
1756 assert!(obj.contains_key("subject"));
1757 assert!(obj.contains_key("payload"));
1758
1759 let payload = obj["payload"].as_object().unwrap();
1761 assert_eq!(payload["kind"], "profiling_completed");
1762 assert!(payload.contains_key("source"));
1763 assert!(payload.contains_key("fields"));
1764 assert!(payload.contains_key("rows"));
1765 assert!(payload.contains_key("duration_ms"));
1766
1767 let subject = obj["subject"].as_object().unwrap();
1769 assert_eq!(subject["kind"], "source");
1770 assert!(subject.contains_key("name"));
1771 }
1772
1773 #[test]
1774 fn snapshot_event_round_trip_preserves_identity() {
1775 let event = sample_event();
1776 let json = serde_json::to_string(&event).unwrap();
1777 let roundtrip: RazeriEvent = serde_json::from_str(&json).unwrap();
1778 assert_eq!(roundtrip.event_id(), event.event_id());
1779 assert_eq!(roundtrip.category(), event.category());
1780 assert_eq!(roundtrip.severity(), event.severity());
1781 assert_eq!(roundtrip.subject().name, event.subject().name);
1782 }
1783
1784 #[test]
1785 fn snapshot_materialization_failed_json_shape() {
1786 let event = RazeriEvent::materialization_failed(
1787 "snap-mat-fail",
1788 sample_timestamp(),
1789 sample_workflow(),
1790 "sales_design",
1791 "duckdb",
1792 "type mismatch",
1793 )
1794 .unwrap();
1795 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1796 let payload = val["payload"].as_object().unwrap();
1797 assert_eq!(payload["kind"], "materialization_failed");
1798 assert_eq!(payload["design"], "sales_design");
1799 assert_eq!(payload["platform"], "duckdb");
1800 assert_eq!(payload["message"], "type mismatch");
1801 }
1802
1803 #[test]
1804 fn snapshot_plugin_loaded_json_shape() {
1805 let event = plugin_event();
1806 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1807 let payload = val["payload"].as_object().unwrap();
1808 assert_eq!(payload["kind"], "plugin_loaded");
1809 assert!(payload.contains_key("name"));
1810 assert!(payload.contains_key("plugin_type"));
1811 assert!(payload.contains_key("source"));
1812 }
1813
1814 #[test]
1815 fn snapshot_event_summary_format_is_stable() {
1816 let event = sample_event();
1817 let summary = event.summary();
1818 assert_eq!(summary.title, "Profiling completed");
1819 assert!(summary.detail.contains("test.csv"));
1820 assert!(summary.detail.contains("10"));
1821 assert!(summary.detail.contains("100"));
1822 }
1823
1824 #[test]
1825 fn snapshot_filter_keys_are_stable() {
1826 let event = sample_event();
1827 let keys = event.filter_keys();
1828 assert_eq!(keys.category, EventCategory::Profiling);
1829 assert_eq!(keys.severity, EventSeverity::Info);
1830 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1831 assert_eq!(keys.subject_name, "test.csv");
1832 }
1833}