Skip to main content

razeri_plugin_api/
event.rs

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