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}
295
296impl EventPayload {
297 #[must_use]
299 pub fn category(&self) -> EventCategory {
300 match self {
301 Self::ProfilingStarted { .. } | Self::ProfilingCompleted { .. } => {
302 EventCategory::Profiling
303 }
304 Self::MaterializationStarted { .. } | Self::MaterializationCompleted { .. } => {
305 EventCategory::Materialization
306 }
307 Self::MaterializationFailed { .. } => EventCategory::Materialization,
308 Self::DesignValidated { .. } | Self::DesignLoaded { .. } => EventCategory::Design,
309 Self::PluginLoaded { .. } => EventCategory::Plugin,
310 Self::StorageOperationCompleted { .. } | Self::StorageWarning { .. } => {
311 EventCategory::Storage
312 }
313 Self::LlmRequestCompleted { .. } => EventCategory::Ai,
314 }
315 }
316
317 #[must_use]
319 pub fn severity(&self) -> EventSeverity {
320 match self {
321 Self::MaterializationFailed { .. } => EventSeverity::Error,
322 Self::DesignValidated { errors, .. } if *errors > 0 => EventSeverity::Error,
323 Self::DesignValidated { warnings, .. } if *warnings > 0 => EventSeverity::Warning,
324 Self::StorageWarning { .. } => EventSeverity::Warning,
325 _ => EventSeverity::Info,
326 }
327 }
328
329 #[must_use]
331 pub fn requires_workflow_context(&self) -> bool {
332 match self {
333 Self::ProfilingStarted { .. }
334 | Self::ProfilingCompleted { .. }
335 | Self::MaterializationStarted { .. }
336 | Self::MaterializationCompleted { .. }
337 | Self::MaterializationFailed { .. }
338 | Self::DesignValidated { .. }
339 | Self::StorageOperationCompleted { .. }
340 | Self::LlmRequestCompleted { .. } => true,
341 Self::DesignLoaded { .. } | Self::PluginLoaded { .. } | Self::StorageWarning { .. } => {
342 false
343 }
344 }
345 }
346
347 pub(crate) fn name(&self) -> &'static str {
348 match self {
349 Self::ProfilingStarted { .. } => "profiling_started",
350 Self::ProfilingCompleted { .. } => "profiling_completed",
351 Self::MaterializationStarted { .. } => "materialization_started",
352 Self::MaterializationCompleted { .. } => "materialization_completed",
353 Self::MaterializationFailed { .. } => "materialization_failed",
354 Self::DesignValidated { .. } => "design_validated",
355 Self::DesignLoaded { .. } => "design_loaded",
356 Self::PluginLoaded { .. } => "plugin_loaded",
357 Self::StorageOperationCompleted { .. } => "storage_operation_completed",
358 Self::StorageWarning { .. } => "storage_warning",
359 Self::LlmRequestCompleted { .. } => "llm_request_completed",
360 }
361 }
362
363 fn summary(&self) -> EventSummary {
364 match self {
365 Self::ProfilingStarted { source } => profiling_started_summary(source),
366 Self::ProfilingCompleted { source, fields, rows, duration_ms } => {
367 profiling_completed_summary(source, *fields, *rows, *duration_ms)
368 }
369 Self::MaterializationStarted { entity, platform } => {
370 materialization_started_summary(entity, platform)
371 }
372 Self::MaterializationCompleted { entity, platform, artifacts, duration_ms } => {
373 materialization_completed_summary(entity, platform, *artifacts, *duration_ms)
374 }
375 Self::MaterializationFailed { design, platform, message } => {
376 materialization_failed_summary(design, platform, message)
377 }
378 Self::DesignValidated { design, errors, warnings } => {
379 design_validated_summary(design, *errors, *warnings)
380 }
381 Self::DesignLoaded { path, entities } => design_loaded_summary(path, *entities),
382 Self::PluginLoaded { name, plugin_type, source } => {
383 plugin_loaded_summary(name, plugin_type, source)
384 }
385 Self::StorageOperationCompleted { operation, path, bytes, duration_ms } => {
386 storage_operation_completed_summary(operation, path, *bytes, *duration_ms)
387 }
388 Self::StorageWarning { entity, feature, platform, recommendation } => {
389 storage_warning_summary(entity, feature, platform, recommendation)
390 }
391 Self::LlmRequestCompleted { provider, model, tokens, duration_ms } => {
392 llm_request_completed_summary(provider, model, *tokens, *duration_ms)
393 }
394 }
395 }
396}
397
398fn profiling_started_summary(source: &str) -> EventSummary {
402 EventSummary {
403 title: "Profiling started".to_string(),
404 detail: format!("Profiling started for source '{source}'"),
405 }
406}
407
408fn profiling_completed_summary(
415 source: &str,
416 fields: usize,
417 rows: usize,
418 duration_ms: u64,
419) -> EventSummary {
420 EventSummary {
421 title: "Profiling completed".to_string(),
422 detail: format!(
423 "Source '{source}' profiled with {fields} fields, {rows} rows in {duration_ms} ms"
424 ),
425 }
426}
427
428fn materialization_started_summary(entity: &str, platform: &str) -> EventSummary {
433 EventSummary {
434 title: "Materialization started".to_string(),
435 detail: format!("Entity '{entity}' started materialization for {platform}"),
436 }
437}
438
439fn materialization_completed_summary(
446 entity: &str,
447 platform: &str,
448 artifacts: usize,
449 duration_ms: u64,
450) -> EventSummary {
451 EventSummary {
452 title: "Materialization completed".to_string(),
453 detail: format!(
454 "Entity '{entity}' produced {artifacts} artifacts for {platform} in {duration_ms} ms"
455 ),
456 }
457}
458
459fn materialization_failed_summary(design: &str, platform: &str, message: &str) -> EventSummary {
465 EventSummary {
466 title: "Materialization failed".to_string(),
467 detail: format!("Design '{design}' failed materialization for {platform}: {message}"),
468 }
469}
470
471fn design_validated_summary(design: &str, errors: usize, warnings: usize) -> EventSummary {
477 EventSummary {
478 title: "Design validated".to_string(),
479 detail: format!("Design '{design}' validated with {errors} errors and {warnings} warnings"),
480 }
481}
482
483fn design_loaded_summary(path: &str, entities: usize) -> EventSummary {
488 EventSummary {
489 title: "Design loaded".to_string(),
490 detail: format!("Loaded design from '{path}' with {entities} entities"),
491 }
492}
493
494fn plugin_loaded_summary(name: &str, plugin_type: &str, source: &str) -> EventSummary {
500 EventSummary {
501 title: "Plugin loaded".to_string(),
502 detail: format!("Loaded {plugin_type} plugin '{name}' from {source}"),
503 }
504}
505
506fn storage_operation_completed_summary(
513 operation: &str,
514 path: &str,
515 bytes: u64,
516 duration_ms: u64,
517) -> EventSummary {
518 EventSummary {
519 title: "Storage operation completed".to_string(),
520 detail: format!(
521 "Operation '{operation}' processed {bytes} bytes at '{path}' in {duration_ms} ms"
522 ),
523 }
524}
525
526fn storage_warning_summary(
533 entity: &str,
534 feature: &str,
535 platform: &str,
536 recommendation: &str,
537) -> EventSummary {
538 EventSummary {
539 title: "Storage warning".to_string(),
540 detail: format!(
541 "Entity '{entity}': feature '{feature}' unavailable on '{platform}'; {recommendation}"
542 ),
543 }
544}
545
546fn llm_request_completed_summary(
553 provider: &str,
554 model: &str,
555 tokens: u64,
556 duration_ms: u64,
557) -> EventSummary {
558 EventSummary {
559 title: "LLM request completed".to_string(),
560 detail: format!(
561 "Provider '{provider}' model '{model}' used {tokens} tokens in {duration_ms} ms"
562 ),
563 }
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
568#[non_exhaustive]
569pub struct EventFilterKeys {
570 pub category: EventCategory,
572 pub severity: EventSeverity,
574 pub workflow_id: Option<String>,
576 pub subject_kind: EventSubjectKind,
578 pub subject_name: String,
580 pub actor: Option<String>,
582 pub surface: Option<String>,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
588#[non_exhaustive]
589pub struct EventSummary {
590 pub title: String,
592 pub detail: String,
594}
595
596#[derive(Debug, Error, Clone, PartialEq, Eq)]
598#[non_exhaustive]
599pub enum EventValidationError {
600 #[error("workflow-scoped event payload '{payload}' requires workflow context")]
602 WorkflowContextRequired {
603 payload: String,
605 },
606 #[error("workflow context requires a non-empty workflow_id")]
608 WorkflowIdRequired,
609}
610
611impl RazeriEvent {
612 pub fn new(
618 event_id: impl Into<String>,
619 timestamp: DateTime<Utc>,
620 subject: EventSubject,
621 payload: EventPayload,
622 ) -> Result<Self, EventValidationError> {
623 Self::build(event_id.into(), timestamp, None, subject, payload)
624 }
625
626 pub fn new_with_workflow(
632 event_id: impl Into<String>,
633 timestamp: DateTime<Utc>,
634 workflow: WorkflowContext,
635 subject: EventSubject,
636 payload: EventPayload,
637 ) -> Result<Self, EventValidationError> {
638 Self::build(event_id.into(), timestamp, Some(workflow), subject, payload)
639 }
640
641 fn build(
642 event_id: String,
643 timestamp: DateTime<Utc>,
644 workflow: Option<WorkflowContext>,
645 subject: EventSubject,
646 payload: EventPayload,
647 ) -> Result<Self, EventValidationError> {
648 let event = Self {
649 event_id,
650 timestamp,
651 category: payload.category(),
652 severity: payload.severity(),
653 workflow,
654 subject,
655 payload,
656 };
657
658 event.validate()?;
659 Ok(event)
660 }
661
662 pub fn validate(&self) -> Result<(), EventValidationError> {
668 if self.payload.requires_workflow_context() && self.workflow.is_none() {
669 return Err(EventValidationError::WorkflowContextRequired {
670 payload: self.payload.name().to_string(),
671 });
672 }
673
674 if self.workflow.as_ref().is_some_and(|workflow| workflow.workflow_id.trim().is_empty()) {
675 return Err(EventValidationError::WorkflowIdRequired);
676 }
677
678 Ok(())
679 }
680
681 #[must_use]
683 pub fn event_id(&self) -> &str {
684 &self.event_id
685 }
686
687 #[must_use]
689 pub fn timestamp(&self) -> DateTime<Utc> {
690 self.timestamp
691 }
692
693 #[must_use]
695 pub fn category(&self) -> EventCategory {
696 self.category
697 }
698
699 #[must_use]
701 pub fn severity(&self) -> EventSeverity {
702 self.severity
703 }
704
705 #[must_use]
707 pub fn workflow(&self) -> Option<&WorkflowContext> {
708 self.workflow.as_ref()
709 }
710
711 #[must_use]
713 pub fn subject(&self) -> &EventSubject {
714 &self.subject
715 }
716
717 #[must_use]
719 pub fn payload(&self) -> &EventPayload {
720 &self.payload
721 }
722
723 #[must_use]
725 pub fn filter_keys(&self) -> EventFilterKeys {
726 EventFilterKeys {
727 category: self.category,
728 severity: self.severity,
729 workflow_id: self.workflow.as_ref().map(|workflow| workflow.workflow_id.clone()),
730 subject_kind: self.subject.kind,
731 subject_name: self.subject.name.clone(),
732 actor: self.workflow.as_ref().and_then(|workflow| workflow.actor.clone()),
733 surface: self.workflow.as_ref().and_then(|workflow| workflow.surface.clone()),
734 }
735 }
736
737 #[must_use]
739 pub fn summary(&self) -> EventSummary {
740 self.payload.summary()
741 }
742
743 pub fn design_validated(
749 event_id: impl Into<String>,
750 timestamp: DateTime<Utc>,
751 workflow: WorkflowContext,
752 design: impl Into<String>,
753 errors: usize,
754 warnings: usize,
755 ) -> Result<Self, EventValidationError> {
756 let design = design.into();
757 Self::new_with_workflow(
758 event_id,
759 timestamp,
760 workflow,
761 EventSubject::design(design.clone()),
762 EventPayload::DesignValidated { design, errors, warnings },
763 )
764 }
765
766 pub fn materialization_started(
772 event_id: impl Into<String>,
773 timestamp: DateTime<Utc>,
774 workflow: WorkflowContext,
775 entity: impl Into<String>,
776 platform: impl Into<String>,
777 ) -> Result<Self, EventValidationError> {
778 let entity = entity.into();
779 let platform = platform.into();
780 Self::new_with_workflow(
781 event_id,
782 timestamp,
783 workflow,
784 EventSubject::entity(entity.clone()),
785 EventPayload::MaterializationStarted { entity, platform },
786 )
787 }
788
789 pub fn materialization_completed(
795 event_id: impl Into<String>,
796 timestamp: DateTime<Utc>,
797 workflow: WorkflowContext,
798 entity: impl Into<String>,
799 platform: impl Into<String>,
800 artifacts: usize,
801 duration_ms: u64,
802 ) -> Result<Self, EventValidationError> {
803 let entity = entity.into();
804 let platform = platform.into();
805 Self::new_with_workflow(
806 event_id,
807 timestamp,
808 workflow,
809 EventSubject::entity(entity.clone()),
810 EventPayload::MaterializationCompleted { entity, platform, artifacts, duration_ms },
811 )
812 }
813
814 pub fn materialization_failed(
820 event_id: impl Into<String>,
821 timestamp: DateTime<Utc>,
822 workflow: WorkflowContext,
823 design: impl Into<String>,
824 platform: impl Into<String>,
825 message: impl Into<String>,
826 ) -> Result<Self, EventValidationError> {
827 let design = design.into();
828 let platform = platform.into();
829 let message = message.into();
830 Self::new_with_workflow(
831 event_id,
832 timestamp,
833 workflow,
834 EventSubject::design(design.clone()),
835 EventPayload::MaterializationFailed { design, platform, message },
836 )
837 }
838
839 pub fn profiling_started(
845 event_id: impl Into<String>,
846 timestamp: DateTime<Utc>,
847 workflow: WorkflowContext,
848 source: impl Into<String>,
849 ) -> Result<Self, EventValidationError> {
850 let source = source.into();
851 Self::new_with_workflow(
852 event_id,
853 timestamp,
854 workflow,
855 EventSubject::source(source.clone()),
856 EventPayload::ProfilingStarted { source },
857 )
858 }
859
860 pub fn profiling_completed(
866 event_id: impl Into<String>,
867 timestamp: DateTime<Utc>,
868 workflow: WorkflowContext,
869 source: impl Into<String>,
870 fields: usize,
871 rows: usize,
872 duration_ms: u64,
873 ) -> Result<Self, EventValidationError> {
874 let source = source.into();
875 Self::new_with_workflow(
876 event_id,
877 timestamp,
878 workflow,
879 EventSubject::source(source.clone()),
880 EventPayload::ProfilingCompleted { source, fields, rows, duration_ms },
881 )
882 }
883
884 pub fn design_loaded(
890 event_id: impl Into<String>,
891 timestamp: DateTime<Utc>,
892 path: impl Into<String>,
893 entities: usize,
894 ) -> Result<Self, EventValidationError> {
895 let path = path.into();
896 Self::new(
897 event_id,
898 timestamp,
899 EventSubject::design(path.clone()),
900 EventPayload::DesignLoaded { path, entities },
901 )
902 }
903
904 pub fn plugin_loaded(
910 event_id: impl Into<String>,
911 timestamp: DateTime<Utc>,
912 name: impl Into<String>,
913 plugin_type: impl Into<String>,
914 source: impl Into<String>,
915 ) -> Result<Self, EventValidationError> {
916 let name = name.into();
917 let plugin_type = plugin_type.into();
918 let source = source.into();
919 Self::new(
920 event_id,
921 timestamp,
922 EventSubject::plugin(name.clone()),
923 EventPayload::PluginLoaded { name, plugin_type, source },
924 )
925 }
926
927 pub fn storage_operation_completed(
933 event_id: impl Into<String>,
934 timestamp: DateTime<Utc>,
935 workflow: WorkflowContext,
936 operation: impl Into<String>,
937 path: impl Into<String>,
938 bytes: u64,
939 duration_ms: u64,
940 ) -> Result<Self, EventValidationError> {
941 let operation = operation.into();
942 let path = path.into();
943 Self::new_with_workflow(
944 event_id,
945 timestamp,
946 workflow,
947 EventSubject::storage(path.clone()),
948 EventPayload::StorageOperationCompleted { operation, path, bytes, duration_ms },
949 )
950 }
951
952 pub fn llm_request_completed(
958 event_id: impl Into<String>,
959 timestamp: DateTime<Utc>,
960 workflow: WorkflowContext,
961 provider: impl Into<String>,
962 model: impl Into<String>,
963 tokens: u64,
964 duration_ms: u64,
965 ) -> Result<Self, EventValidationError> {
966 let provider = provider.into();
967 let model = model.into();
968 Self::new_with_workflow(
969 event_id,
970 timestamp,
971 workflow,
972 EventSubject::llm_model(model.clone()),
973 EventPayload::LlmRequestCompleted { provider, model, tokens, duration_ms },
974 )
975 }
976}
977
978#[async_trait]
980pub trait EventEmitter: Send + Sync {
981 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)>;
983}
984
985#[async_trait]
993pub trait EventSink: Send + Sync {
994 fn name(&self) -> &str;
996
997 async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError>;
1003
1004 async fn flush(&self) -> Result<(), EventSinkError>;
1010}
1011
1012pub struct EventRegistry {
1019 sinks: Vec<Arc<dyn EventSink>>,
1020 history: RwLock<Vec<RazeriEvent>>,
1021}
1022
1023impl EventRegistry {
1024 #[must_use]
1026 pub fn new() -> Self {
1027 Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1028 }
1029
1030 pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1032 self.sinks.push(sink);
1033 }
1034
1035 pub async fn emit(&self, event: &RazeriEvent) -> Vec<(String, EventSinkError)> {
1040 self.record(event.clone());
1041 let mut errors = Vec::new();
1042 for sink in &self.sinks {
1043 if let Err(e) = sink.on_event(event).await {
1044 errors.push((sink.name().to_string(), e));
1045 }
1046 }
1047 errors
1048 }
1049
1050 pub fn record(&self, event: RazeriEvent) {
1052 self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1053 }
1054
1055 pub fn events(&self) -> Vec<RazeriEvent> {
1057 self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1058 }
1059
1060 pub fn recent_events(&self, limit: usize) -> Vec<RazeriEvent> {
1062 let events = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1063 let start = events.len().saturating_sub(limit);
1064 events[start..].to_vec()
1065 }
1066
1067 pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<RazeriEvent> {
1069 self.history
1070 .read()
1071 .unwrap_or_else(std::sync::PoisonError::into_inner)
1072 .iter()
1073 .filter(|event| {
1074 event.workflow().is_some_and(|workflow| workflow.workflow_id == workflow_id)
1075 })
1076 .cloned()
1077 .collect()
1078 }
1079
1080 pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1082 self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1083 }
1084
1085 pub async fn flush_all(&self) -> Vec<(String, EventSinkError)> {
1087 let mut errors = Vec::new();
1088 for sink in &self.sinks {
1089 if let Err(e) = sink.flush().await {
1090 errors.push((sink.name().to_string(), e));
1091 }
1092 }
1093 errors
1094 }
1095
1096 pub fn len(&self) -> usize {
1098 self.sinks.len()
1099 }
1100
1101 pub fn is_empty(&self) -> bool {
1103 self.sinks.is_empty()
1104 }
1105
1106 pub fn query(&self, q: &EventQuery) -> Vec<RazeriEvent> {
1108 let history = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1109 let iter = history.iter().filter(|e| {
1110 q.category.is_none_or(|c| e.category() == c)
1111 && q.min_severity.is_none_or(|s| e.severity() >= s)
1112 && q.workflow_id
1113 .as_ref()
1114 .is_none_or(|wf| e.workflow().is_some_and(|w| w.workflow_id == *wf))
1115 && q.subject_name.as_ref().is_none_or(|n| e.subject().name == *n)
1116 && q.subject_kind.is_none_or(|k| e.subject().kind == k)
1117 && q.since.is_none_or(|t| e.timestamp() >= t)
1118 });
1119 match q.limit {
1120 Some(n) => iter.take(n).cloned().collect(),
1121 None => iter.cloned().collect(),
1122 }
1123 }
1124}
1125
1126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1131pub struct EventQuery {
1132 pub category: Option<EventCategory>,
1134 pub min_severity: Option<EventSeverity>,
1136 pub workflow_id: Option<String>,
1138 pub subject_name: Option<String>,
1140 pub subject_kind: Option<EventSubjectKind>,
1142 pub since: Option<DateTime<Utc>>,
1144 pub limit: Option<usize>,
1146}
1147
1148impl Default for EventRegistry {
1149 fn default() -> Self {
1150 Self::new()
1151 }
1152}
1153
1154#[async_trait]
1155impl EventEmitter for EventRegistry {
1156 async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)> {
1157 self.emit(&event).await
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164 use std::sync::atomic::{AtomicUsize, Ordering};
1165
1166 fn sample_timestamp() -> DateTime<Utc> {
1167 DateTime::parse_from_rfc3339("2026-03-13T12:34:56Z")
1168 .expect("valid timestamp fixture")
1169 .with_timezone(&Utc)
1170 }
1171
1172 fn sample_workflow() -> WorkflowContext {
1173 WorkflowContext::new("workflow-123")
1174 .expect("workflow id should be valid")
1175 .with_request_id("request-456")
1176 .with_actor("analyst@example.com")
1177 .with_surface("web")
1178 }
1179
1180 struct CountingSink {
1181 event_count: AtomicUsize,
1182 flush_count: AtomicUsize,
1183 }
1184
1185 impl CountingSink {
1186 fn new() -> Self {
1187 Self { event_count: AtomicUsize::new(0), flush_count: AtomicUsize::new(0) }
1188 }
1189
1190 fn events_received(&self) -> usize {
1191 self.event_count.load(Ordering::SeqCst)
1192 }
1193
1194 fn flushes(&self) -> usize {
1195 self.flush_count.load(Ordering::SeqCst)
1196 }
1197 }
1198
1199 #[async_trait]
1200 impl EventSink for CountingSink {
1201 fn name(&self) -> &'static str {
1202 "counting"
1203 }
1204
1205 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1206 self.event_count.fetch_add(1, Ordering::SeqCst);
1207 Ok(())
1208 }
1209
1210 async fn flush(&self) -> Result<(), EventSinkError> {
1211 self.flush_count.fetch_add(1, Ordering::SeqCst);
1212 Ok(())
1213 }
1214 }
1215
1216 struct FailingSink;
1217
1218 #[async_trait]
1219 impl EventSink for FailingSink {
1220 fn name(&self) -> &'static str {
1221 "failing"
1222 }
1223
1224 async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1225 Err(EventSinkError::SinkError("intentional failure".to_string()))
1226 }
1227
1228 async fn flush(&self) -> Result<(), EventSinkError> {
1229 Err(EventSinkError::SinkError("intentional failure".to_string()))
1230 }
1231 }
1232
1233 fn sample_event() -> RazeriEvent {
1234 RazeriEvent::new_with_workflow(
1235 "event-123",
1236 sample_timestamp(),
1237 sample_workflow(),
1238 EventSubject::source("test.csv"),
1239 EventPayload::ProfilingCompleted {
1240 source: "test.csv".to_string(),
1241 fields: 10,
1242 rows: 1000,
1243 duration_ms: 42,
1244 },
1245 )
1246 .expect("workflow-scoped sample event should be valid")
1247 }
1248
1249 fn plugin_event() -> RazeriEvent {
1250 RazeriEvent::new(
1251 "event-plugin-1",
1252 sample_timestamp(),
1253 EventSubject::plugin("builtin-logger"),
1254 EventPayload::PluginLoaded {
1255 name: "builtin-logger".to_string(),
1256 plugin_type: "event_sink".to_string(),
1257 source: "builtin".to_string(),
1258 },
1259 )
1260 .expect("plugin events are not workflow scoped")
1261 }
1262
1263 #[tokio::test]
1264 async fn event_dispatched_to_all_sinks() {
1265 let sink1 = Arc::new(CountingSink::new());
1266 let sink2 = Arc::new(CountingSink::new());
1267
1268 let mut registry = EventRegistry::new();
1269 registry.register(sink1.clone());
1270 registry.register(sink2.clone());
1271
1272 let errors = registry.emit(&sample_event()).await;
1273 assert!(errors.is_empty());
1274 assert_eq!(sink1.events_received(), 1);
1275 assert_eq!(sink2.events_received(), 1);
1276 }
1277
1278 #[tokio::test]
1279 async fn sink_failure_does_not_halt_dispatch() {
1280 let good_sink = Arc::new(CountingSink::new());
1281 let bad_sink: Arc<dyn EventSink> = Arc::new(FailingSink);
1282
1283 let mut registry = EventRegistry::new();
1284 registry.register(bad_sink);
1285 registry.register(good_sink.clone());
1286
1287 let errors = registry.emit(&sample_event()).await;
1288 assert_eq!(errors.len(), 1);
1290 assert_eq!(errors[0].0, "failing");
1291 assert_eq!(good_sink.events_received(), 1);
1292 }
1293
1294 #[tokio::test]
1295 async fn flush_all_flushes_every_sink() {
1296 let sink1 = Arc::new(CountingSink::new());
1297 let sink2 = Arc::new(CountingSink::new());
1298
1299 let mut registry = EventRegistry::new();
1300 registry.register(sink1.clone());
1301 registry.register(sink2.clone());
1302
1303 let errors = registry.flush_all().await;
1304 assert!(errors.is_empty());
1305 assert_eq!(sink1.flushes(), 1);
1306 assert_eq!(sink2.flushes(), 1);
1307 }
1308
1309 #[tokio::test]
1310 async fn emitted_events_are_recorded_and_queryable() {
1311 let registry = EventRegistry::new();
1312 registry.emit_event(sample_event()).await;
1313 registry.emit_event(plugin_event()).await;
1314
1315 let recorded = registry.events();
1316 assert_eq!(recorded.len(), 2);
1317
1318 let workflow_events = registry.events_for_workflow("workflow-123");
1319 assert_eq!(workflow_events.len(), 1);
1320 assert_eq!(workflow_events[0].category(), EventCategory::Profiling);
1321
1322 let summaries = registry.recent_summaries(2);
1323 assert_eq!(summaries.len(), 2);
1324 assert_eq!(summaries[0].title, "Profiling completed");
1325 assert_eq!(summaries[1].title, "Plugin loaded");
1326 }
1327
1328 #[test]
1329 fn materialization_failure_has_error_severity_and_summary() {
1330 let event = RazeriEvent::materialization_failed(
1331 "event-materialization-failed",
1332 sample_timestamp(),
1333 sample_workflow(),
1334 "sales_design",
1335 "duckdb",
1336 "dialect mismatch",
1337 )
1338 .expect("materialization failure event should be valid");
1339
1340 assert_eq!(event.category(), EventCategory::Materialization);
1341 assert_eq!(event.severity(), EventSeverity::Error);
1342 assert!(event.summary().detail.contains("dialect mismatch"));
1343 }
1344
1345 #[test]
1346 fn event_serializes_to_json() {
1347 let event = sample_event();
1348 let json = serde_json::to_string(&event);
1349 assert!(json.is_ok());
1350 let json_str = json.unwrap();
1351 assert!(json_str.contains("profiling_completed"));
1352 assert!(json_str.contains("test.csv"));
1353 assert!(json_str.contains("workflow-123"));
1354 }
1355
1356 #[test]
1357 fn workflow_scoped_event_requires_workflow_context() {
1358 let event = RazeriEvent::new(
1359 "event-missing-workflow",
1360 sample_timestamp(),
1361 EventSubject::source("missing.csv"),
1362 EventPayload::ProfilingStarted { source: "missing.csv".to_string() },
1363 );
1364
1365 assert_eq!(
1366 event,
1367 Err(EventValidationError::WorkflowContextRequired {
1368 payload: "profiling_started".to_string(),
1369 })
1370 );
1371 }
1372
1373 #[test]
1374 fn event_round_trips_through_json_without_losing_meaning() {
1375 let event = sample_event();
1376 let json = serde_json::to_string(&event).expect("event should serialize");
1377 let round_trip: RazeriEvent =
1378 serde_json::from_str(&json).expect("event should deserialize");
1379
1380 assert_eq!(event, round_trip);
1381 assert_eq!(round_trip.validate(), Ok(()));
1382 }
1383
1384 #[test]
1385 fn summaries_and_filter_keys_are_derived_from_canonical_event() {
1386 let event = sample_event();
1387
1388 let keys = event.filter_keys();
1389 assert_eq!(keys.category, EventCategory::Profiling);
1390 assert_eq!(keys.severity, EventSeverity::Info);
1391 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1392 assert_eq!(keys.subject_kind, EventSubjectKind::Source);
1393 assert_eq!(keys.subject_name, "test.csv");
1394 assert_eq!(keys.actor.as_deref(), Some("analyst@example.com"));
1395 assert_eq!(keys.surface.as_deref(), Some("web"));
1396
1397 let summary = event.summary();
1398 assert_eq!(summary.title, "Profiling completed");
1399 assert!(summary.detail.contains("10 fields"));
1400 assert!(summary.detail.contains("1000 rows"));
1401 }
1402
1403 #[test]
1404 fn non_workflow_scoped_events_can_be_created_without_workflow_context() {
1405 let event = plugin_event();
1406
1407 assert_eq!(event.category(), EventCategory::Plugin);
1408 assert_eq!(event.severity(), EventSeverity::Info);
1409 assert!(event.workflow().is_none());
1410 }
1411
1412 #[test]
1413 fn profiling_started_constructs_with_source_subject() {
1414 let event = RazeriEvent::profiling_started(
1415 "ps-1",
1416 sample_timestamp(),
1417 sample_workflow(),
1418 "customers.csv",
1419 )
1420 .unwrap();
1421 assert_eq!(event.category(), EventCategory::Profiling);
1422 assert_eq!(event.subject().kind, EventSubjectKind::Source);
1423 assert_eq!(event.summary().title, "Profiling started");
1424 }
1425
1426 #[test]
1427 fn profiling_completed_constructs_with_stats() {
1428 let event = RazeriEvent::profiling_completed(
1429 "pc-1",
1430 sample_timestamp(),
1431 sample_workflow(),
1432 "customers.csv",
1433 10,
1434 5000,
1435 123,
1436 )
1437 .unwrap();
1438 assert_eq!(event.category(), EventCategory::Profiling);
1439 assert!(event.summary().detail.contains("10 fields"));
1440 assert!(event.summary().detail.contains("5000 rows"));
1441 }
1442
1443 #[test]
1444 fn design_loaded_constructs_without_workflow() {
1445 let event =
1446 RazeriEvent::design_loaded("dl-1", sample_timestamp(), "designs/retail.yaml", 3)
1447 .unwrap();
1448 assert_eq!(event.category(), EventCategory::Design);
1449 assert!(event.workflow().is_none());
1450 assert!(event.summary().detail.contains("3 entities"));
1451 }
1452
1453 #[test]
1454 fn plugin_loaded_constructs_without_workflow() {
1455 let event = RazeriEvent::plugin_loaded(
1456 "pl-1",
1457 sample_timestamp(),
1458 "PostgresDialect",
1459 "dialect",
1460 "inventory",
1461 )
1462 .unwrap();
1463 assert_eq!(event.category(), EventCategory::Plugin);
1464 assert_eq!(event.subject().kind, EventSubjectKind::Plugin);
1465 assert!(event.summary().detail.contains("dialect"));
1466 }
1467
1468 #[test]
1469 fn storage_operation_completed_constructs_with_workflow() {
1470 let event = RazeriEvent::storage_operation_completed(
1471 "so-1",
1472 sample_timestamp(),
1473 sample_workflow(),
1474 "move",
1475 "/data/file.csv",
1476 1024,
1477 50,
1478 )
1479 .unwrap();
1480 assert_eq!(event.category(), EventCategory::Storage);
1481 assert_eq!(event.subject().kind, EventSubjectKind::StoragePath);
1482 }
1483
1484 #[test]
1485 fn llm_request_completed_constructs_with_workflow() {
1486 let event = RazeriEvent::llm_request_completed(
1487 "lr-1",
1488 sample_timestamp(),
1489 sample_workflow(),
1490 "ollama",
1491 "llama3",
1492 500,
1493 3000,
1494 )
1495 .unwrap();
1496 assert_eq!(event.category(), EventCategory::Ai);
1497 assert_eq!(event.subject().kind, EventSubjectKind::LlmModel);
1498 assert!(event.summary().detail.contains("500 tokens"));
1499 }
1500
1501 #[test]
1502 fn query_returns_all_events_when_empty() {
1503 let registry = EventRegistry::new();
1504 registry.record(sample_event());
1505 registry.record(plugin_event());
1506
1507 let results = registry.query(&EventQuery::default());
1508 assert_eq!(results.len(), 2);
1509 }
1510
1511 #[test]
1512 fn query_filters_by_category() {
1513 let registry = EventRegistry::new();
1514 registry.record(sample_event());
1515 registry.record(plugin_event());
1516
1517 let results = registry
1518 .query(&EventQuery { category: Some(EventCategory::Plugin), ..Default::default() });
1519 assert_eq!(results.len(), 1);
1520 assert_eq!(results[0].category(), EventCategory::Plugin);
1521 }
1522
1523 #[test]
1524 fn query_filters_by_min_severity() {
1525 let registry = EventRegistry::new();
1526 registry.record(sample_event()); let error_event = RazeriEvent::materialization_failed(
1528 "err-1",
1529 sample_timestamp(),
1530 sample_workflow(),
1531 "design-a",
1532 "PostgreSQL",
1533 "connection error",
1534 )
1535 .unwrap();
1536 registry.record(error_event);
1537
1538 let results = registry
1539 .query(&EventQuery { min_severity: Some(EventSeverity::Error), ..Default::default() });
1540 assert_eq!(results.len(), 1);
1541 assert_eq!(results[0].severity(), EventSeverity::Error);
1542 }
1543
1544 #[test]
1545 fn query_filters_by_workflow_id() {
1546 let registry = EventRegistry::new();
1547 registry.record(sample_event()); registry.record(plugin_event()); let results = registry.query(&EventQuery {
1551 workflow_id: Some("workflow-123".to_string()),
1552 ..Default::default()
1553 });
1554 assert_eq!(results.len(), 1);
1555 }
1556
1557 #[test]
1558 fn query_respects_limit() {
1559 let registry = EventRegistry::new();
1560 registry.record(sample_event());
1561 registry.record(plugin_event());
1562
1563 let results = registry.query(&EventQuery { limit: Some(1), ..Default::default() });
1564 assert_eq!(results.len(), 1);
1565 }
1566
1567 #[test]
1570 fn snapshot_profiling_completed_json_shape() {
1571 let event = sample_event();
1572 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1573 let obj = val.as_object().unwrap();
1574
1575 assert!(obj.contains_key("event_id"));
1577 assert!(obj.contains_key("timestamp"));
1578 assert!(obj.contains_key("category"));
1579 assert!(obj.contains_key("severity"));
1580 assert!(obj.contains_key("workflow"));
1581 assert!(obj.contains_key("subject"));
1582 assert!(obj.contains_key("payload"));
1583
1584 let payload = obj["payload"].as_object().unwrap();
1586 assert_eq!(payload["kind"], "profiling_completed");
1587 assert!(payload.contains_key("source"));
1588 assert!(payload.contains_key("fields"));
1589 assert!(payload.contains_key("rows"));
1590 assert!(payload.contains_key("duration_ms"));
1591
1592 let subject = obj["subject"].as_object().unwrap();
1594 assert_eq!(subject["kind"], "source");
1595 assert!(subject.contains_key("name"));
1596 }
1597
1598 #[test]
1599 fn snapshot_event_round_trip_preserves_identity() {
1600 let event = sample_event();
1601 let json = serde_json::to_string(&event).unwrap();
1602 let roundtrip: RazeriEvent = serde_json::from_str(&json).unwrap();
1603 assert_eq!(roundtrip.event_id(), event.event_id());
1604 assert_eq!(roundtrip.category(), event.category());
1605 assert_eq!(roundtrip.severity(), event.severity());
1606 assert_eq!(roundtrip.subject().name, event.subject().name);
1607 }
1608
1609 #[test]
1610 fn snapshot_materialization_failed_json_shape() {
1611 let event = RazeriEvent::materialization_failed(
1612 "snap-mat-fail",
1613 sample_timestamp(),
1614 sample_workflow(),
1615 "sales_design",
1616 "duckdb",
1617 "type mismatch",
1618 )
1619 .unwrap();
1620 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1621 let payload = val["payload"].as_object().unwrap();
1622 assert_eq!(payload["kind"], "materialization_failed");
1623 assert_eq!(payload["design"], "sales_design");
1624 assert_eq!(payload["platform"], "duckdb");
1625 assert_eq!(payload["message"], "type mismatch");
1626 }
1627
1628 #[test]
1629 fn snapshot_plugin_loaded_json_shape() {
1630 let event = plugin_event();
1631 let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1632 let payload = val["payload"].as_object().unwrap();
1633 assert_eq!(payload["kind"], "plugin_loaded");
1634 assert!(payload.contains_key("name"));
1635 assert!(payload.contains_key("plugin_type"));
1636 assert!(payload.contains_key("source"));
1637 }
1638
1639 #[test]
1640 fn snapshot_event_summary_format_is_stable() {
1641 let event = sample_event();
1642 let summary = event.summary();
1643 assert_eq!(summary.title, "Profiling completed");
1644 assert!(summary.detail.contains("test.csv"));
1645 assert!(summary.detail.contains("10"));
1646 assert!(summary.detail.contains("100"));
1647 }
1648
1649 #[test]
1650 fn snapshot_filter_keys_are_stable() {
1651 let event = sample_event();
1652 let keys = event.filter_keys();
1653 assert_eq!(keys.category, EventCategory::Profiling);
1654 assert_eq!(keys.severity, EventSeverity::Info);
1655 assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1656 assert_eq!(keys.subject_name, "test.csv");
1657 }
1658}