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    /// A file the agent found was moved to where a routing rule sends it.
295    FileRouted {
296        /// Where the file was found.
297        path: String,
298        /// Where it was moved to.
299        destination: String,
300        /// The routing rule that sent it there.
301        rule: String,
302        /// Its size in bytes.
303        bytes: u64,
304    },
305    /// A file the agent found was set aside: no rule routes it, or it failed
306    /// a check. A warning, so a person looks at it.
307    FileSetAside {
308        /// Where the file was found.
309        path: String,
310        /// Where it was set aside.
311        destination: String,
312        /// Why, in a reader's words.
313        reason: String,
314    },
315    /// A file the agent found could not be moved at all, and stays where it
316    /// was. An error.
317    FileNotMoved {
318        /// Where the file is.
319        path: String,
320        /// What stopped the move, in a reader's words.
321        reason: String,
322    },
323    /// The agent finished one pass over a landing location.
324    ScanCompleted {
325        /// The location scanned.
326        location: String,
327        /// Files found.
328        files: usize,
329        /// Of those, routed.
330        routed: usize,
331        /// Of those, set aside.
332        set_aside: usize,
333        /// Of those, not moved.
334        not_moved: usize,
335        /// How long the pass took, in milliseconds.
336        duration_ms: u64,
337    },
338}
339
340impl EventPayload {
341    /// Category derived from the payload variant.
342    #[must_use]
343    pub fn category(&self) -> EventCategory {
344        match self {
345            Self::ProfilingStarted { .. } | Self::ProfilingCompleted { .. } => {
346                EventCategory::Profiling
347            }
348            Self::MaterializationStarted { .. } | Self::MaterializationCompleted { .. } => {
349                EventCategory::Materialization
350            }
351            Self::MaterializationFailed { .. } => EventCategory::Materialization,
352            Self::DesignValidated { .. } | Self::DesignLoaded { .. } => EventCategory::Design,
353            Self::PluginLoaded { .. } => EventCategory::Plugin,
354            Self::StorageOperationCompleted { .. } | Self::StorageWarning { .. } => {
355                EventCategory::Storage
356            }
357            Self::LlmRequestCompleted { .. } => EventCategory::Ai,
358            Self::FileRouted { .. }
359            | Self::FileSetAside { .. }
360            | Self::FileNotMoved { .. }
361            | Self::ScanCompleted { .. } => EventCategory::Storage,
362        }
363    }
364
365    /// Severity derived from the payload variant.
366    #[must_use]
367    pub fn severity(&self) -> EventSeverity {
368        match self {
369            Self::MaterializationFailed { .. } | Self::FileNotMoved { .. } => EventSeverity::Error,
370            Self::DesignValidated { errors, .. } if *errors > 0 => EventSeverity::Error,
371            Self::DesignValidated { warnings, .. } if *warnings > 0 => EventSeverity::Warning,
372            Self::StorageWarning { .. } | Self::FileSetAside { .. } => EventSeverity::Warning,
373            _ => EventSeverity::Info,
374        }
375    }
376
377    /// Whether this payload must be correlated to a workflow.
378    #[must_use]
379    pub fn requires_workflow_context(&self) -> bool {
380        match self {
381            Self::ProfilingStarted { .. }
382            | Self::ProfilingCompleted { .. }
383            | Self::MaterializationStarted { .. }
384            | Self::MaterializationCompleted { .. }
385            | Self::MaterializationFailed { .. }
386            | Self::DesignValidated { .. }
387            | Self::StorageOperationCompleted { .. }
388            | Self::LlmRequestCompleted { .. } => true,
389            Self::DesignLoaded { .. }
390            | Self::PluginLoaded { .. }
391            | Self::StorageWarning { .. }
392            | Self::FileRouted { .. }
393            | Self::FileSetAside { .. }
394            | Self::FileNotMoved { .. }
395            | Self::ScanCompleted { .. } => false,
396        }
397    }
398
399    pub(crate) fn name(&self) -> &'static str {
400        match self {
401            Self::ProfilingStarted { .. } => "profiling_started",
402            Self::ProfilingCompleted { .. } => "profiling_completed",
403            Self::MaterializationStarted { .. } => "materialization_started",
404            Self::MaterializationCompleted { .. } => "materialization_completed",
405            Self::MaterializationFailed { .. } => "materialization_failed",
406            Self::DesignValidated { .. } => "design_validated",
407            Self::DesignLoaded { .. } => "design_loaded",
408            Self::PluginLoaded { .. } => "plugin_loaded",
409            Self::StorageOperationCompleted { .. } => "storage_operation_completed",
410            Self::StorageWarning { .. } => "storage_warning",
411            Self::LlmRequestCompleted { .. } => "llm_request_completed",
412            Self::FileRouted { .. } => "file_routed",
413            Self::FileSetAside { .. } => "file_set_aside",
414            Self::FileNotMoved { .. } => "file_not_moved",
415            Self::ScanCompleted { .. } => "scan_completed",
416        }
417    }
418
419    fn summary(&self) -> EventSummary {
420        match self {
421            Self::ProfilingStarted { source } => profiling_started_summary(source),
422            Self::ProfilingCompleted { source, fields, rows, duration_ms } => {
423                profiling_completed_summary(source, *fields, *rows, *duration_ms)
424            }
425            Self::MaterializationStarted { entity, platform } => {
426                materialization_started_summary(entity, platform)
427            }
428            Self::MaterializationCompleted { entity, platform, artifacts, duration_ms } => {
429                materialization_completed_summary(entity, platform, *artifacts, *duration_ms)
430            }
431            Self::MaterializationFailed { design, platform, message } => {
432                materialization_failed_summary(design, platform, message)
433            }
434            Self::DesignValidated { design, errors, warnings } => {
435                design_validated_summary(design, *errors, *warnings)
436            }
437            Self::DesignLoaded { path, entities } => design_loaded_summary(path, *entities),
438            Self::PluginLoaded { name, plugin_type, source } => {
439                plugin_loaded_summary(name, plugin_type, source)
440            }
441            Self::StorageOperationCompleted { operation, path, bytes, duration_ms } => {
442                storage_operation_completed_summary(operation, path, *bytes, *duration_ms)
443            }
444            Self::StorageWarning { entity, feature, platform, recommendation } => {
445                storage_warning_summary(entity, feature, platform, recommendation)
446            }
447            Self::LlmRequestCompleted { provider, model, tokens, duration_ms } => {
448                llm_request_completed_summary(provider, model, *tokens, *duration_ms)
449            }
450            Self::FileRouted { path, destination, rule, bytes } => EventSummary {
451                title: "File routed".to_string(),
452                detail: format!(
453                    "'{path}' moved to '{destination}' by rule '{rule}', {}",
454                    counted(*bytes, "byte", "bytes")
455                ),
456            },
457            Self::FileSetAside { path, destination, reason } => EventSummary {
458                title: "File set aside".to_string(),
459                detail: format!("'{path}' set aside in '{destination}': {reason}"),
460            },
461            Self::FileNotMoved { path, reason } => EventSummary {
462                title: "File not moved".to_string(),
463                detail: format!("'{path}' was not moved: {reason}"),
464            },
465            Self::ScanCompleted { location, files, routed, set_aside, not_moved, duration_ms } => {
466                EventSummary {
467                    title: "Scan completed".to_string(),
468                    detail: format!(
469                        "'{location}' held {}: {routed} routed, {set_aside} set aside, \
470                         {not_moved} not moved, in {duration_ms} ms",
471                        counted(*files as u64, "file", "files")
472                    ),
473                }
474            }
475        }
476    }
477}
478
479/// **`n` and its noun**, the noun said once for one: `1 byte`, `2 bytes`.
480fn counted(n: u64, one: &str, many: &str) -> String {
481    format!("{n} {}", if n == 1 { one } else { many })
482}
483
484/// Build summary text for a profiling-started payload.
485/// Parameters:
486/// - source: Source name being profiled.
487fn profiling_started_summary(source: &str) -> EventSummary {
488    EventSummary {
489        title: "Profiling started".to_string(),
490        detail: format!("Profiling started for source '{source}'"),
491    }
492}
493
494/// Build summary text for a profiling-completed payload.
495/// Parameters:
496/// - source: Source name that was profiled.
497/// - fields: Number of discovered fields.
498/// - rows: Number of sampled rows.
499/// - duration_ms: Profiling runtime in milliseconds.
500fn profiling_completed_summary(
501    source: &str,
502    fields: usize,
503    rows: usize,
504    duration_ms: u64,
505) -> EventSummary {
506    EventSummary {
507        title: "Profiling completed".to_string(),
508        detail: format!(
509            "Source '{source}' profiled with {}, {} in {duration_ms} ms",
510            counted(fields as u64, "field", "fields"),
511            counted(rows as u64, "row", "rows"),
512        ),
513    }
514}
515
516/// Build summary text for a materialization-started payload.
517/// Parameters:
518/// - entity: Entity being materialized.
519/// - platform: Target SQL platform.
520fn materialization_started_summary(entity: &str, platform: &str) -> EventSummary {
521    EventSummary {
522        title: "Materialization started".to_string(),
523        detail: format!("Entity '{entity}' started materialization for {platform}"),
524    }
525}
526
527/// Build summary text for a materialization-completed payload.
528/// Parameters:
529/// - entity: Entity that finished materialization.
530/// - platform: Target SQL platform.
531/// - artifacts: Number of generated artifacts.
532/// - duration_ms: Materialization runtime in milliseconds.
533fn materialization_completed_summary(
534    entity: &str,
535    platform: &str,
536    artifacts: usize,
537    duration_ms: u64,
538) -> EventSummary {
539    EventSummary {
540        title: "Materialization completed".to_string(),
541        detail: format!(
542            "Entity '{entity}' produced {} for {platform} in {duration_ms} ms",
543            counted(artifacts as u64, "artifact", "artifacts"),
544        ),
545    }
546}
547
548/// Build summary text for a materialization-failed payload.
549/// Parameters:
550/// - design: Design that failed.
551/// - platform: Target SQL platform.
552/// - message: Failure reason.
553fn materialization_failed_summary(design: &str, platform: &str, message: &str) -> EventSummary {
554    EventSummary {
555        title: "Materialization failed".to_string(),
556        detail: format!("Design '{design}' failed materialization for {platform}: {message}"),
557    }
558}
559
560/// Build summary text for a design-validated payload.
561/// Parameters:
562/// - design: Design name.
563/// - errors: Validation error count.
564/// - warnings: Validation warning count.
565fn design_validated_summary(design: &str, errors: usize, warnings: usize) -> EventSummary {
566    EventSummary {
567        title: "Design validated".to_string(),
568        detail: format!(
569            "Design '{design}' validated with {} and {}",
570            counted(errors as u64, "error", "errors"),
571            counted(warnings as u64, "warning", "warnings"),
572        ),
573    }
574}
575
576/// Build summary text for a design-loaded payload.
577/// Parameters:
578/// - path: Loaded path or URI.
579/// - entities: Number of entities in the loaded design.
580fn design_loaded_summary(path: &str, entities: usize) -> EventSummary {
581    EventSummary {
582        title: "Design loaded".to_string(),
583        detail: format!(
584            "Loaded design from '{path}' with {}",
585            counted(entities as u64, "entity", "entities")
586        ),
587    }
588}
589
590/// Build summary text for a plugin-loaded payload.
591/// Parameters:
592/// - name: Plugin name.
593/// - plugin_type: Plugin extension-point type.
594/// - source: Plugin load source descriptor.
595fn plugin_loaded_summary(name: &str, plugin_type: &str, source: &str) -> EventSummary {
596    EventSummary {
597        title: "Plugin loaded".to_string(),
598        detail: format!("Loaded {plugin_type} plugin '{name}' from {source}"),
599    }
600}
601
602/// Build summary text for a storage-operation-completed payload.
603/// Parameters:
604/// - operation: Storage operation name.
605/// - path: Object path processed by the operation.
606/// - bytes: Number of processed bytes.
607/// - duration_ms: Operation runtime in milliseconds.
608fn storage_operation_completed_summary(
609    operation: &str,
610    path: &str,
611    bytes: u64,
612    duration_ms: u64,
613) -> EventSummary {
614    EventSummary {
615        title: "Storage operation completed".to_string(),
616        detail: format!(
617            "Operation '{operation}' processed {} at '{path}' in {duration_ms} ms",
618            counted(bytes, "byte", "bytes"),
619        ),
620    }
621}
622
623/// Build summary text for a storage-warning payload.
624/// Parameters:
625/// - entity: Entity associated with the warning.
626/// - feature: Unsupported storage feature.
627/// - platform: Target SQL platform.
628/// - recommendation: Suggested workaround.
629fn storage_warning_summary(
630    entity: &str,
631    feature: &str,
632    platform: &str,
633    recommendation: &str,
634) -> EventSummary {
635    EventSummary {
636        title: "Storage warning".to_string(),
637        detail: format!(
638            "Entity '{entity}': feature '{feature}' unavailable on '{platform}'; {recommendation}"
639        ),
640    }
641}
642
643/// Build summary text for an LLM-request-completed payload.
644/// Parameters:
645/// - provider: LLM provider name.
646/// - model: Model identifier.
647/// - tokens: Total consumed tokens.
648/// - duration_ms: Request runtime in milliseconds.
649fn llm_request_completed_summary(
650    provider: &str,
651    model: &str,
652    tokens: u64,
653    duration_ms: u64,
654) -> EventSummary {
655    EventSummary {
656        title: "LLM request completed".to_string(),
657        detail: format!(
658            "Provider '{provider}' model '{model}' used {} in {duration_ms} ms",
659            counted(tokens, "token", "tokens"),
660        ),
661    }
662}
663
664/// Filter keys that can be derived from a canonical event alone.
665#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
666#[non_exhaustive]
667pub struct EventFilterKeys {
668    /// Event category derived from the payload.
669    pub category: EventCategory,
670    /// Event severity derived from the payload.
671    pub severity: EventSeverity,
672    /// Workflow identifier if the event was workflow-scoped.
673    pub workflow_id: Option<String>,
674    /// Kind of the primary event subject.
675    pub subject_kind: EventSubjectKind,
676    /// Name of the primary event subject.
677    pub subject_name: String,
678    /// Actor label from the workflow context.
679    pub actor: Option<String>,
680    /// Originating surface label from the workflow context.
681    pub surface: Option<String>,
682}
683
684/// Human-readable summary derived from a canonical event.
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[non_exhaustive]
687pub struct EventSummary {
688    /// Short summary title.
689    pub title: String,
690    /// Longer human-readable detail string.
691    pub detail: String,
692}
693
694/// Validation error for canonical monitoring events.
695#[derive(Debug, Error, Clone, PartialEq, Eq)]
696#[non_exhaustive]
697pub enum EventValidationError {
698    /// A workflow-scoped payload was submitted without a workflow context.
699    #[error("workflow-scoped event payload '{payload}' requires workflow context")]
700    WorkflowContextRequired {
701        /// Name of the payload type that requires workflow context.
702        payload: String,
703    },
704    /// The workflow context was supplied with an empty `workflow_id`.
705    #[error("workflow context requires a non-empty workflow_id")]
706    WorkflowIdRequired,
707}
708
709impl RazeriEvent {
710    /// Create a new non-workflow-scoped event.
711    ///
712    /// # Errors
713    ///
714    /// Returns an error if the operation fails.
715    pub fn new(
716        event_id: impl Into<String>,
717        timestamp: DateTime<Utc>,
718        subject: EventSubject,
719        payload: EventPayload,
720    ) -> Result<Self, EventValidationError> {
721        Self::build(event_id.into(), timestamp, None, subject, payload)
722    }
723
724    /// Create a new workflow-scoped event.
725    ///
726    /// # Errors
727    ///
728    /// Returns an error if the operation fails.
729    pub fn new_with_workflow(
730        event_id: impl Into<String>,
731        timestamp: DateTime<Utc>,
732        workflow: WorkflowContext,
733        subject: EventSubject,
734        payload: EventPayload,
735    ) -> Result<Self, EventValidationError> {
736        Self::build(event_id.into(), timestamp, Some(workflow), subject, payload)
737    }
738
739    fn build(
740        event_id: String,
741        timestamp: DateTime<Utc>,
742        workflow: Option<WorkflowContext>,
743        subject: EventSubject,
744        payload: EventPayload,
745    ) -> Result<Self, EventValidationError> {
746        let event = Self {
747            event_id,
748            timestamp,
749            category: payload.category(),
750            severity: payload.severity(),
751            workflow,
752            subject,
753            payload,
754        };
755
756        event.validate()?;
757        Ok(event)
758    }
759
760    /// Validate that the canonical envelope is internally consistent.
761    ///
762    /// # Errors
763    ///
764    /// Returns an error if the operation fails.
765    pub fn validate(&self) -> Result<(), EventValidationError> {
766        if self.payload.requires_workflow_context() && self.workflow.is_none() {
767            return Err(EventValidationError::WorkflowContextRequired {
768                payload: self.payload.name().to_string(),
769            });
770        }
771
772        if self.workflow.as_ref().is_some_and(|workflow| workflow.workflow_id.trim().is_empty()) {
773            return Err(EventValidationError::WorkflowIdRequired);
774        }
775
776        Ok(())
777    }
778
779    /// Stable event identifier.
780    #[must_use]
781    pub fn event_id(&self) -> &str {
782        &self.event_id
783    }
784
785    /// Event timestamp.
786    #[must_use]
787    pub fn timestamp(&self) -> DateTime<Utc> {
788        self.timestamp
789    }
790
791    /// Derived event category.
792    #[must_use]
793    pub fn category(&self) -> EventCategory {
794        self.category
795    }
796
797    /// Derived event severity.
798    #[must_use]
799    pub fn severity(&self) -> EventSeverity {
800        self.severity
801    }
802
803    /// Optional workflow correlation context.
804    #[must_use]
805    pub fn workflow(&self) -> Option<&WorkflowContext> {
806        self.workflow.as_ref()
807    }
808
809    /// Primary event subject.
810    #[must_use]
811    pub fn subject(&self) -> &EventSubject {
812        &self.subject
813    }
814
815    /// Structured event payload.
816    #[must_use]
817    pub fn payload(&self) -> &EventPayload {
818        &self.payload
819    }
820
821    /// Derived filter keys suitable for summaries and operator UIs.
822    #[must_use]
823    pub fn filter_keys(&self) -> EventFilterKeys {
824        EventFilterKeys {
825            category: self.category,
826            severity: self.severity,
827            workflow_id: self.workflow.as_ref().map(|workflow| workflow.workflow_id.clone()),
828            subject_kind: self.subject.kind,
829            subject_name: self.subject.name.clone(),
830            actor: self.workflow.as_ref().and_then(|workflow| workflow.actor.clone()),
831            surface: self.workflow.as_ref().and_then(|workflow| workflow.surface.clone()),
832        }
833    }
834
835    /// Human-readable summary derived from the canonical event.
836    #[must_use]
837    pub fn summary(&self) -> EventSummary {
838        self.payload.summary()
839    }
840
841    /// Convenience constructor for a design validation event.
842    ///
843    /// # Errors
844    ///
845    /// Returns an error if the operation fails.
846    pub fn design_validated(
847        event_id: impl Into<String>,
848        timestamp: DateTime<Utc>,
849        workflow: WorkflowContext,
850        design: impl Into<String>,
851        errors: usize,
852        warnings: usize,
853    ) -> Result<Self, EventValidationError> {
854        let design = design.into();
855        Self::new_with_workflow(
856            event_id,
857            timestamp,
858            workflow,
859            EventSubject::design(design.clone()),
860            EventPayload::DesignValidated { design, errors, warnings },
861        )
862    }
863
864    /// Convenience constructor for a materialization-started event.
865    ///
866    /// # Errors
867    ///
868    /// Returns an error if the operation fails.
869    pub fn materialization_started(
870        event_id: impl Into<String>,
871        timestamp: DateTime<Utc>,
872        workflow: WorkflowContext,
873        entity: impl Into<String>,
874        platform: impl Into<String>,
875    ) -> Result<Self, EventValidationError> {
876        let entity = entity.into();
877        let platform = platform.into();
878        Self::new_with_workflow(
879            event_id,
880            timestamp,
881            workflow,
882            EventSubject::entity(entity.clone()),
883            EventPayload::MaterializationStarted { entity, platform },
884        )
885    }
886
887    /// Convenience constructor for a materialization-completed event.
888    ///
889    /// # Errors
890    ///
891    /// Returns an error if the operation fails.
892    pub fn materialization_completed(
893        event_id: impl Into<String>,
894        timestamp: DateTime<Utc>,
895        workflow: WorkflowContext,
896        entity: impl Into<String>,
897        platform: impl Into<String>,
898        artifacts: usize,
899        duration_ms: u64,
900    ) -> Result<Self, EventValidationError> {
901        let entity = entity.into();
902        let platform = platform.into();
903        Self::new_with_workflow(
904            event_id,
905            timestamp,
906            workflow,
907            EventSubject::entity(entity.clone()),
908            EventPayload::MaterializationCompleted { entity, platform, artifacts, duration_ms },
909        )
910    }
911
912    /// Convenience constructor for a materialization-failed event.
913    ///
914    /// # Errors
915    ///
916    /// Returns an error if the operation fails.
917    pub fn materialization_failed(
918        event_id: impl Into<String>,
919        timestamp: DateTime<Utc>,
920        workflow: WorkflowContext,
921        design: impl Into<String>,
922        platform: impl Into<String>,
923        message: impl Into<String>,
924    ) -> Result<Self, EventValidationError> {
925        let design = design.into();
926        let platform = platform.into();
927        let message = message.into();
928        Self::new_with_workflow(
929            event_id,
930            timestamp,
931            workflow,
932            EventSubject::design(design.clone()),
933            EventPayload::MaterializationFailed { design, platform, message },
934        )
935    }
936
937    /// Convenience constructor for a profiling-started event.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if the operation fails.
942    pub fn profiling_started(
943        event_id: impl Into<String>,
944        timestamp: DateTime<Utc>,
945        workflow: WorkflowContext,
946        source: impl Into<String>,
947    ) -> Result<Self, EventValidationError> {
948        let source = source.into();
949        Self::new_with_workflow(
950            event_id,
951            timestamp,
952            workflow,
953            EventSubject::source(source.clone()),
954            EventPayload::ProfilingStarted { source },
955        )
956    }
957
958    /// Convenience constructor for a profiling-completed event.
959    ///
960    /// # Errors
961    ///
962    /// Returns an error if the operation fails.
963    pub fn profiling_completed(
964        event_id: impl Into<String>,
965        timestamp: DateTime<Utc>,
966        workflow: WorkflowContext,
967        source: impl Into<String>,
968        fields: usize,
969        rows: usize,
970        duration_ms: u64,
971    ) -> Result<Self, EventValidationError> {
972        let source = source.into();
973        Self::new_with_workflow(
974            event_id,
975            timestamp,
976            workflow,
977            EventSubject::source(source.clone()),
978            EventPayload::ProfilingCompleted { source, fields, rows, duration_ms },
979        )
980    }
981
982    /// Convenience constructor for a design-loaded event.
983    ///
984    /// # Errors
985    ///
986    /// Returns an error if the operation fails.
987    pub fn design_loaded(
988        event_id: impl Into<String>,
989        timestamp: DateTime<Utc>,
990        path: impl Into<String>,
991        entities: usize,
992    ) -> Result<Self, EventValidationError> {
993        let path = path.into();
994        Self::new(
995            event_id,
996            timestamp,
997            EventSubject::design(path.clone()),
998            EventPayload::DesignLoaded { path, entities },
999        )
1000    }
1001
1002    /// Convenience constructor for a plugin-loaded event.
1003    ///
1004    /// # Errors
1005    ///
1006    /// Returns an error if the operation fails.
1007    pub fn plugin_loaded(
1008        event_id: impl Into<String>,
1009        timestamp: DateTime<Utc>,
1010        name: impl Into<String>,
1011        plugin_type: impl Into<String>,
1012        source: impl Into<String>,
1013    ) -> Result<Self, EventValidationError> {
1014        let name = name.into();
1015        let plugin_type = plugin_type.into();
1016        let source = source.into();
1017        Self::new(
1018            event_id,
1019            timestamp,
1020            EventSubject::plugin(name.clone()),
1021            EventPayload::PluginLoaded { name, plugin_type, source },
1022        )
1023    }
1024
1025    /// Convenience constructor for a storage-operation-completed event.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns an error if the operation fails.
1030    pub fn storage_operation_completed(
1031        event_id: impl Into<String>,
1032        timestamp: DateTime<Utc>,
1033        workflow: WorkflowContext,
1034        operation: impl Into<String>,
1035        path: impl Into<String>,
1036        bytes: u64,
1037        duration_ms: u64,
1038    ) -> Result<Self, EventValidationError> {
1039        let operation = operation.into();
1040        let path = path.into();
1041        Self::new_with_workflow(
1042            event_id,
1043            timestamp,
1044            workflow,
1045            EventSubject::storage(path.clone()),
1046            EventPayload::StorageOperationCompleted { operation, path, bytes, duration_ms },
1047        )
1048    }
1049
1050    /// Convenience constructor for an LLM-request-completed event.
1051    ///
1052    /// # Errors
1053    ///
1054    /// Returns an error if the operation fails.
1055    pub fn llm_request_completed(
1056        event_id: impl Into<String>,
1057        timestamp: DateTime<Utc>,
1058        workflow: WorkflowContext,
1059        provider: impl Into<String>,
1060        model: impl Into<String>,
1061        tokens: u64,
1062        duration_ms: u64,
1063    ) -> Result<Self, EventValidationError> {
1064        let provider = provider.into();
1065        let model = model.into();
1066        Self::new_with_workflow(
1067            event_id,
1068            timestamp,
1069            workflow,
1070            EventSubject::llm_model(model.clone()),
1071            EventPayload::LlmRequestCompleted { provider, model, tokens, duration_ms },
1072        )
1073    }
1074}
1075
1076/// Shared interface for components that emit canonical monitoring events.
1077#[async_trait]
1078pub trait EventEmitter: Send + Sync {
1079    /// Emit a canonical event into the monitoring pipeline.
1080    async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)>;
1081}
1082
1083// ── EventSink trait ────────────────────────────────────────────────────
1084
1085/// Sink that receives structured events from Razeri operations.
1086///
1087/// Implementations should be non-blocking and resilient to transient
1088/// failures (e.g., network issues). Razeri will continue operating
1089/// even if an event sink fails.
1090#[async_trait]
1091pub trait EventSink: Send + Sync {
1092    /// Machine-readable sink name.
1093    fn name(&self) -> &str;
1094
1095    /// Handle an event. Should not block or panic.
1096    ///
1097    /// # Errors
1098    ///
1099    /// Returns an error if the operation fails.
1100    async fn on_event(&self, event: &RazeriEvent) -> Result<(), EventSinkError>;
1101
1102    /// Flush any buffered events.
1103    ///
1104    /// # Errors
1105    ///
1106    /// Returns an error if the operation fails.
1107    async fn flush(&self) -> Result<(), EventSinkError>;
1108}
1109
1110// ── EventRegistry ──────────────────────────────────────────────────────
1111
1112/// Registry that dispatches events to all registered sinks.
1113///
1114/// [`emit`](Self::emit) hands an event to each sink **one after another, in
1115/// the order the sinks were registered**, and every sink has the event before
1116/// [`emit`](Self::emit) returns, so each sink receives every event in the
1117/// order it was emitted. A sink that refuses an event does not stop the sinks
1118/// after it: its refusal is returned with its name. A sink that is slow holds
1119/// up the sinks after it, so a sink that sends events over a network hands
1120/// them to a queue of its own and returns.
1121pub struct EventRegistry {
1122    sinks: Vec<Arc<dyn EventSink>>,
1123    history: RwLock<Vec<RazeriEvent>>,
1124}
1125
1126impl EventRegistry {
1127    /// Create an empty registry.
1128    #[must_use]
1129    pub fn new() -> Self {
1130        Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1131    }
1132
1133    /// Register an event sink.
1134    pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1135        self.sinks.push(sink);
1136    }
1137
1138    /// Record an event, then hand it to every sink, one after another in the
1139    /// order they were registered.
1140    ///
1141    /// Returns each sink's refusal with the sink's name; a refusal does not
1142    /// stop the sinks after it.
1143    pub async fn emit(&self, event: &RazeriEvent) -> Vec<(String, EventSinkError)> {
1144        self.record(event.clone());
1145        let mut errors = Vec::new();
1146        for sink in &self.sinks {
1147            if let Err(e) = sink.on_event(event).await {
1148                errors.push((sink.name().to_string(), e));
1149            }
1150        }
1151        errors
1152    }
1153
1154    /// Record an event without dispatching it to sinks.
1155    pub fn record(&self, event: RazeriEvent) {
1156        self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1157    }
1158
1159    /// Return all recorded events in chronological order.
1160    pub fn events(&self) -> Vec<RazeriEvent> {
1161        self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1162    }
1163
1164    /// Return the most recent events in chronological order.
1165    pub fn recent_events(&self, limit: usize) -> Vec<RazeriEvent> {
1166        let events = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1167        let start = events.len().saturating_sub(limit);
1168        events[start..].to_vec()
1169    }
1170
1171    /// Return events associated with the provided workflow identifier.
1172    pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<RazeriEvent> {
1173        self.history
1174            .read()
1175            .unwrap_or_else(std::sync::PoisonError::into_inner)
1176            .iter()
1177            .filter(|event| {
1178                event.workflow().is_some_and(|workflow| workflow.workflow_id == workflow_id)
1179            })
1180            .cloned()
1181            .collect()
1182    }
1183
1184    /// Return human-readable summaries for the most recent events.
1185    pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1186        self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1187    }
1188
1189    /// Flush all sinks.
1190    pub async fn flush_all(&self) -> Vec<(String, EventSinkError)> {
1191        let mut errors = Vec::new();
1192        for sink in &self.sinks {
1193            if let Err(e) = sink.flush().await {
1194                errors.push((sink.name().to_string(), e));
1195            }
1196        }
1197        errors
1198    }
1199
1200    /// The names of the sinks this registry dispatches to, in the order they
1201    /// were registered.
1202    #[must_use]
1203    pub fn sink_names(&self) -> Vec<String> {
1204        self.sinks.iter().map(|sink| sink.name().to_owned()).collect()
1205    }
1206
1207    /// Number of registered sinks.
1208    pub fn len(&self) -> usize {
1209        self.sinks.len()
1210    }
1211
1212    /// Whether the registry has no sinks.
1213    pub fn is_empty(&self) -> bool {
1214        self.sinks.is_empty()
1215    }
1216
1217    /// Execute a flexible query against the recorded event history.
1218    pub fn query(&self, q: &EventQuery) -> Vec<RazeriEvent> {
1219        let history = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1220        let iter = history.iter().filter(|e| {
1221            q.category.is_none_or(|c| e.category() == c)
1222                && q.min_severity.is_none_or(|s| e.severity() >= s)
1223                && q.workflow_id
1224                    .as_ref()
1225                    .is_none_or(|wf| e.workflow().is_some_and(|w| w.workflow_id == *wf))
1226                && q.subject_name.as_ref().is_none_or(|n| e.subject().name == *n)
1227                && q.subject_kind.is_none_or(|k| e.subject().kind == k)
1228                && q.since.is_none_or(|t| e.timestamp() >= t)
1229        });
1230        match q.limit {
1231            Some(n) => iter.take(n).cloned().collect(),
1232            None => iter.cloned().collect(),
1233        }
1234    }
1235}
1236
1237/// Flexible event query for filtering and pagination.
1238///
1239/// All fields are optional. When `None`, no filtering is applied for that
1240/// dimension. Combine fields to narrow results (they are `ANDed` together).
1241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1242pub struct EventQuery {
1243    /// Filter by event category.
1244    pub category: Option<EventCategory>,
1245    /// Filter to events at or above this severity.
1246    pub min_severity: Option<EventSeverity>,
1247    /// Filter by workflow identifier.
1248    pub workflow_id: Option<String>,
1249    /// Filter by subject name.
1250    pub subject_name: Option<String>,
1251    /// Filter by subject kind.
1252    pub subject_kind: Option<EventSubjectKind>,
1253    /// Only include events at or after this timestamp.
1254    pub since: Option<DateTime<Utc>>,
1255    /// Maximum number of events to return.
1256    pub limit: Option<usize>,
1257}
1258
1259impl Default for EventRegistry {
1260    fn default() -> Self {
1261        Self::new()
1262    }
1263}
1264
1265#[async_trait]
1266impl EventEmitter for EventRegistry {
1267    async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)> {
1268        self.emit(&event).await
1269    }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275    use std::sync::atomic::{AtomicUsize, Ordering};
1276
1277    /// **A count of one says its noun once** (`fr-1019-91`): `1 byte`, never
1278    /// `1 bytes`, in every summary that counts.
1279    #[test]
1280    fn a_count_of_one_says_its_noun_once() {
1281        let one = [
1282            EventPayload::ProfilingCompleted {
1283                source: "s".into(),
1284                fields: 1,
1285                rows: 1,
1286                duration_ms: 1,
1287            },
1288            EventPayload::MaterializationCompleted {
1289                entity: "e".into(),
1290                platform: "p".into(),
1291                artifacts: 1,
1292                duration_ms: 1,
1293            },
1294            EventPayload::DesignValidated { design: "d".into(), errors: 1, warnings: 1 },
1295            EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 1 },
1296            EventPayload::StorageOperationCompleted {
1297                operation: "put".into(),
1298                path: "p".into(),
1299                bytes: 1,
1300                duration_ms: 1,
1301            },
1302            EventPayload::LlmRequestCompleted {
1303                provider: "p".into(),
1304                model: "m".into(),
1305                tokens: 1,
1306                duration_ms: 1,
1307            },
1308        ];
1309        for payload in one {
1310            let detail = payload.summary().detail;
1311            for plural in
1312                ["1 fields", "1 rows", "1 artifacts", "1 errors", "1 warnings", "1 entities"]
1313            {
1314                assert!(!detail.contains(plural), "{detail}");
1315            }
1316            assert!(!detail.contains("1 bytes") && !detail.contains("1 tokens"), "{detail}");
1317        }
1318        let two = EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 2 };
1319        assert!(two.summary().detail.contains("2 entities"), "{}", two.summary().detail);
1320    }
1321
1322    /// **A file set aside is a warning, a file not moved an error**
1323    /// (`fr-1019-91`), so `razeri status` and an alert rule can count them;
1324    /// a routed file and a finished scan are information.
1325    #[test]
1326    fn a_file_set_aside_warns_and_a_file_not_moved_is_an_error() {
1327        let aside = EventPayload::FileSetAside {
1328            path: "in/x.csv".into(),
1329            destination: "quarantine/x.csv".into(),
1330            reason: "no rule routes it".into(),
1331        };
1332        let stuck = EventPayload::FileNotMoved { path: "in/y.csv".into(), reason: "denied".into() };
1333        let routed = EventPayload::FileRouted {
1334            path: "in/z.csv".into(),
1335            destination: "raw/z.csv".into(),
1336            rule: "customers".into(),
1337            bytes: 1,
1338        };
1339        assert_eq!(aside.severity(), EventSeverity::Warning);
1340        assert_eq!(stuck.severity(), EventSeverity::Error);
1341        assert_eq!(routed.severity(), EventSeverity::Info);
1342        assert_eq!(aside.category(), EventCategory::Storage);
1343        assert_eq!(aside.name(), "file_set_aside");
1344        assert!(routed.summary().detail.ends_with("1 byte"), "{}", routed.summary().detail);
1345    }
1346
1347    fn sample_timestamp() -> DateTime<Utc> {
1348        DateTime::parse_from_rfc3339("2026-03-13T12:34:56Z")
1349            .expect("valid timestamp fixture")
1350            .with_timezone(&Utc)
1351    }
1352
1353    fn sample_workflow() -> WorkflowContext {
1354        WorkflowContext::new("workflow-123")
1355            .expect("workflow id should be valid")
1356            .with_request_id("request-456")
1357            .with_actor("analyst@example.com")
1358            .with_surface("web")
1359    }
1360
1361    struct CountingSink {
1362        event_count: AtomicUsize,
1363        flush_count: AtomicUsize,
1364    }
1365
1366    impl CountingSink {
1367        fn new() -> Self {
1368            Self { event_count: AtomicUsize::new(0), flush_count: AtomicUsize::new(0) }
1369        }
1370
1371        fn events_received(&self) -> usize {
1372            self.event_count.load(Ordering::SeqCst)
1373        }
1374
1375        fn flushes(&self) -> usize {
1376            self.flush_count.load(Ordering::SeqCst)
1377        }
1378    }
1379
1380    #[async_trait]
1381    impl EventSink for CountingSink {
1382        fn name(&self) -> &'static str {
1383            "counting"
1384        }
1385
1386        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1387            self.event_count.fetch_add(1, Ordering::SeqCst);
1388            Ok(())
1389        }
1390
1391        async fn flush(&self) -> Result<(), EventSinkError> {
1392            self.flush_count.fetch_add(1, Ordering::SeqCst);
1393            Ok(())
1394        }
1395    }
1396
1397    struct FailingSink;
1398
1399    #[async_trait]
1400    impl EventSink for FailingSink {
1401        fn name(&self) -> &'static str {
1402            "failing"
1403        }
1404
1405        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1406            Err(EventSinkError::SinkError("intentional failure".to_string()))
1407        }
1408
1409        async fn flush(&self) -> Result<(), EventSinkError> {
1410            Err(EventSinkError::SinkError("intentional failure".to_string()))
1411        }
1412    }
1413
1414    fn sample_event() -> RazeriEvent {
1415        RazeriEvent::new_with_workflow(
1416            "event-123",
1417            sample_timestamp(),
1418            sample_workflow(),
1419            EventSubject::source("test.csv"),
1420            EventPayload::ProfilingCompleted {
1421                source: "test.csv".to_string(),
1422                fields: 10,
1423                rows: 1000,
1424                duration_ms: 42,
1425            },
1426        )
1427        .expect("workflow-scoped sample event should be valid")
1428    }
1429
1430    fn plugin_event() -> RazeriEvent {
1431        RazeriEvent::new(
1432            "event-plugin-1",
1433            sample_timestamp(),
1434            EventSubject::plugin("builtin-logger"),
1435            EventPayload::PluginLoaded {
1436                name: "builtin-logger".to_string(),
1437                plugin_type: "event_sink".to_string(),
1438                source: "builtin".to_string(),
1439            },
1440        )
1441        .expect("plugin events are not workflow scoped")
1442    }
1443
1444    #[tokio::test]
1445    async fn event_dispatched_to_all_sinks() {
1446        let sink1 = Arc::new(CountingSink::new());
1447        let sink2 = Arc::new(CountingSink::new());
1448
1449        let mut registry = EventRegistry::new();
1450        registry.register(sink1.clone());
1451        registry.register(sink2.clone());
1452
1453        let errors = registry.emit(&sample_event()).await;
1454        assert!(errors.is_empty());
1455        assert_eq!(sink1.events_received(), 1);
1456        assert_eq!(sink2.events_received(), 1);
1457    }
1458
1459    #[tokio::test]
1460    async fn sink_failure_does_not_halt_dispatch() {
1461        let good_sink = Arc::new(CountingSink::new());
1462        let bad_sink: Arc<dyn EventSink> = Arc::new(FailingSink);
1463
1464        let mut registry = EventRegistry::new();
1465        registry.register(bad_sink);
1466        registry.register(good_sink.clone());
1467
1468        let errors = registry.emit(&sample_event()).await;
1469        // Failing sink produced an error, but good sink still received the event
1470        assert_eq!(errors.len(), 1);
1471        assert_eq!(errors[0].0, "failing");
1472        assert_eq!(good_sink.events_received(), 1);
1473    }
1474
1475    #[tokio::test]
1476    async fn flush_all_flushes_every_sink() {
1477        let sink1 = Arc::new(CountingSink::new());
1478        let sink2 = Arc::new(CountingSink::new());
1479
1480        let mut registry = EventRegistry::new();
1481        registry.register(sink1.clone());
1482        registry.register(sink2.clone());
1483
1484        let errors = registry.flush_all().await;
1485        assert!(errors.is_empty());
1486        assert_eq!(sink1.flushes(), 1);
1487        assert_eq!(sink2.flushes(), 1);
1488    }
1489
1490    #[tokio::test]
1491    async fn emitted_events_are_recorded_and_queryable() {
1492        let registry = EventRegistry::new();
1493        registry.emit_event(sample_event()).await;
1494        registry.emit_event(plugin_event()).await;
1495
1496        let recorded = registry.events();
1497        assert_eq!(recorded.len(), 2);
1498
1499        let workflow_events = registry.events_for_workflow("workflow-123");
1500        assert_eq!(workflow_events.len(), 1);
1501        assert_eq!(workflow_events[0].category(), EventCategory::Profiling);
1502
1503        let summaries = registry.recent_summaries(2);
1504        assert_eq!(summaries.len(), 2);
1505        assert_eq!(summaries[0].title, "Profiling completed");
1506        assert_eq!(summaries[1].title, "Plugin loaded");
1507    }
1508
1509    #[test]
1510    fn materialization_failure_has_error_severity_and_summary() {
1511        let event = RazeriEvent::materialization_failed(
1512            "event-materialization-failed",
1513            sample_timestamp(),
1514            sample_workflow(),
1515            "sales_design",
1516            "duckdb",
1517            "dialect mismatch",
1518        )
1519        .expect("materialization failure event should be valid");
1520
1521        assert_eq!(event.category(), EventCategory::Materialization);
1522        assert_eq!(event.severity(), EventSeverity::Error);
1523        assert!(event.summary().detail.contains("dialect mismatch"));
1524    }
1525
1526    #[test]
1527    fn event_serializes_to_json() {
1528        let event = sample_event();
1529        let json = serde_json::to_string(&event);
1530        assert!(json.is_ok());
1531        let json_str = json.unwrap();
1532        assert!(json_str.contains("profiling_completed"));
1533        assert!(json_str.contains("test.csv"));
1534        assert!(json_str.contains("workflow-123"));
1535    }
1536
1537    #[test]
1538    fn workflow_scoped_event_requires_workflow_context() {
1539        let event = RazeriEvent::new(
1540            "event-missing-workflow",
1541            sample_timestamp(),
1542            EventSubject::source("missing.csv"),
1543            EventPayload::ProfilingStarted { source: "missing.csv".to_string() },
1544        );
1545
1546        assert_eq!(
1547            event,
1548            Err(EventValidationError::WorkflowContextRequired {
1549                payload: "profiling_started".to_string(),
1550            })
1551        );
1552    }
1553
1554    #[test]
1555    fn event_round_trips_through_json_without_losing_meaning() {
1556        let event = sample_event();
1557        let json = serde_json::to_string(&event).expect("event should serialize");
1558        let round_trip: RazeriEvent =
1559            serde_json::from_str(&json).expect("event should deserialize");
1560
1561        assert_eq!(event, round_trip);
1562        assert_eq!(round_trip.validate(), Ok(()));
1563    }
1564
1565    #[test]
1566    fn summaries_and_filter_keys_are_derived_from_canonical_event() {
1567        let event = sample_event();
1568
1569        let keys = event.filter_keys();
1570        assert_eq!(keys.category, EventCategory::Profiling);
1571        assert_eq!(keys.severity, EventSeverity::Info);
1572        assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1573        assert_eq!(keys.subject_kind, EventSubjectKind::Source);
1574        assert_eq!(keys.subject_name, "test.csv");
1575        assert_eq!(keys.actor.as_deref(), Some("analyst@example.com"));
1576        assert_eq!(keys.surface.as_deref(), Some("web"));
1577
1578        let summary = event.summary();
1579        assert_eq!(summary.title, "Profiling completed");
1580        assert!(summary.detail.contains("10 fields"));
1581        assert!(summary.detail.contains("1000 rows"));
1582    }
1583
1584    #[test]
1585    fn non_workflow_scoped_events_can_be_created_without_workflow_context() {
1586        let event = plugin_event();
1587
1588        assert_eq!(event.category(), EventCategory::Plugin);
1589        assert_eq!(event.severity(), EventSeverity::Info);
1590        assert!(event.workflow().is_none());
1591    }
1592
1593    #[test]
1594    fn profiling_started_constructs_with_source_subject() {
1595        let event = RazeriEvent::profiling_started(
1596            "ps-1",
1597            sample_timestamp(),
1598            sample_workflow(),
1599            "customers.csv",
1600        )
1601        .unwrap();
1602        assert_eq!(event.category(), EventCategory::Profiling);
1603        assert_eq!(event.subject().kind, EventSubjectKind::Source);
1604        assert_eq!(event.summary().title, "Profiling started");
1605    }
1606
1607    #[test]
1608    fn profiling_completed_constructs_with_stats() {
1609        let event = RazeriEvent::profiling_completed(
1610            "pc-1",
1611            sample_timestamp(),
1612            sample_workflow(),
1613            "customers.csv",
1614            10,
1615            5000,
1616            123,
1617        )
1618        .unwrap();
1619        assert_eq!(event.category(), EventCategory::Profiling);
1620        assert!(event.summary().detail.contains("10 fields"));
1621        assert!(event.summary().detail.contains("5000 rows"));
1622    }
1623
1624    #[test]
1625    fn design_loaded_constructs_without_workflow() {
1626        let event =
1627            RazeriEvent::design_loaded("dl-1", sample_timestamp(), "designs/retail.yaml", 3)
1628                .unwrap();
1629        assert_eq!(event.category(), EventCategory::Design);
1630        assert!(event.workflow().is_none());
1631        assert!(event.summary().detail.contains("3 entities"));
1632    }
1633
1634    #[test]
1635    fn plugin_loaded_constructs_without_workflow() {
1636        let event = RazeriEvent::plugin_loaded(
1637            "pl-1",
1638            sample_timestamp(),
1639            "PostgresDialect",
1640            "dialect",
1641            "inventory",
1642        )
1643        .unwrap();
1644        assert_eq!(event.category(), EventCategory::Plugin);
1645        assert_eq!(event.subject().kind, EventSubjectKind::Plugin);
1646        assert!(event.summary().detail.contains("dialect"));
1647    }
1648
1649    #[test]
1650    fn storage_operation_completed_constructs_with_workflow() {
1651        let event = RazeriEvent::storage_operation_completed(
1652            "so-1",
1653            sample_timestamp(),
1654            sample_workflow(),
1655            "move",
1656            "/data/file.csv",
1657            1024,
1658            50,
1659        )
1660        .unwrap();
1661        assert_eq!(event.category(), EventCategory::Storage);
1662        assert_eq!(event.subject().kind, EventSubjectKind::StoragePath);
1663    }
1664
1665    #[test]
1666    fn llm_request_completed_constructs_with_workflow() {
1667        let event = RazeriEvent::llm_request_completed(
1668            "lr-1",
1669            sample_timestamp(),
1670            sample_workflow(),
1671            "ollama",
1672            "llama3",
1673            500,
1674            3000,
1675        )
1676        .unwrap();
1677        assert_eq!(event.category(), EventCategory::Ai);
1678        assert_eq!(event.subject().kind, EventSubjectKind::LlmModel);
1679        assert!(event.summary().detail.contains("500 tokens"));
1680    }
1681
1682    #[test]
1683    fn query_returns_all_events_when_empty() {
1684        let registry = EventRegistry::new();
1685        registry.record(sample_event());
1686        registry.record(plugin_event());
1687
1688        let results = registry.query(&EventQuery::default());
1689        assert_eq!(results.len(), 2);
1690    }
1691
1692    #[test]
1693    fn query_filters_by_category() {
1694        let registry = EventRegistry::new();
1695        registry.record(sample_event());
1696        registry.record(plugin_event());
1697
1698        let results = registry
1699            .query(&EventQuery { category: Some(EventCategory::Plugin), ..Default::default() });
1700        assert_eq!(results.len(), 1);
1701        assert_eq!(results[0].category(), EventCategory::Plugin);
1702    }
1703
1704    #[test]
1705    fn query_filters_by_min_severity() {
1706        let registry = EventRegistry::new();
1707        registry.record(sample_event()); // Info
1708        let error_event = RazeriEvent::materialization_failed(
1709            "err-1",
1710            sample_timestamp(),
1711            sample_workflow(),
1712            "design-a",
1713            "PostgreSQL",
1714            "connection error",
1715        )
1716        .unwrap();
1717        registry.record(error_event);
1718
1719        let results = registry
1720            .query(&EventQuery { min_severity: Some(EventSeverity::Error), ..Default::default() });
1721        assert_eq!(results.len(), 1);
1722        assert_eq!(results[0].severity(), EventSeverity::Error);
1723    }
1724
1725    #[test]
1726    fn query_filters_by_workflow_id() {
1727        let registry = EventRegistry::new();
1728        registry.record(sample_event()); // workflow-123
1729        registry.record(plugin_event()); // no workflow
1730
1731        let results = registry.query(&EventQuery {
1732            workflow_id: Some("workflow-123".to_string()),
1733            ..Default::default()
1734        });
1735        assert_eq!(results.len(), 1);
1736    }
1737
1738    #[test]
1739    fn query_respects_limit() {
1740        let registry = EventRegistry::new();
1741        registry.record(sample_event());
1742        registry.record(plugin_event());
1743
1744        let results = registry.query(&EventQuery { limit: Some(1), ..Default::default() });
1745        assert_eq!(results.len(), 1);
1746    }
1747
1748    // ── Snapshot tests ──────────────────────────────────────────────────
1749
1750    #[test]
1751    fn snapshot_profiling_completed_json_shape() {
1752        let event = sample_event();
1753        let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1754        let obj = val.as_object().unwrap();
1755
1756        // Envelope fields
1757        assert!(obj.contains_key("event_id"));
1758        assert!(obj.contains_key("timestamp"));
1759        assert!(obj.contains_key("category"));
1760        assert!(obj.contains_key("severity"));
1761        assert!(obj.contains_key("workflow"));
1762        assert!(obj.contains_key("subject"));
1763        assert!(obj.contains_key("payload"));
1764
1765        // Payload is internally tagged with "kind"
1766        let payload = obj["payload"].as_object().unwrap();
1767        assert_eq!(payload["kind"], "profiling_completed");
1768        assert!(payload.contains_key("source"));
1769        assert!(payload.contains_key("fields"));
1770        assert!(payload.contains_key("rows"));
1771        assert!(payload.contains_key("duration_ms"));
1772
1773        // Subject has kind+name
1774        let subject = obj["subject"].as_object().unwrap();
1775        assert_eq!(subject["kind"], "source");
1776        assert!(subject.contains_key("name"));
1777    }
1778
1779    #[test]
1780    fn snapshot_event_round_trip_preserves_identity() {
1781        let event = sample_event();
1782        let json = serde_json::to_string(&event).unwrap();
1783        let roundtrip: RazeriEvent = serde_json::from_str(&json).unwrap();
1784        assert_eq!(roundtrip.event_id(), event.event_id());
1785        assert_eq!(roundtrip.category(), event.category());
1786        assert_eq!(roundtrip.severity(), event.severity());
1787        assert_eq!(roundtrip.subject().name, event.subject().name);
1788    }
1789
1790    #[test]
1791    fn snapshot_materialization_failed_json_shape() {
1792        let event = RazeriEvent::materialization_failed(
1793            "snap-mat-fail",
1794            sample_timestamp(),
1795            sample_workflow(),
1796            "sales_design",
1797            "duckdb",
1798            "type mismatch",
1799        )
1800        .unwrap();
1801        let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1802        let payload = val["payload"].as_object().unwrap();
1803        assert_eq!(payload["kind"], "materialization_failed");
1804        assert_eq!(payload["design"], "sales_design");
1805        assert_eq!(payload["platform"], "duckdb");
1806        assert_eq!(payload["message"], "type mismatch");
1807    }
1808
1809    #[test]
1810    fn snapshot_plugin_loaded_json_shape() {
1811        let event = plugin_event();
1812        let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1813        let payload = val["payload"].as_object().unwrap();
1814        assert_eq!(payload["kind"], "plugin_loaded");
1815        assert!(payload.contains_key("name"));
1816        assert!(payload.contains_key("plugin_type"));
1817        assert!(payload.contains_key("source"));
1818    }
1819
1820    #[test]
1821    fn snapshot_event_summary_format_is_stable() {
1822        let event = sample_event();
1823        let summary = event.summary();
1824        assert_eq!(summary.title, "Profiling completed");
1825        assert!(summary.detail.contains("test.csv"));
1826        assert!(summary.detail.contains("10"));
1827        assert!(summary.detail.contains("100"));
1828    }
1829
1830    #[test]
1831    fn snapshot_filter_keys_are_stable() {
1832        let event = sample_event();
1833        let keys = event.filter_keys();
1834        assert_eq!(keys.category, EventCategory::Profiling);
1835        assert_eq!(keys.severity, EventSeverity::Info);
1836        assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1837        assert_eq!(keys.subject_name, "test.csv");
1838    }
1839}