1#![allow(clippy::doc_markdown)]
4
5use crate::error::EventSinkError;
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::sync::{Arc, RwLock};
17use thiserror::Error;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[non_exhaustive]
24pub struct RazeriEvent {
25 event_id: String,
26 timestamp: DateTime<Utc>,
27 category: EventCategory,
28 severity: EventSeverity,
29 workflow: Option<WorkflowContext>,
30 subject: EventSubject,
31 payload: EventPayload,
32}
33
34#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37#[non_exhaustive]
38pub enum EventCategory {
39 Profiling,
41 Materialization,
43 Design,
45 Plugin,
47 Storage,
49 Ai,
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum EventSeverity {
58 Info,
60 Warning,
62 Error,
64 Critical,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct WorkflowContext {
72 pub workflow_id: String,
74 pub request_id: Option<String>,
76 pub actor: Option<String>,
78 pub surface: Option<String>,
80}
81
82impl WorkflowContext {
83 pub fn new(workflow_id: impl Into<String>) -> Result<Self, EventValidationError> {
89 let workflow_id = workflow_id.into();
90 if workflow_id.trim().is_empty() {
91 return Err(EventValidationError::WorkflowIdRequired);
92 }
93
94 Ok(Self { workflow_id, request_id: None, actor: None, surface: None })
95 }
96
97 #[must_use]
99 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
100 self.request_id = Some(request_id.into());
101 self
102 }
103
104 #[must_use]
106 pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
107 self.actor = Some(actor.into());
108 self
109 }
110
111 #[must_use]
113 pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
114 self.surface = Some(surface.into());
115 self
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[non_exhaustive]
122pub struct EventSubject {
123 pub kind: EventSubjectKind,
125 pub name: String,
127}
128
129impl EventSubject {
130 pub fn new(kind: EventSubjectKind, name: impl Into<String>) -> Self {
132 Self { kind, name: name.into() }
133 }
134
135 pub fn source(name: impl Into<String>) -> Self {
137 Self::new(EventSubjectKind::Source, name)
138 }
139
140 pub fn entity(name: impl Into<String>) -> Self {
142 Self::new(EventSubjectKind::Entity, name)
143 }
144
145 pub fn design(name: impl Into<String>) -> Self {
147 Self::new(EventSubjectKind::Design, name)
148 }
149
150 pub fn plugin(name: impl Into<String>) -> Self {
152 Self::new(EventSubjectKind::Plugin, name)
153 }
154
155 pub fn storage(name: impl Into<String>) -> Self {
157 Self::new(EventSubjectKind::StoragePath, name)
158 }
159
160 pub fn llm_model(name: impl Into<String>) -> Self {
162 Self::new(EventSubjectKind::LlmModel, name)
163 }
164}
165
166#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum EventSubjectKind {
171 Source,
173 Entity,
175 Design,
177 Plugin,
179 StoragePath,
181 LlmModel,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(tag = "kind", rename_all = "snake_case")]
188#[non_exhaustive]
189pub enum EventPayload {
190 ProfilingStarted {
192 source: String,
194 },
195 ProfilingCompleted {
197 source: String,
199 fields: usize,
201 rows: usize,
203 duration_ms: u64,
205 },
206 MaterializationStarted {
208 entity: String,
210 platform: String,
212 },
213 MaterializationCompleted {
215 entity: String,
217 platform: String,
219 artifacts: usize,
221 duration_ms: u64,
223 },
224 MaterializationFailed {
226 design: String,
228 platform: String,
230 message: String,
232 },
233 DesignValidated {
235 design: String,
237 errors: usize,
239 warnings: usize,
241 },
242 DesignLoaded {
244 path: String,
246 entities: usize,
248 },
249 PluginLoaded {
251 name: String,
253 plugin_type: String,
255 source: String,
257 },
258 StorageOperationCompleted {
260 operation: String,
262 path: String,
264 bytes: u64,
266 duration_ms: u64,
268 },
269 StorageWarning {
272 entity: String,
274 feature: String,
276 platform: String,
278 recommendation: String,
280 },
281 LlmRequestCompleted {
283 provider: String,
285 model: String,
287 tokens: u64,
289 duration_ms: u64,
291 },
292}
293
294impl EventPayload {
295 #[must_use]
297 pub fn category(&self) -> EventCategory {
298 match self {
299 Self::ProfilingStarted { .. } | Self::ProfilingCompleted { .. } => {
300 EventCategory::Profiling
301 }
302 Self::MaterializationStarted { .. } | Self::MaterializationCompleted { .. } => {
303 EventCategory::Materialization
304 }
305 Self::MaterializationFailed { .. } => EventCategory::Materialization,
306 Self::DesignValidated { .. } | Self::DesignLoaded { .. } => EventCategory::Design,
307 Self::PluginLoaded { .. } => EventCategory::Plugin,
308 Self::StorageOperationCompleted { .. } | Self::StorageWarning { .. } => {
309 EventCategory::Storage
310 }
311 Self::LlmRequestCompleted { .. } => EventCategory::Ai,
312 }
313 }
314
315 #[must_use]
317 pub fn severity(&self) -> EventSeverity {
318 match self {
319 Self::MaterializationFailed { .. } => EventSeverity::Error,
320 Self::DesignValidated { errors, .. } if *errors > 0 => EventSeverity::Error,
321 Self::DesignValidated { warnings, .. } if *warnings > 0 => EventSeverity::Warning,
322 Self::StorageWarning { .. } => EventSeverity::Warning,
323 _ => EventSeverity::Info,
324 }
325 }
326
327 #[must_use]
329 pub fn requires_workflow_context(&self) -> bool {
330 match self {
331 Self::ProfilingStarted { .. }
332 | Self::ProfilingCompleted { .. }
333 | Self::MaterializationStarted { .. }
334 | Self::MaterializationCompleted { .. }
335 | Self::MaterializationFailed { .. }
336 | Self::DesignValidated { .. }
337 | Self::StorageOperationCompleted { .. }
338 | Self::LlmRequestCompleted { .. } => true,
339 Self::DesignLoaded { .. } | Self::PluginLoaded { .. } | Self::StorageWarning { .. } => {
340 false
341 }
342 }
343 }
344
345 pub(crate) fn name(&self) -> &'static str {
346 match self {
347 Self::ProfilingStarted { .. } => "profiling_started",
348 Self::ProfilingCompleted { .. } => "profiling_completed",
349 Self::MaterializationStarted { .. } => "materialization_started",
350 Self::MaterializationCompleted { .. } => "materialization_completed",
351 Self::MaterializationFailed { .. } => "materialization_failed",
352 Self::DesignValidated { .. } => "design_validated",
353 Self::DesignLoaded { .. } => "design_loaded",
354 Self::PluginLoaded { .. } => "plugin_loaded",
355 Self::StorageOperationCompleted { .. } => "storage_operation_completed",
356 Self::StorageWarning { .. } => "storage_warning",
357 Self::LlmRequestCompleted { .. } => "llm_request_completed",
358 }
359 }
360
361 fn summary(&self) -> EventSummary {
362 match self {
363 Self::ProfilingStarted { source } => profiling_started_summary(source),
364 Self::ProfilingCompleted { source, fields, rows, duration_ms } => {
365 profiling_completed_summary(source, *fields, *rows, *duration_ms)
366 }
367 Self::MaterializationStarted { entity, platform } => {
368 materialization_started_summary(entity, platform)
369 }
370 Self::MaterializationCompleted { entity, platform, artifacts, duration_ms } => {
371 materialization_completed_summary(entity, platform, *artifacts, *duration_ms)
372 }
373 Self::MaterializationFailed { design, platform, message } => {
374 materialization_failed_summary(design, platform, message)
375 }
376 Self::DesignValidated { design, errors, warnings } => {
377 design_validated_summary(design, *errors, *warnings)
378 }
379 Self::DesignLoaded { path, entities } => design_loaded_summary(path, *entities),
380 Self::PluginLoaded { name, plugin_type, source } => {
381 plugin_loaded_summary(name, plugin_type, source)
382 }
383 Self::StorageOperationCompleted { operation, path, bytes, duration_ms } => {
384 storage_operation_completed_summary(operation, path, *bytes, *duration_ms)
385 }
386 Self::StorageWarning { entity, feature, platform, recommendation } => {
387 storage_warning_summary(entity, feature, platform, recommendation)
388 }
389 Self::LlmRequestCompleted { provider, model, tokens, duration_ms } => {
390 llm_request_completed_summary(provider, model, *tokens, *duration_ms)
391 }
392 }
393 }
394}
395
396fn profiling_started_summary(source: &str) -> EventSummary {
400 EventSummary {
401 title: "Profiling started".to_string(),
402 detail: format!("Profiling started for source '{source}'"),
403 }
404}
405
406fn profiling_completed_summary(
413 source: &str,
414 fields: usize,
415 rows: usize,
416 duration_ms: u64,
417) -> EventSummary {
418 EventSummary {
419 title: "Profiling completed".to_string(),
420 detail: format!(
421 "Source '{source}' profiled with {fields} fields, {rows} rows in {duration_ms} ms"
422 ),
423 }
424}
425
426fn materialization_started_summary(entity: &str, platform: &str) -> EventSummary {
431 EventSummary {
432 title: "Materialization started".to_string(),
433 detail: format!("Entity '{entity}' started materialization for {platform}"),
434 }
435}
436
437fn materialization_completed_summary(
444 entity: &str,
445 platform: &str,
446 artifacts: usize,
447 duration_ms: u64,
448) -> EventSummary {
449 EventSummary {
450 title: "Materialization completed".to_string(),
451 detail: format!(
452 "Entity '{entity}' produced {artifacts} artifacts for {platform} in {duration_ms} ms"
453 ),
454 }
455}
456
457fn materialization_failed_summary(design: &str, platform: &str, message: &str) -> EventSummary {
463 EventSummary {
464 title: "Materialization failed".to_string(),
465 detail: format!("Design '{design}' failed materialization for {platform}: {message}"),
466 }
467}
468
469fn design_validated_summary(design: &str, errors: usize, warnings: usize) -> EventSummary {
475 EventSummary {
476 title: "Design validated".to_string(),
477 detail: format!("Design '{design}' validated with {errors} errors and {warnings} warnings"),
478 }
479}
480
481fn design_loaded_summary(path: &str, entities: usize) -> EventSummary {
486 EventSummary {
487 title: "Design loaded".to_string(),
488 detail: format!("Loaded design from '{path}' with {entities} entities"),
489 }
490}
491
492fn plugin_loaded_summary(name: &str, plugin_type: &str, source: &str) -> EventSummary {
498 EventSummary {
499 title: "Plugin loaded".to_string(),
500 detail: format!("Loaded {plugin_type} plugin '{name}' from {source}"),
501 }
502}
503
504fn storage_operation_completed_summary(
511 operation: &str,
512 path: &str,
513 bytes: u64,
514 duration_ms: u64,
515) -> EventSummary {
516 EventSummary {
517 title: "Storage operation completed".to_string(),
518 detail: format!(
519 "Operation '{operation}' processed {bytes} bytes at '{path}' in {duration_ms} ms"
520 ),
521 }
522}
523
524fn storage_warning_summary(
531 entity: &str,
532 feature: &str,
533 platform: &str,
534 recommendation: &str,
535) -> EventSummary {
536 EventSummary {
537 title: "Storage warning".to_string(),
538 detail: format!(
539 "Entity '{entity}': feature '{feature}' unavailable on '{platform}'; {recommendation}"
540 ),
541 }
542}
543
544fn llm_request_completed_summary(
551 provider: &str,
552 model: &str,
553 tokens: u64,
554 duration_ms: u64,
555) -> EventSummary {
556 EventSummary {
557 title: "LLM request completed".to_string(),
558 detail: format!(
559 "Provider '{provider}' model '{model}' used {tokens} tokens in {duration_ms} ms"
560 ),
561 }
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
566#[non_exhaustive]
567pub struct EventFilterKeys {
568 pub category: EventCategory,
570 pub severity: EventSeverity,
572 pub workflow_id: Option<String>,
574 pub subject_kind: EventSubjectKind,
576 pub subject_name: String,
578 pub actor: Option<String>,
580 pub surface: Option<String>,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[non_exhaustive]
587pub struct EventSummary {
588 pub title: String,
590 pub detail: String,
592}
593
594#[derive(Debug, Error, Clone, PartialEq, Eq)]
596#[non_exhaustive]
597pub enum EventValidationError {
598 #[error("workflow-scoped event payload '{payload}' requires workflow context")]
600 WorkflowContextRequired {
601 payload: String,
603 },
604 #[error("workflow context requires a non-empty workflow_id")]
606 WorkflowIdRequired,
607}
608
609impl RazeriEvent {
610 pub fn new(
616 event_id: impl Into<String>,
617 timestamp: DateTime<Utc>,
618 subject: EventSubject,
619 payload: EventPayload,
620 ) -> Result<Self, EventValidationError> {
621 Self::build(event_id.into(), timestamp, None, subject, payload)
622 }
623
624 pub fn new_with_workflow(
630 event_id: impl Into<String>,
631 timestamp: DateTime<Utc>,
632 workflow: WorkflowContext,
633 subject: EventSubject,
634 payload: EventPayload,
635 ) -> Result<Self, EventValidationError> {
636 Self::build(event_id.into(), timestamp, Some(workflow), subject, payload)
637 }
638
639 fn build(
640 event_id: String,
641 timestamp: DateTime<Utc>,
642 workflow: Option<WorkflowContext>,
643 subject: EventSubject,
644 payload: EventPayload,
645 ) -> Result<Self, EventValidationError> {
646 let event = Self {
647 event_id,
648 timestamp,
649 category: payload.category(),
650 severity: payload.severity(),
651 workflow,
652 subject,
653 payload,
654 };
655
656 event.validate()?;
657 Ok(event)
658 }
659
660 pub fn validate(&self) -> Result<(), EventValidationError> {
666 if self.payload.requires_workflow_context() && self.workflow.is_none() {
667 return Err(EventValidationError::WorkflowContextRequired {
668 payload: self.payload.name().to_string(),
669 });
670 }
671
672 if self.workflow.as_ref().is_some_and(|workflow| workflow.workflow_id.trim().is_empty()) {
673 return Err(EventValidationError::WorkflowIdRequired);
674 }
675
676 Ok(())
677 }
678
679 #[must_use]
681 pub fn event_id(&self) -> &str {
682 &self.event_id
683 }
684
685 #[must_use]
687 pub fn timestamp(&self) -> DateTime<Utc> {
688 self.timestamp
689 }
690
691 #[must_use]
693 pub fn category(&self) -> EventCategory {
694 self.category
695 }
696
697 #[must_use]
699 pub fn severity(&self) -> EventSeverity {
700 self.severity
701 }
702
703 #[must_use]
705 pub fn workflow(&self) -> Option<&WorkflowContext> {
706 self.workflow.as_ref()
707 }
708
709 #[must_use]
711 pub fn subject(&self) -> &EventSubject {
712 &self.subject
713 }
714
715 #[must_use]
717 pub fn payload(&self) -> &EventPayload {
718 &self.payload
719 }
720
721 #[must_use]
723 pub fn filter_keys(&self) -> EventFilterKeys {
724 EventFilterKeys {
725 category: self.category,
726 severity: self.severity,
727 workflow_id: self.workflow.as_ref().map(|workflow| workflow.workflow_id.clone()),
728 subject_kind: self.subject.kind,
729 subject_name: self.subject.name.clone(),
730 actor: self.workflow.as_ref().and_then(|workflow| workflow.actor.clone()),
731 surface: self.workflow.as_ref().and_then(|workflow| workflow.surface.clone()),
732 }
733 }
734
735 #[must_use]
737 pub fn summary(&self) -> EventSummary {
738 self.payload.summary()
739 }
740
741 pub fn design_validated(
747 event_id: impl Into<String>,
748 timestamp: DateTime<Utc>,
749 workflow: WorkflowContext,
750 design: impl Into<String>,
751 errors: usize,
752 warnings: usize,
753 ) -> Result<Self, EventValidationError> {
754 let design = design.into();
755 Self::new_with_workflow(
756 event_id,
757 timestamp,
758 workflow,
759 EventSubject::design(design.clone()),
760 EventPayload::DesignValidated { design, errors, warnings },
761 )
762 }
763
764 pub fn materialization_started(
770 event_id: impl Into<String>,
771 timestamp: DateTime<Utc>,
772 workflow: WorkflowContext,
773 entity: impl Into<String>,
774 platform: impl Into<String>,
775 ) -> Result<Self, EventValidationError> {
776 let entity = entity.into();
777 let platform = platform.into();
778 Self::new_with_workflow(
779 event_id,
780 timestamp,
781 workflow,
782 EventSubject::entity(entity.clone()),
783 EventPayload::MaterializationStarted { entity, platform },
784 )
785 }
786
787 pub fn materialization_completed(
793 event_id: impl Into<String>,
794 timestamp: DateTime<Utc>,
795 workflow: WorkflowContext,
796 entity: impl Into<String>,
797 platform: impl Into<String>,
798 artifacts: usize,
799 duration_ms: u64,
800 ) -> Result<Self, EventValidationError> {
801 let entity = entity.into();
802 let platform = platform.into();
803 Self::new_with_workflow(
804 event_id,
805 timestamp,
806 workflow,
807 EventSubject::entity(entity.clone()),
808 EventPayload::MaterializationCompleted { entity, platform, artifacts, duration_ms },
809 )
810 }
811
812 pub fn materialization_failed(
818 event_id: impl Into<String>,
819 timestamp: DateTime<Utc>,
820 workflow: WorkflowContext,
821 design: impl Into<String>,
822 platform: impl Into<String>,
823 message: impl Into<String>,
824 ) -> Result<Self, EventValidationError> {
825 let design = design.into();
826 let platform = platform.into();
827 let message = message.into();
828 Self::new_with_workflow(
829 event_id,
830 timestamp,
831 workflow,
832 EventSubject::design(design.clone()),
833 EventPayload::MaterializationFailed { design, platform, message },
834 )
835 }
836
837 pub fn profiling_started(
843 event_id: impl Into<String>,
844 timestamp: DateTime<Utc>,
845 workflow: WorkflowContext,
846 source: impl Into<String>,
847 ) -> Result<Self, EventValidationError> {
848 let source = source.into();
849 Self::new_with_workflow(
850 event_id,
851 timestamp,
852 workflow,
853 EventSubject::source(source.clone()),
854 EventPayload::ProfilingStarted { source },
855 )
856 }
857
858 pub fn profiling_completed(
864 event_id: impl Into<String>,
865 timestamp: DateTime<Utc>,
866 workflow: WorkflowContext,
867 source: impl Into<String>,
868 fields: usize,
869 rows: usize,
870 duration_ms: u64,
871 ) -> Result<Self, EventValidationError> {
872 let source = source.into();
873 Self::new_with_workflow(
874 event_id,
875 timestamp,
876 workflow,
877 EventSubject::source(source.clone()),
878 EventPayload::ProfilingCompleted { source, fields, rows, duration_ms },
879 )
880 }
881
882 pub fn design_loaded(
888 event_id: impl Into<String>,
889 timestamp: DateTime<Utc>,
890 path: impl Into<String>,
891 entities: usize,
892 ) -> Result<Self, EventValidationError> {
893 let path = path.into();
894 Self::new(
895 event_id,
896 timestamp,
897 EventSubject::design(path.clone()),
898 EventPayload::DesignLoaded { path, entities },
899 )
900 }
901
902 pub fn plugin_loaded(
908 event_id: impl Into<String>,
909 timestamp: DateTime<Utc>,
910 name: impl Into<String>,
911 plugin_type: impl Into<String>,
912 source: impl Into<String>,
913 ) -> Result<Self, EventValidationError> {
914 let name = name.into();
915 let plugin_type = plugin_type.into();
916 let source = source.into();
917 Self::new(
918 event_id,
919 timestamp,
920 EventSubject::plugin(name.clone()),
921 EventPayload::PluginLoaded { name, plugin_type, source },
922 )
923 }
924
925 pub fn storage_operation_completed(
931 event_id: impl Into<String>,
932 timestamp: DateTime<Utc>,
933 workflow: WorkflowContext,
934 operation: impl Into<String>,
935 path: impl Into<String>,
936 bytes: u64,
937 duration_ms: u64,
938 ) -> Result<Self, EventValidationError> {
939 let operation = operation.into();
940 let path = path.into();
941 Self::new_with_workflow(
942 event_id,
943 timestamp,
944 workflow,
945 EventSubject::storage(path.clone()),
946 EventPayload::StorageOperationCompleted { operation, path, bytes, duration_ms },
947 )
948 }
949
950 pub fn llm_request_completed(
956 event_id: impl Into<String>,
957 timestamp: DateTime<Utc>,
958 workflow: WorkflowContext,
959 provider: impl Into<String>,
960 model: impl Into<String>,
961 tokens: u64,
962 duration_ms: u64,
963 ) -> Result<Self, EventValidationError> {
964 let provider = provider.into();
965 let model = model.into();
966 Self::new_with_workflow(
967 event_id,
968 timestamp,
969 workflow,
970 EventSubject::llm_model(model.clone()),
971 EventPayload::LlmRequestCompleted { provider, model, tokens, duration_ms },
972 )
973 }
974}
975
976#[async_trait]
978pub trait EventEmitter: Send + Sync {
979 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)>;
981}
982
983#[async_trait]
991pub trait EventSink: Send + Sync {
992 fn name(&self) -> &str;
994
995 async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError>;
1001
1002 async fn flush(&self) -> Result<(), EventSinkError>;
1008}
1009
1010pub struct EventRegistry {
1017 sinks: Vec<Arc<dyn EventSink>>,
1018 history: RwLock<Vec<RazeriEvent>>,
1019}
1020
1021impl EventRegistry {
1022 #[must_use]
1024 pub fn new() -> Self {
1025 Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1026 }
1027
1028 pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1030 self.sinks.push(sink);
1031 }
1032
1033 pub async fn emit(&self, event: &RazeriEvent) -> Vec<(String, EventSinkError)> {
1038 self.record(event.clone());
1039 let mut errors = Vec::new();
1040 for sink in &self.sinks {
1041 if let Err(e) = sink.on_event(event).await {
1042 errors.push((sink.name().to_string(), e));
1043 }
1044 }
1045 errors
1046 }
1047
1048 pub fn record(&self, event: RazeriEvent) {
1050 self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1051 }
1052
1053 pub fn events(&self) -> Vec<RazeriEvent> {
1055 self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1056 }
1057
1058 pub fn recent_events(&self, limit: usize) -> Vec<RazeriEvent> {
1060 let events = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1061 let start = events.len().saturating_sub(limit);
1062 events[start..].to_vec()
1063 }
1064
1065 pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<RazeriEvent> {
1067 self.history
1068 .read()
1069 .unwrap_or_else(std::sync::PoisonError::into_inner)
1070 .iter()
1071 .filter(|event| {
1072 event.workflow().is_some_and(|workflow| workflow.workflow_id == workflow_id)
1073 })
1074 .cloned()
1075 .collect()
1076 }
1077
1078 pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1080 self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1081 }
1082
1083 pub async fn flush_all(&self) -> Vec<(String, EventSinkError)> {
1085 let mut errors = Vec::new();
1086 for sink in &self.sinks {
1087 if let Err(e) = sink.flush().await {
1088 errors.push((sink.name().to_string(), e));
1089 }
1090 }
1091 errors
1092 }
1093
1094 pub fn len(&self) -> usize {
1096 self.sinks.len()
1097 }
1098
1099 pub fn is_empty(&self) -> bool {
1101 self.sinks.is_empty()
1102 }
1103
1104 pub fn query(&self, q: &EventQuery) -> Vec<RazeriEvent> {
1106 let history = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1107 let iter = history.iter().filter(|e| {
1108 q.category.is_none_or(|c| e.category() == c)
1109 && q.min_severity.is_none_or(|s| e.severity() >= s)
1110 && q.workflow_id
1111 .as_ref()
1112 .is_none_or(|wf| e.workflow().is_some_and(|w| w.workflow_id == *wf))
1113 && q.subject_name.as_ref().is_none_or(|n| e.subject().name == *n)
1114 && q.subject_kind.is_none_or(|k| e.subject().kind == k)
1115 && q.since.is_none_or(|t| e.timestamp() >= t)
1116 });
1117 match q.limit {
1118 Some(n) => iter.take(n).cloned().collect(),
1119 None => iter.cloned().collect(),
1120 }
1121 }
1122}
1123
1124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1129pub struct EventQuery {
1130 pub category: Option<EventCategory>,
1132 pub min_severity: Option<EventSeverity>,
1134 pub workflow_id: Option<String>,
1136 pub subject_name: Option<String>,
1138 pub subject_kind: Option<EventSubjectKind>,
1140 pub since: Option<DateTime<Utc>>,
1142 pub limit: Option<usize>,
1144}
1145
1146impl Default for EventRegistry {
1147 fn default() -> Self {
1148 Self::new()
1149 }
1150}
1151
1152#[async_trait]
1153impl EventEmitter for EventRegistry {
1154 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)> {
1155 self.emit(&event).await
1156 }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162 use std::sync::atomic::{AtomicUsize, Ordering};
1163
1164 fn sample_timestamp() -> DateTime<Utc> {
1165 DateTime::parse_from_rfc3339("2026-03-13T12:34:56Z")
1166 .expect("valid timestamp fixture")
1167 .with_timezone(&Utc)
1168 }
1169
1170 fn sample_workflow() -> WorkflowContext {
1171 WorkflowContext::new("workflow-123")
1172 .expect("workflow id should be valid")
1173 .with_request_id("request-456")
1174 .with_actor("analyst@example.com")
1175 .with_surface("web")
1176 }
1177
1178 struct CountingSink {
1179 event_count: AtomicUsize,
1180 flush_count: AtomicUsize,
1181 }
1182
1183 impl CountingSink {
1184 fn new() -> Self {
1185 Self { event_count: AtomicUsize::new(0), flush_count: AtomicUsize::new(0) }
1186 }
1187
1188 fn events_received(&self) -> usize {
1189 self.event_count.load(Ordering::SeqCst)
1190 }
1191
1192 fn flushes(&self) -> usize {
1193 self.flush_count.load(Ordering::SeqCst)
1194 }
1195 }
1196
1197 #[async_trait]
1198 impl EventSink for CountingSink {
1199 fn name(&self) -> &'static str {
1200 "counting"
1201 }
1202
1203 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1204 self.event_count.fetch_add(1, Ordering::SeqCst);
1205 Ok(())
1206 }
1207
1208 async fn flush(&self) -> Result<(), EventSinkError> {
1209 self.flush_count.fetch_add(1, Ordering::SeqCst);
1210 Ok(())
1211 }
1212 }
1213
1214 struct FailingSink;
1215
1216 #[async_trait]
1217 impl EventSink for FailingSink {
1218 fn name(&self) -> &'static str {
1219 "failing"
1220 }
1221
1222 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1223 Err(EventSinkError::SinkError("intentional failure".to_string()))
1224 }
1225
1226 async fn flush(&self) -> Result<(), EventSinkError> {
1227 Err(EventSinkError::SinkError("intentional failure".to_string()))
1228 }
1229 }
1230
1231 fn sample_event() -> RazeriEvent {
1232 RazeriEvent::new_with_workflow(
1233 "event-123",
1234 sample_timestamp(),
1235 sample_workflow(),
1236 EventSubject::source("test.csv"),
1237 EventPayload::ProfilingCompleted {
1238 source: "test.csv".to_string(),
1239 fields: 10,
1240 rows: 1000,
1241 duration_ms: 42,
1242 },
1243 )
1244 .expect("workflow-scoped sample event should be valid")
1245 }
1246
1247 fn plugin_event() -> RazeriEvent {
1248 RazeriEvent::new(
1249 "event-plugin-1",
1250 sample_timestamp(),
1251 EventSubject::plugin("builtin-logger"),
1252 EventPayload::PluginLoaded {
1253 name: "builtin-logger".to_string(),
1254 plugin_type: "event_sink".to_string(),
1255 source: "builtin".to_string(),
1256 },
1257 )
1258 .expect("plugin events are not workflow scoped")
1259 }
1260
1261 #[tokio::test]
1262 async fn event_dispatched_to_all_sinks() {
1263 let sink1 = Arc::new(CountingSink::new());
1264 let sink2 = Arc::new(CountingSink::new());
1265
1266 let mut registry = EventRegistry::new();
1267 registry.register(sink1.clone());
1268 registry.register(sink2.clone());
1269
1270 let errors = registry.emit(&sample_event()).await;
1271 assert!(errors.is_empty());
1272 assert_eq!(sink1.events_received(), 1);
1273 assert_eq!(sink2.events_received(), 1);
1274 }
1275
1276 #[tokio::test]
1277 async fn sink_failure_does_not_halt_dispatch() {
1278 let good_sink = Arc::new(CountingSink::new());
1279 let bad_sink: Arc<dyn EventSink> = Arc::new(FailingSink);
1280
1281 let mut registry = EventRegistry::new();
1282 registry.register(bad_sink);
1283 registry.register(good_sink.clone());
1284
1285 let errors = registry.emit(&sample_event()).await;
1286 assert_eq!(errors.len(), 1);
1288 assert_eq!(errors[0].0, "failing");
1289 assert_eq!(good_sink.events_received(), 1);
1290 }
1291
1292 #[tokio::test]
1293 async fn flush_all_flushes_every_sink() {
1294 let sink1 = Arc::new(CountingSink::new());
1295 let sink2 = Arc::new(CountingSink::new());
1296
1297 let mut registry = EventRegistry::new();
1298 registry.register(sink1.clone());
1299 registry.register(sink2.clone());
1300
1301 let errors = registry.flush_all().await;
1302 assert!(errors.is_empty());
1303 assert_eq!(sink1.flushes(), 1);
1304 assert_eq!(sink2.flushes(), 1);
1305 }
1306
1307 #[tokio::test]
1308 async fn emitted_events_are_recorded_and_queryable() {
1309 let registry = EventRegistry::new();
1310 registry.emit_event(sample_event()).await;
1311 registry.emit_event(plugin_event()).await;
1312
1313 let recorded = registry.events();
1314 assert_eq!(recorded.len(), 2);
1315
1316 let workflow_events = registry.events_for_workflow("workflow-123");
1317 assert_eq!(workflow_events.len(), 1);
1318 assert_eq!(workflow_events[0].category(), EventCategory::Profiling);
1319
1320 let summaries = registry.recent_summaries(2);
1321 assert_eq!(summaries.len(), 2);
1322 assert_eq!(summaries[0].title, "Profiling completed");
1323 assert_eq!(summaries[1].title, "Plugin loaded");
1324 }
1325
1326 #[test]
1327 fn materialization_failure_has_error_severity_and_summary() {
1328 let event = RazeriEvent::materialization_failed(
1329 "event-materialization-failed",
1330 sample_timestamp(),
1331 sample_workflow(),
1332 "sales_design",
1333 "duckdb",
1334 "dialect mismatch",
1335 )
1336 .expect("materialization failure event should be valid");
1337
1338 assert_eq!(event.category(), EventCategory::Materialization);
1339 assert_eq!(event.severity(), EventSeverity::Error);
1340 assert!(event.summary().detail.contains("dialect mismatch"));
1341 }
1342
1343 #[test]
1344 fn event_serializes_to_json() {
1345 let event = sample_event();
1346 let json = serde_json::to_string(&event);
1347 assert!(json.is_ok());
1348 let json_str = json.unwrap();
1349 assert!(json_str.contains("profiling_completed"));
1350 assert!(json_str.contains("test.csv"));
1351 assert!(json_str.contains("workflow-123"));
1352 }
1353
1354 #[test]
1355 fn workflow_scoped_event_requires_workflow_context() {
1356 let event = RazeriEvent::new(
1357 "event-missing-workflow",
1358 sample_timestamp(),
1359 EventSubject::source("missing.csv"),
1360 EventPayload::ProfilingStarted { source: "missing.csv".to_string() },
1361 );
1362
1363 assert_eq!(
1364 event,
1365 Err(EventValidationError::WorkflowContextRequired {
1366 payload: "profiling_started".to_string(),
1367 })
1368 );
1369 }
1370
1371 #[test]
1372 fn event_round_trips_through_json_without_losing_meaning() {
1373 let event = sample_event();
1374 let json = serde_json::to_string(&event).expect("event should serialize");
1375 let round_trip: RazeriEvent =
1376 serde_json::from_str(&json).expect("event should deserialize");
1377
1378 assert_eq!(event, round_trip);
1379 assert_eq!(round_trip.validate(), Ok(()));
1380 }
1381
1382 #[test]
1383 fn summaries_and_filter_keys_are_derived_from_canonical_event() {
1384 let event = sample_event();
1385
1386 let keys = event.filter_keys();
1387 assert_eq!(keys.category, EventCategory::Profiling);
1388 assert_eq!(keys.severity, EventSeverity::Info);
1389 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1390 assert_eq!(keys.subject_kind, EventSubjectKind::Source);
1391 assert_eq!(keys.subject_name, "test.csv");
1392 assert_eq!(keys.actor.as_deref(), Some("analyst@example.com"));
1393 assert_eq!(keys.surface.as_deref(), Some("web"));
1394
1395 let summary = event.summary();
1396 assert_eq!(summary.title, "Profiling completed");
1397 assert!(summary.detail.contains("10 fields"));
1398 assert!(summary.detail.contains("1000 rows"));
1399 }
1400
1401 #[test]
1402 fn non_workflow_scoped_events_can_be_created_without_workflow_context() {
1403 let event = plugin_event();
1404
1405 assert_eq!(event.category(), EventCategory::Plugin);
1406 assert_eq!(event.severity(), EventSeverity::Info);
1407 assert!(event.workflow().is_none());
1408 }
1409
1410 #[test]
1411 fn profiling_started_constructs_with_source_subject() {
1412 let event = RazeriEvent::profiling_started(
1413 "ps-1",
1414 sample_timestamp(),
1415 sample_workflow(),
1416 "customers.csv",
1417 )
1418 .unwrap();
1419 assert_eq!(event.category(), EventCategory::Profiling);
1420 assert_eq!(event.subject().kind, EventSubjectKind::Source);
1421 assert_eq!(event.summary().title, "Profiling started");
1422 }
1423
1424 #[test]
1425 fn profiling_completed_constructs_with_stats() {
1426 let event = RazeriEvent::profiling_completed(
1427 "pc-1",
1428 sample_timestamp(),
1429 sample_workflow(),
1430 "customers.csv",
1431 10,
1432 5000,
1433 123,
1434 )
1435 .unwrap();
1436 assert_eq!(event.category(), EventCategory::Profiling);
1437 assert!(event.summary().detail.contains("10 fields"));
1438 assert!(event.summary().detail.contains("5000 rows"));
1439 }
1440
1441 #[test]
1442 fn design_loaded_constructs_without_workflow() {
1443 let event =
1444 RazeriEvent::design_loaded("dl-1", sample_timestamp(), "designs/retail.yaml", 3)
1445 .unwrap();
1446 assert_eq!(event.category(), EventCategory::Design);
1447 assert!(event.workflow().is_none());
1448 assert!(event.summary().detail.contains("3 entities"));
1449 }
1450
1451 #[test]
1452 fn plugin_loaded_constructs_without_workflow() {
1453 let event = RazeriEvent::plugin_loaded(
1454 "pl-1",
1455 sample_timestamp(),
1456 "PostgresDialect",
1457 "dialect",
1458 "inventory",
1459 )
1460 .unwrap();
1461 assert_eq!(event.category(), EventCategory::Plugin);
1462 assert_eq!(event.subject().kind, EventSubjectKind::Plugin);
1463 assert!(event.summary().detail.contains("dialect"));
1464 }
1465
1466 #[test]
1467 fn storage_operation_completed_constructs_with_workflow() {
1468 let event = RazeriEvent::storage_operation_completed(
1469 "so-1",
1470 sample_timestamp(),
1471 sample_workflow(),
1472 "move",
1473 "/data/file.csv",
1474 1024,
1475 50,
1476 )
1477 .unwrap();
1478 assert_eq!(event.category(), EventCategory::Storage);
1479 assert_eq!(event.subject().kind, EventSubjectKind::StoragePath);
1480 }
1481
1482 #[test]
1483 fn llm_request_completed_constructs_with_workflow() {
1484 let event = RazeriEvent::llm_request_completed(
1485 "lr-1",
1486 sample_timestamp(),
1487 sample_workflow(),
1488 "ollama",
1489 "llama3",
1490 500,
1491 3000,
1492 )
1493 .unwrap();
1494 assert_eq!(event.category(), EventCategory::Ai);
1495 assert_eq!(event.subject().kind, EventSubjectKind::LlmModel);
1496 assert!(event.summary().detail.contains("500 tokens"));
1497 }
1498
1499 #[test]
1500 fn query_returns_all_events_when_empty() {
1501 let registry = EventRegistry::new();
1502 registry.record(sample_event());
1503 registry.record(plugin_event());
1504
1505 let results = registry.query(&EventQuery::default());
1506 assert_eq!(results.len(), 2);
1507 }
1508
1509 #[test]
1510 fn query_filters_by_category() {
1511 let registry = EventRegistry::new();
1512 registry.record(sample_event());
1513 registry.record(plugin_event());
1514
1515 let results = registry
1516 .query(&EventQuery { category: Some(EventCategory::Plugin), ..Default::default() });
1517 assert_eq!(results.len(), 1);
1518 assert_eq!(results[0].category(), EventCategory::Plugin);
1519 }
1520
1521 #[test]
1522 fn query_filters_by_min_severity() {
1523 let registry = EventRegistry::new();
1524 registry.record(sample_event()); let error_event = RazeriEvent::materialization_failed(
1526 "err-1",
1527 sample_timestamp(),
1528 sample_workflow(),
1529 "design-a",
1530 "PostgreSQL",
1531 "connection error",
1532 )
1533 .unwrap();
1534 registry.record(error_event);
1535
1536 let results = registry
1537 .query(&EventQuery { min_severity: Some(EventSeverity::Error), ..Default::default() });
1538 assert_eq!(results.len(), 1);
1539 assert_eq!(results[0].severity(), EventSeverity::Error);
1540 }
1541
1542 #[test]
1543 fn query_filters_by_workflow_id() {
1544 let registry = EventRegistry::new();
1545 registry.record(sample_event()); registry.record(plugin_event()); let results = registry.query(&EventQuery {
1549 workflow_id: Some("workflow-123".to_string()),
1550 ..Default::default()
1551 });
1552 assert_eq!(results.len(), 1);
1553 }
1554
1555 #[test]
1556 fn query_respects_limit() {
1557 let registry = EventRegistry::new();
1558 registry.record(sample_event());
1559 registry.record(plugin_event());
1560
1561 let results = registry.query(&EventQuery { limit: Some(1), ..Default::default() });
1562 assert_eq!(results.len(), 1);
1563 }
1564
1565 #[test]
1568 fn snapshot_profiling_completed_json_shape() {
1569 let event = sample_event();
1570 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1571 let obj = val.as_object().unwrap();
1572
1573 assert!(obj.contains_key("event_id"));
1575 assert!(obj.contains_key("timestamp"));
1576 assert!(obj.contains_key("category"));
1577 assert!(obj.contains_key("severity"));
1578 assert!(obj.contains_key("workflow"));
1579 assert!(obj.contains_key("subject"));
1580 assert!(obj.contains_key("payload"));
1581
1582 let payload = obj["payload"].as_object().unwrap();
1584 assert_eq!(payload["kind"], "profiling_completed");
1585 assert!(payload.contains_key("source"));
1586 assert!(payload.contains_key("fields"));
1587 assert!(payload.contains_key("rows"));
1588 assert!(payload.contains_key("duration_ms"));
1589
1590 let subject = obj["subject"].as_object().unwrap();
1592 assert_eq!(subject["kind"], "source");
1593 assert!(subject.contains_key("name"));
1594 }
1595
1596 #[test]
1597 fn snapshot_event_round_trip_preserves_identity() {
1598 let event = sample_event();
1599 let json = serde_json::to_string(&event).unwrap();
1600 let roundtrip: RazeriEvent = serde_json::from_str(&json).unwrap();
1601 assert_eq!(roundtrip.event_id(), event.event_id());
1602 assert_eq!(roundtrip.category(), event.category());
1603 assert_eq!(roundtrip.severity(), event.severity());
1604 assert_eq!(roundtrip.subject().name, event.subject().name);
1605 }
1606
1607 #[test]
1608 fn snapshot_materialization_failed_json_shape() {
1609 let event = RazeriEvent::materialization_failed(
1610 "snap-mat-fail",
1611 sample_timestamp(),
1612 sample_workflow(),
1613 "sales_design",
1614 "duckdb",
1615 "type mismatch",
1616 )
1617 .unwrap();
1618 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1619 let payload = val["payload"].as_object().unwrap();
1620 assert_eq!(payload["kind"], "materialization_failed");
1621 assert_eq!(payload["design"], "sales_design");
1622 assert_eq!(payload["platform"], "duckdb");
1623 assert_eq!(payload["message"], "type mismatch");
1624 }
1625
1626 #[test]
1627 fn snapshot_plugin_loaded_json_shape() {
1628 let event = plugin_event();
1629 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1630 let payload = val["payload"].as_object().unwrap();
1631 assert_eq!(payload["kind"], "plugin_loaded");
1632 assert!(payload.contains_key("name"));
1633 assert!(payload.contains_key("plugin_type"));
1634 assert!(payload.contains_key("source"));
1635 }
1636
1637 #[test]
1638 fn snapshot_event_summary_format_is_stable() {
1639 let event = sample_event();
1640 let summary = event.summary();
1641 assert_eq!(summary.title, "Profiling completed");
1642 assert!(summary.detail.contains("test.csv"));
1643 assert!(summary.detail.contains("10"));
1644 assert!(summary.detail.contains("100"));
1645 }
1646
1647 #[test]
1648 fn snapshot_filter_keys_are_stable() {
1649 let event = sample_event();
1650 let keys = event.filter_keys();
1651 assert_eq!(keys.category, EventCategory::Profiling);
1652 assert_eq!(keys.severity, EventSeverity::Info);
1653 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1654 assert_eq!(keys.subject_name, "test.csv");
1655 }
1656}