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/// Errors from individual sinks are logged but do **not** halt the
1115/// pipeline — this is the "fire and continue" pattern.
1116pub struct EventRegistry {
1117    sinks: Vec<Arc<dyn EventSink>>,
1118    history: RwLock<Vec<RazeriEvent>>,
1119}
1120
1121impl EventRegistry {
1122    /// Create an empty registry.
1123    #[must_use]
1124    pub fn new() -> Self {
1125        Self { sinks: Vec::new(), history: RwLock::new(Vec::new()) }
1126    }
1127
1128    /// Register an event sink.
1129    pub fn register(&mut self, sink: Arc<dyn EventSink>) {
1130        self.sinks.push(sink);
1131    }
1132
1133    /// Dispatch an event to all sinks.
1134    ///
1135    /// Errors from individual sinks are collected but do not propagate —
1136    /// all sinks are attempted regardless of failures.
1137    pub async fn emit(&self, event: &RazeriEvent) -> Vec<(String, EventSinkError)> {
1138        self.record(event.clone());
1139        let mut errors = Vec::new();
1140        for sink in &self.sinks {
1141            if let Err(e) = sink.on_event(event).await {
1142                errors.push((sink.name().to_string(), e));
1143            }
1144        }
1145        errors
1146    }
1147
1148    /// Record an event without dispatching it to sinks.
1149    pub fn record(&self, event: RazeriEvent) {
1150        self.history.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(event);
1151    }
1152
1153    /// Return all recorded events in chronological order.
1154    pub fn events(&self) -> Vec<RazeriEvent> {
1155        self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
1156    }
1157
1158    /// Return the most recent events in chronological order.
1159    pub fn recent_events(&self, limit: usize) -> Vec<RazeriEvent> {
1160        let events = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1161        let start = events.len().saturating_sub(limit);
1162        events[start..].to_vec()
1163    }
1164
1165    /// Return events associated with the provided workflow identifier.
1166    pub fn events_for_workflow(&self, workflow_id: &str) -> Vec<RazeriEvent> {
1167        self.history
1168            .read()
1169            .unwrap_or_else(std::sync::PoisonError::into_inner)
1170            .iter()
1171            .filter(|event| {
1172                event.workflow().is_some_and(|workflow| workflow.workflow_id == workflow_id)
1173            })
1174            .cloned()
1175            .collect()
1176    }
1177
1178    /// Return human-readable summaries for the most recent events.
1179    pub fn recent_summaries(&self, limit: usize) -> Vec<EventSummary> {
1180        self.recent_events(limit).into_iter().map(|event| event.summary()).collect()
1181    }
1182
1183    /// Flush all sinks.
1184    pub async fn flush_all(&self) -> Vec<(String, EventSinkError)> {
1185        let mut errors = Vec::new();
1186        for sink in &self.sinks {
1187            if let Err(e) = sink.flush().await {
1188                errors.push((sink.name().to_string(), e));
1189            }
1190        }
1191        errors
1192    }
1193
1194    /// The names of the sinks this registry dispatches to, in the order they
1195    /// were registered.
1196    #[must_use]
1197    pub fn sink_names(&self) -> Vec<String> {
1198        self.sinks.iter().map(|sink| sink.name().to_owned()).collect()
1199    }
1200
1201    /// Number of registered sinks.
1202    pub fn len(&self) -> usize {
1203        self.sinks.len()
1204    }
1205
1206    /// Whether the registry has no sinks.
1207    pub fn is_empty(&self) -> bool {
1208        self.sinks.is_empty()
1209    }
1210
1211    /// Execute a flexible query against the recorded event history.
1212    pub fn query(&self, q: &EventQuery) -> Vec<RazeriEvent> {
1213        let history = self.history.read().unwrap_or_else(std::sync::PoisonError::into_inner);
1214        let iter = history.iter().filter(|e| {
1215            q.category.is_none_or(|c| e.category() == c)
1216                && q.min_severity.is_none_or(|s| e.severity() >= s)
1217                && q.workflow_id
1218                    .as_ref()
1219                    .is_none_or(|wf| e.workflow().is_some_and(|w| w.workflow_id == *wf))
1220                && q.subject_name.as_ref().is_none_or(|n| e.subject().name == *n)
1221                && q.subject_kind.is_none_or(|k| e.subject().kind == k)
1222                && q.since.is_none_or(|t| e.timestamp() >= t)
1223        });
1224        match q.limit {
1225            Some(n) => iter.take(n).cloned().collect(),
1226            None => iter.cloned().collect(),
1227        }
1228    }
1229}
1230
1231/// Flexible event query for filtering and pagination.
1232///
1233/// All fields are optional. When `None`, no filtering is applied for that
1234/// dimension. Combine fields to narrow results (they are `ANDed` together).
1235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1236pub struct EventQuery {
1237    /// Filter by event category.
1238    pub category: Option<EventCategory>,
1239    /// Filter to events at or above this severity.
1240    pub min_severity: Option<EventSeverity>,
1241    /// Filter by workflow identifier.
1242    pub workflow_id: Option<String>,
1243    /// Filter by subject name.
1244    pub subject_name: Option<String>,
1245    /// Filter by subject kind.
1246    pub subject_kind: Option<EventSubjectKind>,
1247    /// Only include events at or after this timestamp.
1248    pub since: Option<DateTime<Utc>>,
1249    /// Maximum number of events to return.
1250    pub limit: Option<usize>,
1251}
1252
1253impl Default for EventRegistry {
1254    fn default() -> Self {
1255        Self::new()
1256    }
1257}
1258
1259#[async_trait]
1260impl EventEmitter for EventRegistry {
1261    async fn emit_event(&self, event: RazeriEvent) -> Vec<(String, EventSinkError)> {
1262        self.emit(&event).await
1263    }
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268    use super::*;
1269    use std::sync::atomic::{AtomicUsize, Ordering};
1270
1271    /// **A count of one says its noun once** (`fr-1019-91`): `1 byte`, never
1272    /// `1 bytes`, in every summary that counts.
1273    #[test]
1274    fn a_count_of_one_says_its_noun_once() {
1275        let one = [
1276            EventPayload::ProfilingCompleted {
1277                source: "s".into(),
1278                fields: 1,
1279                rows: 1,
1280                duration_ms: 1,
1281            },
1282            EventPayload::MaterializationCompleted {
1283                entity: "e".into(),
1284                platform: "p".into(),
1285                artifacts: 1,
1286                duration_ms: 1,
1287            },
1288            EventPayload::DesignValidated { design: "d".into(), errors: 1, warnings: 1 },
1289            EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 1 },
1290            EventPayload::StorageOperationCompleted {
1291                operation: "put".into(),
1292                path: "p".into(),
1293                bytes: 1,
1294                duration_ms: 1,
1295            },
1296            EventPayload::LlmRequestCompleted {
1297                provider: "p".into(),
1298                model: "m".into(),
1299                tokens: 1,
1300                duration_ms: 1,
1301            },
1302        ];
1303        for payload in one {
1304            let detail = payload.summary().detail;
1305            for plural in
1306                ["1 fields", "1 rows", "1 artifacts", "1 errors", "1 warnings", "1 entities"]
1307            {
1308                assert!(!detail.contains(plural), "{detail}");
1309            }
1310            assert!(!detail.contains("1 bytes") && !detail.contains("1 tokens"), "{detail}");
1311        }
1312        let two = EventPayload::DesignLoaded { path: "d.yaml".into(), entities: 2 };
1313        assert!(two.summary().detail.contains("2 entities"), "{}", two.summary().detail);
1314    }
1315
1316    /// **A file set aside is a warning, a file not moved an error**
1317    /// (`fr-1019-91`), so `razeri status` and an alert rule can count them;
1318    /// a routed file and a finished scan are information.
1319    #[test]
1320    fn a_file_set_aside_warns_and_a_file_not_moved_is_an_error() {
1321        let aside = EventPayload::FileSetAside {
1322            path: "in/x.csv".into(),
1323            destination: "quarantine/x.csv".into(),
1324            reason: "no rule routes it".into(),
1325        };
1326        let stuck = EventPayload::FileNotMoved { path: "in/y.csv".into(), reason: "denied".into() };
1327        let routed = EventPayload::FileRouted {
1328            path: "in/z.csv".into(),
1329            destination: "raw/z.csv".into(),
1330            rule: "customers".into(),
1331            bytes: 1,
1332        };
1333        assert_eq!(aside.severity(), EventSeverity::Warning);
1334        assert_eq!(stuck.severity(), EventSeverity::Error);
1335        assert_eq!(routed.severity(), EventSeverity::Info);
1336        assert_eq!(aside.category(), EventCategory::Storage);
1337        assert_eq!(aside.name(), "file_set_aside");
1338        assert!(routed.summary().detail.ends_with("1 byte"), "{}", routed.summary().detail);
1339    }
1340
1341    fn sample_timestamp() -> DateTime<Utc> {
1342        DateTime::parse_from_rfc3339("2026-03-13T12:34:56Z")
1343            .expect("valid timestamp fixture")
1344            .with_timezone(&Utc)
1345    }
1346
1347    fn sample_workflow() -> WorkflowContext {
1348        WorkflowContext::new("workflow-123")
1349            .expect("workflow id should be valid")
1350            .with_request_id("request-456")
1351            .with_actor("analyst@example.com")
1352            .with_surface("web")
1353    }
1354
1355    struct CountingSink {
1356        event_count: AtomicUsize,
1357        flush_count: AtomicUsize,
1358    }
1359
1360    impl CountingSink {
1361        fn new() -> Self {
1362            Self { event_count: AtomicUsize::new(0), flush_count: AtomicUsize::new(0) }
1363        }
1364
1365        fn events_received(&self) -> usize {
1366            self.event_count.load(Ordering::SeqCst)
1367        }
1368
1369        fn flushes(&self) -> usize {
1370            self.flush_count.load(Ordering::SeqCst)
1371        }
1372    }
1373
1374    #[async_trait]
1375    impl EventSink for CountingSink {
1376        fn name(&self) -> &'static str {
1377            "counting"
1378        }
1379
1380        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1381            self.event_count.fetch_add(1, Ordering::SeqCst);
1382            Ok(())
1383        }
1384
1385        async fn flush(&self) -> Result<(), EventSinkError> {
1386            self.flush_count.fetch_add(1, Ordering::SeqCst);
1387            Ok(())
1388        }
1389    }
1390
1391    struct FailingSink;
1392
1393    #[async_trait]
1394    impl EventSink for FailingSink {
1395        fn name(&self) -> &'static str {
1396            "failing"
1397        }
1398
1399        async fn on_event(&self, _event: &RazeriEvent) -> Result<(), EventSinkError> {
1400            Err(EventSinkError::SinkError("intentional failure".to_string()))
1401        }
1402
1403        async fn flush(&self) -> Result<(), EventSinkError> {
1404            Err(EventSinkError::SinkError("intentional failure".to_string()))
1405        }
1406    }
1407
1408    fn sample_event() -> RazeriEvent {
1409        RazeriEvent::new_with_workflow(
1410            "event-123",
1411            sample_timestamp(),
1412            sample_workflow(),
1413            EventSubject::source("test.csv"),
1414            EventPayload::ProfilingCompleted {
1415                source: "test.csv".to_string(),
1416                fields: 10,
1417                rows: 1000,
1418                duration_ms: 42,
1419            },
1420        )
1421        .expect("workflow-scoped sample event should be valid")
1422    }
1423
1424    fn plugin_event() -> RazeriEvent {
1425        RazeriEvent::new(
1426            "event-plugin-1",
1427            sample_timestamp(),
1428            EventSubject::plugin("builtin-logger"),
1429            EventPayload::PluginLoaded {
1430                name: "builtin-logger".to_string(),
1431                plugin_type: "event_sink".to_string(),
1432                source: "builtin".to_string(),
1433            },
1434        )
1435        .expect("plugin events are not workflow scoped")
1436    }
1437
1438    #[tokio::test]
1439    async fn event_dispatched_to_all_sinks() {
1440        let sink1 = Arc::new(CountingSink::new());
1441        let sink2 = Arc::new(CountingSink::new());
1442
1443        let mut registry = EventRegistry::new();
1444        registry.register(sink1.clone());
1445        registry.register(sink2.clone());
1446
1447        let errors = registry.emit(&sample_event()).await;
1448        assert!(errors.is_empty());
1449        assert_eq!(sink1.events_received(), 1);
1450        assert_eq!(sink2.events_received(), 1);
1451    }
1452
1453    #[tokio::test]
1454    async fn sink_failure_does_not_halt_dispatch() {
1455        let good_sink = Arc::new(CountingSink::new());
1456        let bad_sink: Arc<dyn EventSink> = Arc::new(FailingSink);
1457
1458        let mut registry = EventRegistry::new();
1459        registry.register(bad_sink);
1460        registry.register(good_sink.clone());
1461
1462        let errors = registry.emit(&sample_event()).await;
1463        // Failing sink produced an error, but good sink still received the event
1464        assert_eq!(errors.len(), 1);
1465        assert_eq!(errors[0].0, "failing");
1466        assert_eq!(good_sink.events_received(), 1);
1467    }
1468
1469    #[tokio::test]
1470    async fn flush_all_flushes_every_sink() {
1471        let sink1 = Arc::new(CountingSink::new());
1472        let sink2 = Arc::new(CountingSink::new());
1473
1474        let mut registry = EventRegistry::new();
1475        registry.register(sink1.clone());
1476        registry.register(sink2.clone());
1477
1478        let errors = registry.flush_all().await;
1479        assert!(errors.is_empty());
1480        assert_eq!(sink1.flushes(), 1);
1481        assert_eq!(sink2.flushes(), 1);
1482    }
1483
1484    #[tokio::test]
1485    async fn emitted_events_are_recorded_and_queryable() {
1486        let registry = EventRegistry::new();
1487        registry.emit_event(sample_event()).await;
1488        registry.emit_event(plugin_event()).await;
1489
1490        let recorded = registry.events();
1491        assert_eq!(recorded.len(), 2);
1492
1493        let workflow_events = registry.events_for_workflow("workflow-123");
1494        assert_eq!(workflow_events.len(), 1);
1495        assert_eq!(workflow_events[0].category(), EventCategory::Profiling);
1496
1497        let summaries = registry.recent_summaries(2);
1498        assert_eq!(summaries.len(), 2);
1499        assert_eq!(summaries[0].title, "Profiling completed");
1500        assert_eq!(summaries[1].title, "Plugin loaded");
1501    }
1502
1503    #[test]
1504    fn materialization_failure_has_error_severity_and_summary() {
1505        let event = RazeriEvent::materialization_failed(
1506            "event-materialization-failed",
1507            sample_timestamp(),
1508            sample_workflow(),
1509            "sales_design",
1510            "duckdb",
1511            "dialect mismatch",
1512        )
1513        .expect("materialization failure event should be valid");
1514
1515        assert_eq!(event.category(), EventCategory::Materialization);
1516        assert_eq!(event.severity(), EventSeverity::Error);
1517        assert!(event.summary().detail.contains("dialect mismatch"));
1518    }
1519
1520    #[test]
1521    fn event_serializes_to_json() {
1522        let event = sample_event();
1523        let json = serde_json::to_string(&event);
1524        assert!(json.is_ok());
1525        let json_str = json.unwrap();
1526        assert!(json_str.contains("profiling_completed"));
1527        assert!(json_str.contains("test.csv"));
1528        assert!(json_str.contains("workflow-123"));
1529    }
1530
1531    #[test]
1532    fn workflow_scoped_event_requires_workflow_context() {
1533        let event = RazeriEvent::new(
1534            "event-missing-workflow",
1535            sample_timestamp(),
1536            EventSubject::source("missing.csv"),
1537            EventPayload::ProfilingStarted { source: "missing.csv".to_string() },
1538        );
1539
1540        assert_eq!(
1541            event,
1542            Err(EventValidationError::WorkflowContextRequired {
1543                payload: "profiling_started".to_string(),
1544            })
1545        );
1546    }
1547
1548    #[test]
1549    fn event_round_trips_through_json_without_losing_meaning() {
1550        let event = sample_event();
1551        let json = serde_json::to_string(&event).expect("event should serialize");
1552        let round_trip: RazeriEvent =
1553            serde_json::from_str(&json).expect("event should deserialize");
1554
1555        assert_eq!(event, round_trip);
1556        assert_eq!(round_trip.validate(), Ok(()));
1557    }
1558
1559    #[test]
1560    fn summaries_and_filter_keys_are_derived_from_canonical_event() {
1561        let event = sample_event();
1562
1563        let keys = event.filter_keys();
1564        assert_eq!(keys.category, EventCategory::Profiling);
1565        assert_eq!(keys.severity, EventSeverity::Info);
1566        assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1567        assert_eq!(keys.subject_kind, EventSubjectKind::Source);
1568        assert_eq!(keys.subject_name, "test.csv");
1569        assert_eq!(keys.actor.as_deref(), Some("analyst@example.com"));
1570        assert_eq!(keys.surface.as_deref(), Some("web"));
1571
1572        let summary = event.summary();
1573        assert_eq!(summary.title, "Profiling completed");
1574        assert!(summary.detail.contains("10 fields"));
1575        assert!(summary.detail.contains("1000 rows"));
1576    }
1577
1578    #[test]
1579    fn non_workflow_scoped_events_can_be_created_without_workflow_context() {
1580        let event = plugin_event();
1581
1582        assert_eq!(event.category(), EventCategory::Plugin);
1583        assert_eq!(event.severity(), EventSeverity::Info);
1584        assert!(event.workflow().is_none());
1585    }
1586
1587    #[test]
1588    fn profiling_started_constructs_with_source_subject() {
1589        let event = RazeriEvent::profiling_started(
1590            "ps-1",
1591            sample_timestamp(),
1592            sample_workflow(),
1593            "customers.csv",
1594        )
1595        .unwrap();
1596        assert_eq!(event.category(), EventCategory::Profiling);
1597        assert_eq!(event.subject().kind, EventSubjectKind::Source);
1598        assert_eq!(event.summary().title, "Profiling started");
1599    }
1600
1601    #[test]
1602    fn profiling_completed_constructs_with_stats() {
1603        let event = RazeriEvent::profiling_completed(
1604            "pc-1",
1605            sample_timestamp(),
1606            sample_workflow(),
1607            "customers.csv",
1608            10,
1609            5000,
1610            123,
1611        )
1612        .unwrap();
1613        assert_eq!(event.category(), EventCategory::Profiling);
1614        assert!(event.summary().detail.contains("10 fields"));
1615        assert!(event.summary().detail.contains("5000 rows"));
1616    }
1617
1618    #[test]
1619    fn design_loaded_constructs_without_workflow() {
1620        let event =
1621            RazeriEvent::design_loaded("dl-1", sample_timestamp(), "designs/retail.yaml", 3)
1622                .unwrap();
1623        assert_eq!(event.category(), EventCategory::Design);
1624        assert!(event.workflow().is_none());
1625        assert!(event.summary().detail.contains("3 entities"));
1626    }
1627
1628    #[test]
1629    fn plugin_loaded_constructs_without_workflow() {
1630        let event = RazeriEvent::plugin_loaded(
1631            "pl-1",
1632            sample_timestamp(),
1633            "PostgresDialect",
1634            "dialect",
1635            "inventory",
1636        )
1637        .unwrap();
1638        assert_eq!(event.category(), EventCategory::Plugin);
1639        assert_eq!(event.subject().kind, EventSubjectKind::Plugin);
1640        assert!(event.summary().detail.contains("dialect"));
1641    }
1642
1643    #[test]
1644    fn storage_operation_completed_constructs_with_workflow() {
1645        let event = RazeriEvent::storage_operation_completed(
1646            "so-1",
1647            sample_timestamp(),
1648            sample_workflow(),
1649            "move",
1650            "/data/file.csv",
1651            1024,
1652            50,
1653        )
1654        .unwrap();
1655        assert_eq!(event.category(), EventCategory::Storage);
1656        assert_eq!(event.subject().kind, EventSubjectKind::StoragePath);
1657    }
1658
1659    #[test]
1660    fn llm_request_completed_constructs_with_workflow() {
1661        let event = RazeriEvent::llm_request_completed(
1662            "lr-1",
1663            sample_timestamp(),
1664            sample_workflow(),
1665            "ollama",
1666            "llama3",
1667            500,
1668            3000,
1669        )
1670        .unwrap();
1671        assert_eq!(event.category(), EventCategory::Ai);
1672        assert_eq!(event.subject().kind, EventSubjectKind::LlmModel);
1673        assert!(event.summary().detail.contains("500 tokens"));
1674    }
1675
1676    #[test]
1677    fn query_returns_all_events_when_empty() {
1678        let registry = EventRegistry::new();
1679        registry.record(sample_event());
1680        registry.record(plugin_event());
1681
1682        let results = registry.query(&EventQuery::default());
1683        assert_eq!(results.len(), 2);
1684    }
1685
1686    #[test]
1687    fn query_filters_by_category() {
1688        let registry = EventRegistry::new();
1689        registry.record(sample_event());
1690        registry.record(plugin_event());
1691
1692        let results = registry
1693            .query(&EventQuery { category: Some(EventCategory::Plugin), ..Default::default() });
1694        assert_eq!(results.len(), 1);
1695        assert_eq!(results[0].category(), EventCategory::Plugin);
1696    }
1697
1698    #[test]
1699    fn query_filters_by_min_severity() {
1700        let registry = EventRegistry::new();
1701        registry.record(sample_event()); // Info
1702        let error_event = RazeriEvent::materialization_failed(
1703            "err-1",
1704            sample_timestamp(),
1705            sample_workflow(),
1706            "design-a",
1707            "PostgreSQL",
1708            "connection error",
1709        )
1710        .unwrap();
1711        registry.record(error_event);
1712
1713        let results = registry
1714            .query(&EventQuery { min_severity: Some(EventSeverity::Error), ..Default::default() });
1715        assert_eq!(results.len(), 1);
1716        assert_eq!(results[0].severity(), EventSeverity::Error);
1717    }
1718
1719    #[test]
1720    fn query_filters_by_workflow_id() {
1721        let registry = EventRegistry::new();
1722        registry.record(sample_event()); // workflow-123
1723        registry.record(plugin_event()); // no workflow
1724
1725        let results = registry.query(&EventQuery {
1726            workflow_id: Some("workflow-123".to_string()),
1727            ..Default::default()
1728        });
1729        assert_eq!(results.len(), 1);
1730    }
1731
1732    #[test]
1733    fn query_respects_limit() {
1734        let registry = EventRegistry::new();
1735        registry.record(sample_event());
1736        registry.record(plugin_event());
1737
1738        let results = registry.query(&EventQuery { limit: Some(1), ..Default::default() });
1739        assert_eq!(results.len(), 1);
1740    }
1741
1742    // ── Snapshot tests ──────────────────────────────────────────────────
1743
1744    #[test]
1745    fn snapshot_profiling_completed_json_shape() {
1746        let event = sample_event();
1747        let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1748        let obj = val.as_object().unwrap();
1749
1750        // Envelope fields
1751        assert!(obj.contains_key("event_id"));
1752        assert!(obj.contains_key("timestamp"));
1753        assert!(obj.contains_key("category"));
1754        assert!(obj.contains_key("severity"));
1755        assert!(obj.contains_key("workflow"));
1756        assert!(obj.contains_key("subject"));
1757        assert!(obj.contains_key("payload"));
1758
1759        // Payload is internally tagged with "kind"
1760        let payload = obj["payload"].as_object().unwrap();
1761        assert_eq!(payload["kind"], "profiling_completed");
1762        assert!(payload.contains_key("source"));
1763        assert!(payload.contains_key("fields"));
1764        assert!(payload.contains_key("rows"));
1765        assert!(payload.contains_key("duration_ms"));
1766
1767        // Subject has kind+name
1768        let subject = obj["subject"].as_object().unwrap();
1769        assert_eq!(subject["kind"], "source");
1770        assert!(subject.contains_key("name"));
1771    }
1772
1773    #[test]
1774    fn snapshot_event_round_trip_preserves_identity() {
1775        let event = sample_event();
1776        let json = serde_json::to_string(&event).unwrap();
1777        let roundtrip: RazeriEvent = serde_json::from_str(&json).unwrap();
1778        assert_eq!(roundtrip.event_id(), event.event_id());
1779        assert_eq!(roundtrip.category(), event.category());
1780        assert_eq!(roundtrip.severity(), event.severity());
1781        assert_eq!(roundtrip.subject().name, event.subject().name);
1782    }
1783
1784    #[test]
1785    fn snapshot_materialization_failed_json_shape() {
1786        let event = RazeriEvent::materialization_failed(
1787            "snap-mat-fail",
1788            sample_timestamp(),
1789            sample_workflow(),
1790            "sales_design",
1791            "duckdb",
1792            "type mismatch",
1793        )
1794        .unwrap();
1795        let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1796        let payload = val["payload"].as_object().unwrap();
1797        assert_eq!(payload["kind"], "materialization_failed");
1798        assert_eq!(payload["design"], "sales_design");
1799        assert_eq!(payload["platform"], "duckdb");
1800        assert_eq!(payload["message"], "type mismatch");
1801    }
1802
1803    #[test]
1804    fn snapshot_plugin_loaded_json_shape() {
1805        let event = plugin_event();
1806        let val: serde_json::Value = serde_json::to_value(&event).unwrap();
1807        let payload = val["payload"].as_object().unwrap();
1808        assert_eq!(payload["kind"], "plugin_loaded");
1809        assert!(payload.contains_key("name"));
1810        assert!(payload.contains_key("plugin_type"));
1811        assert!(payload.contains_key("source"));
1812    }
1813
1814    #[test]
1815    fn snapshot_event_summary_format_is_stable() {
1816        let event = sample_event();
1817        let summary = event.summary();
1818        assert_eq!(summary.title, "Profiling completed");
1819        assert!(summary.detail.contains("test.csv"));
1820        assert!(summary.detail.contains("10"));
1821        assert!(summary.detail.contains("100"));
1822    }
1823
1824    #[test]
1825    fn snapshot_filter_keys_are_stable() {
1826        let event = sample_event();
1827        let keys = event.filter_keys();
1828        assert_eq!(keys.category, EventCategory::Profiling);
1829        assert_eq!(keys.severity, EventSeverity::Info);
1830        assert_eq!(keys.workflow_id.as_deref(), Some("workflow-123"));
1831        assert_eq!(keys.subject_name, "test.csv");
1832    }
1833}