Skip to main content

razeri_plugin_api/
event.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3#![allow(clippy::doc_markdown)]
4
5//! Structured event sink trait and fan-out registry.
6//!
7//! [`EventSink`] is the extension point for custom observability
8//! backends (Kafka, Datadog, Prometheus, etc.).
9//! [`EventRegistry`] dispatches events to all registered sinks,
10//! with error resilience (one failing sink does not halt dispatch).
11
12use 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// ── Event types ────────────────────────────────────────────────────────
20
21/// Stable monitoring event envelope emitted by Razeri operations.
22#[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/// High-level event category used for filtering and aggregation.
35#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37#[non_exhaustive]
38pub enum EventCategory {
39    /// Data profiling events.
40    Profiling,
41    /// SQL materialization events.
42    Materialization,
43    /// Design loading and validation events.
44    Design,
45    /// Plugin loading and registration events.
46    Plugin,
47    /// Storage I/O events.
48    Storage,
49    /// AI and LLM request events.
50    Ai,
51}
52
53/// Operator-facing severity level for a monitoring event.
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum EventSeverity {
58    /// Informational event — no operator action required.
59    Info,
60    /// Something unexpected occurred but processing continued.
61    Warning,
62    /// An operation failed; operator attention is needed.
63    Error,
64    /// A severe failure requiring immediate attention.
65    Critical,
66}
67
68/// Correlation context for workflow-scoped operations.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct WorkflowContext {
72    /// Stable workflow identifier shared across related events.
73    pub workflow_id: String,
74    /// Optional request identifier from an API or RPC boundary.
75    pub request_id: Option<String>,
76    /// Optional actor name such as a user, service account, or agent.
77    pub actor: Option<String>,
78    /// Optional originating surface such as `cli`, `api`, `web`, or `mcp`.
79    pub surface: Option<String>,
80}
81
82impl WorkflowContext {
83    /// Create a workflow context with the required workflow identifier.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the operation fails.
88    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    /// Attach a request identifier.
98    #[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    /// Attach an actor label.
105    #[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    /// Attach an originating surface label.
112    #[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/// Primary subject for filtering and grouping events.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[non_exhaustive]
122pub struct EventSubject {
123    /// Discriminator classifying the type of subject.
124    pub kind: EventSubjectKind,
125    /// Subject name (source name, entity name, plugin name, etc.).
126    pub name: String,
127}
128
129impl EventSubject {
130    /// Create a new event subject.
131    pub fn new(kind: EventSubjectKind, name: impl Into<String>) -> Self {
132        Self { kind, name: name.into() }
133    }
134
135    /// Subject representing a source dataset or file.
136    pub fn source(name: impl Into<String>) -> Self {
137        Self::new(EventSubjectKind::Source, name)
138    }
139
140    /// Subject representing an entity.
141    pub fn entity(name: impl Into<String>) -> Self {
142        Self::new(EventSubjectKind::Entity, name)
143    }
144
145    /// Subject representing a design.
146    pub fn design(name: impl Into<String>) -> Self {
147        Self::new(EventSubjectKind::Design, name)
148    }
149
150    /// Subject representing a plugin.
151    pub fn plugin(name: impl Into<String>) -> Self {
152        Self::new(EventSubjectKind::Plugin, name)
153    }
154
155    /// Subject representing a storage path or backend object.
156    pub fn storage(name: impl Into<String>) -> Self {
157        Self::new(EventSubjectKind::StoragePath, name)
158    }
159
160    /// Subject representing an LLM request target.
161    pub fn llm_model(name: impl Into<String>) -> Self {
162        Self::new(EventSubjectKind::LlmModel, name)
163    }
164}
165
166/// Subject kind used for filtering and aggregation.
167#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "snake_case")]
169#[non_exhaustive]
170pub enum EventSubjectKind {
171    /// A raw data source.
172    Source,
173    /// A design entity.
174    Entity,
175    /// A design document.
176    Design,
177    /// A plugin.
178    Plugin,
179    /// A storage path or backend object.
180    StoragePath,
181    /// An LLM model target.
182    LlmModel,
183}
184
185/// Structured payload variants carried by the event envelope.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[serde(tag = "kind", rename_all = "snake_case")]
188#[non_exhaustive]
189pub enum EventPayload {
190    /// Data profiling started on a source.
191    ProfilingStarted {
192        /// Name of the data source being profiled.
193        source: String,
194    },
195    /// Data profiling completed.
196    ProfilingCompleted {
197        /// Name of the data source that was profiled.
198        source: String,
199        /// Number of fields discovered.
200        fields: usize,
201        /// Number of rows sampled.
202        rows: usize,
203        /// Duration of the profiling run in milliseconds.
204        duration_ms: u64,
205    },
206    /// SQL materialization started for an entity.
207    MaterializationStarted {
208        /// Name of the entity being materialized.
209        entity: String,
210        /// Target SQL platform name.
211        platform: String,
212    },
213    /// SQL materialization completed.
214    MaterializationCompleted {
215        /// Name of the entity that was materialized.
216        entity: String,
217        /// Target SQL platform name.
218        platform: String,
219        /// Number of SQL artifacts produced.
220        artifacts: usize,
221        /// Duration of the materialization run in milliseconds.
222        duration_ms: u64,
223    },
224    /// SQL materialization failed.
225    MaterializationFailed {
226        /// Name of the design that failed.
227        design: String,
228        /// Target SQL platform name.
229        platform: String,
230        /// Human-readable failure description.
231        message: String,
232    },
233    /// Design validated with warning and error counts.
234    DesignValidated {
235        /// Name of the validated design.
236        design: String,
237        /// Number of validation errors.
238        errors: usize,
239        /// Number of validation warnings.
240        warnings: usize,
241    },
242    /// Design document loaded.
243    DesignLoaded {
244        /// File path or URI from which the design was loaded.
245        path: String,
246        /// Number of entities in the design.
247        entities: usize,
248    },
249    /// A plugin was loaded.
250    PluginLoaded {
251        /// Plugin name.
252        name: String,
253        /// Plugin extension point type (e.g. `"dialect"`).
254        plugin_type: String,
255        /// Source description (e.g. `"static"` or a library path).
256        source: String,
257    },
258    /// Storage I/O operation completed.
259    StorageOperationCompleted {
260        /// Operation name (e.g. `"put"`, `"get"`, `"delete"`).
261        operation: String,
262        /// Storage path of the object.
263        path: String,
264        /// Number of bytes transferred.
265        bytes: u64,
266        /// Duration of the operation in milliseconds.
267        duration_ms: u64,
268    },
269    /// Materializer detected a storage feature that the target platform cannot honor.
270    /// Emitted instead of panicking (TP-010).
271    StorageWarning {
272        /// Name of the entity that triggered the warning.
273        entity: String,
274        /// Storage feature that is unsupported.
275        feature: String,
276        /// Target SQL platform name.
277        platform: String,
278        /// Suggested alternative or workaround.
279        recommendation: String,
280    },
281    /// LLM request completed.
282    LlmRequestCompleted {
283        /// LLM provider name.
284        provider: String,
285        /// Model identifier used for the request.
286        model: String,
287        /// Total tokens consumed.
288        tokens: u64,
289        /// Duration of the request in milliseconds.
290        duration_ms: u64,
291    },
292}
293
294impl EventPayload {
295    /// Category derived from the payload variant.
296    #[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    /// Severity derived from the payload variant.
316    #[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    /// Whether this payload must be correlated to a workflow.
328    #[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
396/// Build summary text for a profiling-started payload.
397/// Parameters:
398/// - source: Source name being profiled.
399fn 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
406/// Build summary text for a profiling-completed payload.
407/// Parameters:
408/// - source: Source name that was profiled.
409/// - fields: Number of discovered fields.
410/// - rows: Number of sampled rows.
411/// - duration_ms: Profiling runtime in milliseconds.
412fn 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
426/// Build summary text for a materialization-started payload.
427/// Parameters:
428/// - entity: Entity being materialized.
429/// - platform: Target SQL platform.
430fn 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
437/// Build summary text for a materialization-completed payload.
438/// Parameters:
439/// - entity: Entity that finished materialization.
440/// - platform: Target SQL platform.
441/// - artifacts: Number of generated artifacts.
442/// - duration_ms: Materialization runtime in milliseconds.
443fn 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
457/// Build summary text for a materialization-failed payload.
458/// Parameters:
459/// - design: Design that failed.
460/// - platform: Target SQL platform.
461/// - message: Failure reason.
462fn 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
469/// Build summary text for a design-validated payload.
470/// Parameters:
471/// - design: Design name.
472/// - errors: Validation error count.
473/// - warnings: Validation warning count.
474fn 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
481/// Build summary text for a design-loaded payload.
482/// Parameters:
483/// - path: Loaded path or URI.
484/// - entities: Number of entities in the loaded design.
485fn 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
492/// Build summary text for a plugin-loaded payload.
493/// Parameters:
494/// - name: Plugin name.
495/// - plugin_type: Plugin extension-point type.
496/// - source: Plugin load source descriptor.
497fn 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
504/// Build summary text for a storage-operation-completed payload.
505/// Parameters:
506/// - operation: Storage operation name.
507/// - path: Object path processed by the operation.
508/// - bytes: Number of processed bytes.
509/// - duration_ms: Operation runtime in milliseconds.
510fn 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
524/// Build summary text for a storage-warning payload.
525/// Parameters:
526/// - entity: Entity associated with the warning.
527/// - feature: Unsupported storage feature.
528/// - platform: Target SQL platform.
529/// - recommendation: Suggested workaround.
530fn 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
544/// Build summary text for an LLM-request-completed payload.
545/// Parameters:
546/// - provider: LLM provider name.
547/// - model: Model identifier.
548/// - tokens: Total consumed tokens.
549/// - duration_ms: Request runtime in milliseconds.
550fn 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/// Filter keys that can be derived from a canonical event alone.
565#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
566#[non_exhaustive]
567pub struct EventFilterKeys {
568    /// Event category derived from the payload.
569    pub category: EventCategory,
570    /// Event severity derived from the payload.
571    pub severity: EventSeverity,
572    /// Workflow identifier if the event was workflow-scoped.
573    pub workflow_id: Option<String>,
574    /// Kind of the primary event subject.
575    pub subject_kind: EventSubjectKind,
576    /// Name of the primary event subject.
577    pub subject_name: String,
578    /// Actor label from the workflow context.
579    pub actor: Option<String>,
580    /// Originating surface label from the workflow context.
581    pub surface: Option<String>,
582}
583
584/// Human-readable summary derived from a canonical event.
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
586#[non_exhaustive]
587pub struct EventSummary {
588    /// Short summary title.
589    pub title: String,
590    /// Longer human-readable detail string.
591    pub detail: String,
592}
593
594/// Validation error for canonical monitoring events.
595#[derive(Debug, Error, Clone, PartialEq, Eq)]
596#[non_exhaustive]
597pub enum EventValidationError {
598    /// A workflow-scoped payload was submitted without a workflow context.
599    #[error("workflow-scoped event payload '{payload}' requires workflow context")]
600    WorkflowContextRequired {
601        /// Name of the payload type that requires workflow context.
602        payload: String,
603    },
604    /// The workflow context was supplied with an empty `workflow_id`.
605    #[error("workflow context requires a non-empty workflow_id")]
606    WorkflowIdRequired,
607}
608
609impl RazeriEvent {
610    /// Create a new non-workflow-scoped event.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if the operation fails.
615    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    /// Create a new workflow-scoped event.
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if the operation fails.
629    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    /// Validate that the canonical envelope is internally consistent.
661    ///
662    /// # Errors
663    ///
664    /// Returns an error if the operation fails.
665    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    /// Stable event identifier.
680    #[must_use]
681    pub fn event_id(&self) -> &str {
682        &self.event_id
683    }
684
685    /// Event timestamp.
686    #[must_use]
687    pub fn timestamp(&self) -> DateTime<Utc> {
688        self.timestamp
689    }
690
691    /// Derived event category.
692    #[must_use]
693    pub fn category(&self) -> EventCategory {
694        self.category
695    }
696
697    /// Derived event severity.
698    #[must_use]
699    pub fn severity(&self) -> EventSeverity {
700        self.severity
701    }
702
703    /// Optional workflow correlation context.
704    #[must_use]
705    pub fn workflow(&self) -> Option<&WorkflowContext> {
706        self.workflow.as_ref()
707    }
708
709    /// Primary event subject.
710    #[must_use]
711    pub fn subject(&self) -> &EventSubject {
712        &self.subject
713    }
714
715    /// Structured event payload.
716    #[must_use]
717    pub fn payload(&self) -> &EventPayload {
718        &self.payload
719    }
720
721    /// Derived filter keys suitable for summaries and operator UIs.
722    #[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    /// Human-readable summary derived from the canonical event.
736    #[must_use]
737    pub fn summary(&self) -> EventSummary {
738        self.payload.summary()
739    }
740
741    /// Convenience constructor for a design validation event.
742    ///
743    /// # Errors
744    ///
745    /// Returns an error if the operation fails.
746    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    /// Convenience constructor for a materialization-started event.
765    ///
766    /// # Errors
767    ///
768    /// Returns an error if the operation fails.
769    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    /// Convenience constructor for a materialization-completed event.
788    ///
789    /// # Errors
790    ///
791    /// Returns an error if the operation fails.
792    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    /// Convenience constructor for a materialization-failed event.
813    ///
814    /// # Errors
815    ///
816    /// Returns an error if the operation fails.
817    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    /// Convenience constructor for a profiling-started event.
838    ///
839    /// # Errors
840    ///
841    /// Returns an error if the operation fails.
842    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    /// Convenience constructor for a profiling-completed event.
859    ///
860    /// # Errors
861    ///
862    /// Returns an error if the operation fails.
863    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    /// Convenience constructor for a design-loaded event.
883    ///
884    /// # Errors
885    ///
886    /// Returns an error if the operation fails.
887    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    /// Convenience constructor for a plugin-loaded event.
903    ///
904    /// # Errors
905    ///
906    /// Returns an error if the operation fails.
907    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    /// Convenience constructor for a storage-operation-completed event.
926    ///
927    /// # Errors
928    ///
929    /// Returns an error if the operation fails.
930    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    /// Convenience constructor for an LLM-request-completed event.
951    ///
952    /// # Errors
953    ///
954    /// Returns an error if the operation fails.
955    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/// Shared interface for components that emit canonical monitoring events.
977#[async_trait]
978pub trait EventEmitter: Send + Sync {
979    /// Emit a canonical event into the monitoring pipeline.
980    async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)>;
981}
982
983// ── EventSink trait ────────────────────────────────────────────────────
984
985/// Sink that receives structured events from Razeri operations.
986///
987/// Implementations should be non-blocking and resilient to transient
988/// failures (e.g., network issues). Razeri will continue operating
989/// even if an event sink fails.
990#[async_trait]
991pub trait EventSink: Send + Sync {
992    /// Machine-readable sink name.
993    fn name(&self) -> &str;
994
995    /// Handle an event. Should not block or panic.
996    ///
997    /// # Errors
998    ///
999    /// Returns an error if the operation fails.
1000    async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError>;
1001
1002    /// Flush any buffered events.
1003    ///
1004    /// # Errors
1005    ///
1006    /// Returns an error if the operation fails.
1007    async fn flush(&self) -> Result<(), EventSinkError>;
1008}
1009
1010// ── EventRegistry ──────────────────────────────────────────────────────
1011
1012/// Registry that dispatches events to all registered sinks.
1013///
1014/// Errors from individual sinks are logged but do **not** halt the
1015/// pipeline — this is the "fire and continue" pattern.
1016pub struct EventRegistry {
1017    sinks: Vec<Arc<dyn EventSink>>,
1018    history: RwLock<Vec<RazeriEvent>>,
1019}
1020
1021impl EventRegistry {
1022    /// Create an empty registry.
1023    #[must_use]
1024    pub fn new() -> Self {
1025        Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1026    }
1027
1028    /// Register an event sink.
1029    pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1030        self.sinks.push(sink);
1031    }
1032
1033    /// Dispatch an event to all sinks.
1034    ///
1035    /// Errors from individual sinks are collected but do not propagate —
1036    /// all sinks are attempted regardless of failures.
1037    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    /// Record an event without dispatching it to sinks.
1049    pub fn record(&self, event: RazeriEvent) {
1050        self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1051    }
1052
1053    /// Return all recorded events in chronological order.
1054    pub fn events(&self) -> Vec<RazeriEvent> {
1055        self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1056    }
1057
1058    /// Return the most recent events in chronological order.
1059    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    /// Return events associated with the provided workflow identifier.
1066    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    /// Return human-readable summaries for the most recent events.
1079    pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1080        self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1081    }
1082
1083    /// Flush all sinks.
1084    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    /// Number of registered sinks.
1095    pub fn len(&self) -> usize {
1096        self.sinks.len()
1097    }
1098
1099    /// Whether the registry has no sinks.
1100    pub fn is_empty(&self) -> bool {
1101        self.sinks.is_empty()
1102    }
1103
1104    /// Execute a flexible query against the recorded event history.
1105    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/// Flexible event query for filtering and pagination.
1125///
1126/// All fields are optional. When `None`, no filtering is applied for that
1127/// dimension. Combine fields to narrow results (they are `ANDed` together).
1128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1129pub struct EventQuery {
1130    /// Filter by event category.
1131    pub category: Option<EventCategory>,
1132    /// Filter to events at or above this severity.
1133    pub min_severity: Option<EventSeverity>,
1134    /// Filter by workflow identifier.
1135    pub workflow_id: Option<String>,
1136    /// Filter by subject name.
1137    pub subject_name: Option<String>,
1138    /// Filter by subject kind.
1139    pub subject_kind: Option<EventSubjectKind>,
1140    /// Only include events at or after this timestamp.
1141    pub since: Option<DateTime<Utc>>,
1142    /// Maximum number of events to return.
1143    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        // Failing sink produced an error, but good sink still received the event
1287        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()); // Info
1525        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()); // workflow-123
1546        registry.record(plugin_event()); // no workflow
1547
1548        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    // ── Snapshot tests ──────────────────────────────────────────────────
1566
1567    #[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        // Envelope fields
1574        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        // Payload is internally tagged with "kind"
1583        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        // Subject has kind+name
1591        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}