saluki_components/sources/checks_ipc/
mod.rs

1use std::sync::LazyLock;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use datadog_protos::checks::{
6    check_data::Data,
7    checks_server::{Checks, ChecksServer},
8    event::{AlertType as ProtoAlertType, Event as ProtoEvent, Priority as ProtoPriority},
9    log::{Log as ProtoLog, LogLevel},
10    metric::{Metric as ProtoMetric, MetricType},
11    service_check::{ServiceCheck as ProtoServiceCheck, Status as ServiceCheckStatus},
12    SendCheckPayloadRequest, SendCheckPayloadResponse,
13};
14use saluki_context::tags::{Tag, TagSet};
15use saluki_context::Context;
16use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
17use saluki_core::data_model::event::eventd::{AlertType, EventD, Priority};
18use saluki_core::data_model::event::log::Log;
19use saluki_core::data_model::event::metric::Metric;
20use saluki_core::data_model::event::service_check::{CheckStatus, ServiceCheck};
21use saluki_core::data_model::event::{Event, EventType};
22use saluki_core::runtime;
23use saluki_core::topology::OutputDefinition;
24use saluki_core::{
25    components::{sources::*, BuildContext},
26    data_model::event::log::LogStatus,
27};
28use saluki_error::{generic_error, GenericError};
29use saluki_io::net::{
30    server::http::{Http2Config, HttpServer},
31    ListenAddress,
32};
33use stringtheory::MetaString;
34use tokio::sync::mpsc;
35use tokio::{pin, select};
36use tonic::{Response, Status};
37use tracing::{debug, trace, warn};
38
39/// Checks IPC source.
40#[derive(Debug)]
41pub struct ChecksIPCConfiguration {
42    default_hostname: MetaString,
43    grpc_endpoint: ListenAddress,
44}
45
46impl ChecksIPCConfiguration {
47    /// Creates a new `ChecksIPCConfiguration` from the resolved endpoint and default hostname.
48    pub fn new(grpc_endpoint: ListenAddress, default_hostname: impl Into<MetaString>) -> Self {
49        Self {
50            default_hostname: default_hostname.into(),
51            grpc_endpoint,
52        }
53    }
54}
55
56#[async_trait]
57impl SourceBuilder for ChecksIPCConfiguration {
58    fn outputs(&self) -> &[OutputDefinition<EventType>] {
59        static OUTPUTS: LazyLock<Vec<OutputDefinition<EventType>>> = LazyLock::new(|| {
60            vec![
61                OutputDefinition::named_output("metrics", EventType::Metric),
62                OutputDefinition::named_output("logs", EventType::Log),
63                OutputDefinition::named_output("events", EventType::EventD),
64                OutputDefinition::named_output("service_checks", EventType::ServiceCheck),
65            ]
66        });
67
68        &OUTPUTS
69    }
70
71    async fn build(&self, _context: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
72        Ok(Box::new(ChecksIPC {
73            grpc_endpoint: self.grpc_endpoint.clone(),
74            default_hostname: self.default_hostname.clone(),
75        }))
76    }
77}
78
79impl MemoryBounds for ChecksIPCConfiguration {
80    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
81        // Capture the size of the heap allocation when the component is built.
82        builder.minimum().with_single_value::<ChecksIPC>("checks_ipc");
83    }
84}
85
86struct ChecksIPC {
87    grpc_endpoint: ListenAddress,
88    default_hostname: MetaString,
89}
90
91#[async_trait]
92impl Source for ChecksIPC {
93    async fn run(self: Box<Self>, mut context: SourceContext) -> Result<(), GenericError> {
94        let ChecksIPC {
95            grpc_endpoint,
96            default_hostname,
97        } = *self;
98
99        let global_shutdown = context.take_shutdown_handle();
100        pin!(global_shutdown);
101
102        let mut health = context.take_health_handle();
103
104        let (events_tx, mut events_rx) = mpsc::channel(16);
105
106        let ListenAddress::Tcp(grpc_socket_addr) = grpc_endpoint else {
107            return Err(generic_error!("Checks IPC gRPC endpoint must be a TCP address."));
108        };
109
110        // This endpoint only ever speaks gRPC, so it is restricted to HTTP/2: an HTTP/1.1 caller here is a client bug,
111        // and rejecting it at the protocol level says so more clearly than routing it and answering with a 404.
112        let grpc_server = HttpServer::from_listen_address(ListenAddress::Tcp(grpc_socket_addr))
113            .add_grpc_service(ChecksServer::new(ChecksService {
114                events_tx,
115                default_hostname,
116            }))
117            .with_http2_only()
118            .with_http2_config(Http2Config::grpc_defaults())
119            .with_worker_pool(context.topology_context().global_thread_pool().clone());
120
121        runtime::nested_supervisor(grpc_server.into_supervisor()).spawn();
122
123        health.mark_ready();
124        debug!("Checks IPC source started.");
125
126        loop {
127            select! {
128                _ = &mut global_shutdown => {
129                    debug!("Received shutdown signal.");
130                    break;
131                },
132                _ = health.live() => continue,
133                Some(event) = events_rx.recv() => {
134                    let output_name = match &event {
135                        Event::Metric(_) => "metrics",
136                        Event::Log(_) => "logs",
137                        Event::EventD(_) => "events",
138                        Event::ServiceCheck(_) => "service_checks",
139                        _ => continue,
140                    };
141
142                    if let Err(e) = context.dispatcher().dispatch_one_named(output_name, event).await {
143                        warn!("Failed to dispatch {output_name} event: {:?}", e);
144                    }
145                },
146            }
147        }
148
149        debug!("Checks IPC source stopped.");
150        Ok(())
151    }
152}
153
154struct ChecksService {
155    events_tx: mpsc::Sender<Event>,
156    default_hostname: MetaString,
157}
158
159#[async_trait]
160impl Checks for ChecksService {
161    async fn send_check_payload(
162        &self, request: tonic::Request<SendCheckPayloadRequest>,
163    ) -> Result<Response<SendCheckPayloadResponse>, Status> {
164        trace!("Received check payload.");
165
166        let payload = request.into_inner();
167        for check_data in payload.data.into_iter().filter_map(|data| data.data) {
168            let Some(event) = check_data_to_event(check_data, &self.default_hostname) else {
169                continue;
170            };
171
172            if let Err(e) = self.events_tx.send(event).await {
173                warn!("Failed to send check event: {:?}", e);
174            }
175        }
176
177        Ok(Response::new(SendCheckPayloadResponse {}))
178    }
179}
180
181fn check_data_to_event(check_data: Data, default_hostname: &MetaString) -> Option<Event> {
182    // Each arm exhaustively destructures its proto message (no `..`) so adding a new field
183    // upstream becomes a compile error here until it's mapped or explicitly ignored.
184    match check_data {
185        Data::Metric(metric) => {
186            let ProtoMetric {
187                r#type,
188                name,
189                value,
190                timestamp,
191                tags,
192                hostname,
193                interval_secs,
194            } = metric;
195
196            let metric_type = MetricType::try_from(r#type).ok()?;
197
198            let tags = tags.into_iter().map(Tag::from).collect::<TagSet>();
199            let mut context = Context::from_parts(name, tags.into_shared());
200            let hostname = if hostname.is_empty() {
201                default_hostname.clone()
202            } else {
203                MetaString::from(hostname)
204            };
205            context = context.with_host(Some(hostname));
206            let metric = match metric_type {
207                MetricType::Counter => Metric::counter(context, (timestamp, value)),
208                MetricType::Gauge => Metric::gauge(context, (timestamp, value)),
209                MetricType::Rate => {
210                    if interval_secs == 0 {
211                        warn!("Received rate metric from check with interval of zero. Skipping.");
212                        return None;
213                    }
214                    Metric::rate(context, (timestamp, value), Duration::from_secs(interval_secs))
215                }
216                MetricType::Histogram => Metric::histogram(context, (timestamp, value)),
217                MetricType::Unspecified => {
218                    warn!("Received metric with unspecified type. Skipping.");
219                    return None;
220                }
221            };
222            Some(Event::Metric(metric))
223        }
224        Data::Log(log) => {
225            let ProtoLog { message, level } = log;
226
227            let level = LogLevel::try_from(level).ok()?;
228            let status = log_level_to_log_status(level);
229
230            Some(Event::Log(Log::new(message).with_status(status)))
231        }
232        Data::Event(event) => {
233            let ProtoEvent {
234                title,
235                text,
236                priority,
237                hostname,
238                tags,
239                alert_type,
240                aggregation_key,
241                source_type_name,
242                timestamp,
243            } = event;
244
245            let tags = tags.into_iter().map(Tag::from).collect::<TagSet>();
246            let mut eventd = EventD::new(title, text)
247                .with_timestamp(timestamp)
248                .with_tags(tags.into_shared());
249
250            if !hostname.is_empty() {
251                eventd.set_hostname(MetaString::from(hostname));
252            }
253            if !aggregation_key.is_empty() {
254                eventd.set_aggregation_key(MetaString::from(aggregation_key));
255            }
256            if !source_type_name.is_empty() {
257                eventd.set_source_type_name(MetaString::from(source_type_name));
258            }
259            if let Some(p) = ProtoPriority::try_from(priority)
260                .ok()
261                .and_then(proto_priority_to_priority)
262            {
263                eventd.set_priority(p);
264            }
265            if let Some(a) = ProtoAlertType::try_from(alert_type)
266                .ok()
267                .and_then(proto_alert_type_to_alert_type)
268            {
269                eventd.set_alert_type(a);
270            }
271            Some(Event::EventD(eventd))
272        }
273        Data::ServiceCheck(sc) => {
274            let ProtoServiceCheck {
275                status,
276                name,
277                message,
278                tags,
279                hostname,
280            } = sc;
281
282            let Some(status) = ServiceCheckStatus::try_from(status)
283                .ok()
284                .and_then(service_check_status_to_check_status)
285            else {
286                warn!(
287                    "Received service check with unspecified or invalid status: {}. Skipping.",
288                    status
289                );
290                return None;
291            };
292            let tags = tags.into_iter().map(Tag::from).collect::<TagSet>();
293            let mut service_check = ServiceCheck::new(name, status)
294                .with_message(MetaString::from(message))
295                .with_tags(tags.into_shared());
296            if !hostname.is_empty() {
297                service_check.set_hostname(MetaString::from(hostname));
298            }
299            Some(Event::ServiceCheck(service_check))
300        }
301    }
302}
303
304fn log_level_to_log_status(log_level: LogLevel) -> LogStatus {
305    match log_level {
306        LogLevel::Trace => LogStatus::Trace,
307        LogLevel::Debug => LogStatus::Debug,
308        LogLevel::Info => LogStatus::Info,
309        LogLevel::Warning => LogStatus::Warning,
310        LogLevel::Error => LogStatus::Error,
311        LogLevel::Critical => LogStatus::Emergency,
312        _ => LogStatus::Info,
313    }
314}
315
316fn service_check_status_to_check_status(status: ServiceCheckStatus) -> Option<CheckStatus> {
317    match status {
318        ServiceCheckStatus::Ok => Some(CheckStatus::Ok),
319        ServiceCheckStatus::Warning => Some(CheckStatus::Warning),
320        ServiceCheckStatus::Critical => Some(CheckStatus::Critical),
321        ServiceCheckStatus::Unknown => Some(CheckStatus::Unknown),
322        ServiceCheckStatus::Unspecified => None,
323    }
324}
325
326fn proto_priority_to_priority(priority: ProtoPriority) -> Option<Priority> {
327    match priority {
328        ProtoPriority::Normal => Some(Priority::Normal),
329        ProtoPriority::Low => Some(Priority::Low),
330        ProtoPriority::Unspecified => None,
331    }
332}
333
334fn proto_alert_type_to_alert_type(alert_type: ProtoAlertType) -> Option<AlertType> {
335    match alert_type {
336        ProtoAlertType::Info => Some(AlertType::Info),
337        ProtoAlertType::Error => Some(AlertType::Error),
338        ProtoAlertType::Warning => Some(AlertType::Warning),
339        ProtoAlertType::Success => Some(AlertType::Success),
340        ProtoAlertType::Unspecified => None,
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use datadog_protos::checks::{
347        check_data::Data,
348        event::Event as ProtoEvent,
349        log::Log as ProtoLog,
350        metric::{Metric as ProtoMetric, MetricType as ProtoMetricType},
351        service_check::{ServiceCheck as ProtoServiceCheck, Status as ProtoServiceCheckStatus},
352    };
353    use saluki_core::data_model::event::metric::MetricValues;
354
355    use super::*;
356
357    fn metric_data(
358        r#type: i32, name: &str, value: f64, timestamp: u64, interval_secs: u64, tags: &[&str], hostname: &str,
359    ) -> Data {
360        Data::Metric(ProtoMetric {
361            r#type,
362            name: name.to_string(),
363            value,
364            timestamp,
365            tags: tags.iter().map(|t| (*t).to_string()).collect(),
366            hostname: hostname.to_string(),
367            interval_secs,
368        })
369    }
370
371    fn log_data(level: i32, message: &str) -> Data {
372        Data::Log(ProtoLog {
373            message: message.to_string(),
374            level,
375        })
376    }
377
378    fn event_data(title: &str, text: &str, timestamp: u64, tags: &[&str], hostname: &str) -> Data {
379        Data::Event(ProtoEvent {
380            title: title.to_string(),
381            text: text.to_string(),
382            priority: 0,
383            hostname: hostname.to_string(),
384            tags: tags.iter().map(|t| (*t).to_string()).collect(),
385            alert_type: 0,
386            aggregation_key: String::new(),
387            source_type_name: String::new(),
388            timestamp,
389        })
390    }
391
392    fn service_check_data(status: i32, name: &str, message: &str, tags: &[&str], hostname: &str) -> Data {
393        Data::ServiceCheck(ProtoServiceCheck {
394            status,
395            name: name.to_string(),
396            message: message.to_string(),
397            tags: tags.iter().map(|t| (*t).to_string()).collect(),
398            hostname: hostname.to_string(),
399        })
400    }
401
402    fn check_data_to_event_for_tests(check_data: Data) -> Option<Event> {
403        check_data_to_event(check_data, &MetaString::from_static("default-host"))
404    }
405
406    #[test]
407    fn metric_counter_conversion() {
408        let event = check_data_to_event_for_tests(metric_data(
409            ProtoMetricType::Counter as i32,
410            "my_counter",
411            1.0,
412            1234,
413            0,
414            &["tag1:value1", "tag2:value2"],
415            "",
416        ))
417        .expect("counter should convert");
418
419        let Event::Metric(metric) = event else {
420            panic!("expected Metric event");
421        };
422        assert_eq!(metric.context().name().as_ref(), "my_counter");
423        assert!(metric.context().tags().has_tag("tag1:value1"));
424        assert!(metric.context().tags().has_tag("tag2:value2"));
425        assert!(matches!(metric.values(), MetricValues::Counter(_)));
426    }
427
428    #[test]
429    fn metric_gauge_conversion() {
430        let event = check_data_to_event_for_tests(metric_data(
431            ProtoMetricType::Gauge as i32,
432            "my_gauge",
433            42.0,
434            1234,
435            0,
436            &[],
437            "",
438        ))
439        .expect("gauge should convert");
440        let Event::Metric(metric) = event else {
441            panic!("expected Metric event");
442        };
443        assert!(matches!(metric.values(), MetricValues::Gauge(_)));
444    }
445
446    #[test]
447    fn metric_histogram_conversion() {
448        let event = check_data_to_event_for_tests(metric_data(
449            ProtoMetricType::Histogram as i32,
450            "my_hist",
451            1.0,
452            1234,
453            0,
454            &[],
455            "",
456        ))
457        .expect("histogram should convert");
458        let Event::Metric(metric) = event else {
459            panic!("expected Metric event");
460        };
461        assert!(matches!(metric.values(), MetricValues::Histogram(_)));
462    }
463
464    #[test]
465    fn metric_rate_conversion_uses_interval() {
466        let event = check_data_to_event_for_tests(metric_data(
467            ProtoMetricType::Rate as i32,
468            "my_rate",
469            10.0,
470            1234,
471            60,
472            &[],
473            "",
474        ))
475        .expect("rate should convert");
476        let Event::Metric(metric) = event else {
477            panic!("expected Metric event");
478        };
479        match metric.values() {
480            MetricValues::Rate(_, interval) => assert_eq!(*interval, Duration::from_secs(60)),
481            other => panic!("expected Rate values, got {other:?}"),
482        }
483    }
484
485    #[test]
486    fn metric_rate_with_zero_interval_is_skipped() {
487        let event = check_data_to_event_for_tests(metric_data(
488            ProtoMetricType::Rate as i32,
489            "my_rate",
490            10.0,
491            1234,
492            0,
493            &[],
494            "",
495        ));
496        assert!(event.is_none(), "rate with zero interval must be skipped");
497    }
498
499    #[test]
500    fn metric_unspecified_type_is_skipped() {
501        let event = check_data_to_event_for_tests(metric_data(
502            ProtoMetricType::Unspecified as i32,
503            "x",
504            1.0,
505            1234,
506            0,
507            &[],
508            "",
509        ));
510        assert!(event.is_none(), "unspecified metric type must be skipped");
511    }
512
513    #[test]
514    fn metric_unknown_type_is_skipped() {
515        // Any i32 outside the proto enum range fails MetricType::try_from.
516        let event = check_data_to_event_for_tests(metric_data(99, "x", 1.0, 1234, 0, &[], ""));
517        assert!(event.is_none(), "unknown metric type must be skipped");
518    }
519
520    #[test]
521    fn log_unknown_level_is_skipped() {
522        // 99 is not part of the LogLevel proto enum, so try_from returns Err.
523        let event = check_data_to_event_for_tests(log_data(99, "hello"));
524        assert!(event.is_none(), "unknown log level must be skipped");
525    }
526
527    #[test]
528    fn event_conversion_preserves_fields() {
529        let event = check_data_to_event_for_tests(event_data("title", "body", 1234, &["env:prod", "team:foo"], ""))
530            .expect("event should convert");
531        let Event::EventD(ev) = event else {
532            panic!("expected EventD event");
533        };
534        assert_eq!(ev.title(), "title");
535        assert_eq!(ev.text(), "body");
536        assert_eq!(ev.timestamp(), Some(1234));
537        assert!(ev.tags().has_tag("env:prod"));
538        assert!(ev.tags().has_tag("team:foo"));
539    }
540
541    #[test]
542    fn service_check_status_mapping() {
543        let cases = [
544            (ProtoServiceCheckStatus::Ok, CheckStatus::Ok),
545            (ProtoServiceCheckStatus::Warning, CheckStatus::Warning),
546            (ProtoServiceCheckStatus::Critical, CheckStatus::Critical),
547            (ProtoServiceCheckStatus::Unknown, CheckStatus::Unknown),
548        ];
549
550        for (proto_status, expected) in cases {
551            let event = check_data_to_event_for_tests(service_check_data(proto_status as i32, "n", "m", &[], ""))
552                .unwrap_or_else(|| panic!("status {proto_status:?} should convert"));
553            let Event::ServiceCheck(sc) = event else {
554                panic!("expected ServiceCheck event for {proto_status:?}");
555            };
556            assert_eq!(sc.status(), expected, "status {proto_status:?}");
557        }
558    }
559
560    #[test]
561    fn service_check_unspecified_status_is_skipped() {
562        let event = check_data_to_event_for_tests(service_check_data(
563            ProtoServiceCheckStatus::Unspecified as i32,
564            "n",
565            "m",
566            &[],
567            "",
568        ));
569        assert!(event.is_none(), "service check with unspecified status must be skipped");
570    }
571
572    #[test]
573    fn service_check_unknown_status_value_is_skipped() {
574        // 99 is outside the proto Status enum, so try_from returns Err.
575        let event = check_data_to_event_for_tests(service_check_data(99, "n", "m", &[], ""));
576        assert!(
577            event.is_none(),
578            "service check with out-of-range status must be skipped"
579        );
580    }
581
582    #[test]
583    fn service_check_preserves_name_message_and_tags() {
584        let event = check_data_to_event_for_tests(service_check_data(
585            ProtoServiceCheckStatus::Ok as i32,
586            "my.check",
587            "all good",
588            &["env:prod"],
589            "",
590        ))
591        .expect("service check should convert");
592        let Event::ServiceCheck(sc) = event else {
593            panic!("expected ServiceCheck event");
594        };
595        assert_eq!(sc.name(), "my.check");
596        assert_eq!(sc.status(), CheckStatus::Ok);
597        assert_eq!(sc.message(), Some("all good"));
598        assert!(sc.tags().has_tag("env:prod"));
599    }
600
601    #[test]
602    fn metric_hostname_propagates() {
603        let event = check_data_to_event_for_tests(metric_data(
604            ProtoMetricType::Counter as i32,
605            "n",
606            1.0,
607            0,
608            0,
609            &[],
610            "host-a",
611        ))
612        .expect("metric should convert");
613        let Event::Metric(m) = event else {
614            panic!("expected Metric event");
615        };
616        assert_eq!(m.context().host(), Some("host-a"));
617    }
618
619    #[test]
620    fn metric_empty_hostname_uses_default_host() {
621        let event =
622            check_data_to_event_for_tests(metric_data(ProtoMetricType::Counter as i32, "n", 1.0, 0, 0, &[], ""))
623                .expect("metric should convert");
624        let Event::Metric(m) = event else {
625            panic!("expected Metric event");
626        };
627        assert_eq!(m.context().host(), Some("default-host"));
628    }
629
630    #[test]
631    fn eventd_hostname_propagates() {
632        let event =
633            check_data_to_event_for_tests(event_data("title", "body", 0, &[], "host-b")).expect("event should convert");
634        let Event::EventD(ev) = event else {
635            panic!("expected EventD event");
636        };
637        assert_eq!(ev.hostname(), Some("host-b"));
638    }
639
640    #[test]
641    fn eventd_empty_hostname_stays_unset() {
642        let event =
643            check_data_to_event_for_tests(event_data("title", "body", 0, &[], "")).expect("event should convert");
644        let Event::EventD(ev) = event else {
645            panic!("expected EventD event");
646        };
647        assert_eq!(ev.hostname(), None);
648    }
649
650    #[test]
651    fn service_check_hostname_propagates() {
652        let event = check_data_to_event_for_tests(service_check_data(
653            ProtoServiceCheckStatus::Ok as i32,
654            "n",
655            "m",
656            &[],
657            "host-c",
658        ))
659        .expect("service check should convert");
660        let Event::ServiceCheck(sc) = event else {
661            panic!("expected ServiceCheck event");
662        };
663        assert_eq!(sc.hostname(), Some("host-c"));
664    }
665
666    #[test]
667    fn service_check_empty_hostname_stays_unset() {
668        let event = check_data_to_event_for_tests(service_check_data(
669            ProtoServiceCheckStatus::Ok as i32,
670            "n",
671            "m",
672            &[],
673            "",
674        ))
675        .expect("service check should convert");
676        let Event::ServiceCheck(sc) = event else {
677            panic!("expected ServiceCheck event");
678        };
679        assert_eq!(sc.hostname(), None);
680    }
681
682    #[test]
683    fn eventd_priority_propagates() {
684        let event = check_data_to_event_for_tests(Data::Event(ProtoEvent {
685            priority: ProtoPriority::Low as i32,
686            ..Default::default()
687        }))
688        .expect("event should convert");
689        let Event::EventD(ev) = event else {
690            panic!("expected EventD event");
691        };
692        assert_eq!(ev.priority(), Some(Priority::Low));
693    }
694
695    #[test]
696    fn eventd_alert_type_propagates() {
697        let event = check_data_to_event_for_tests(Data::Event(ProtoEvent {
698            alert_type: ProtoAlertType::Warning as i32,
699            ..Default::default()
700        }))
701        .expect("event should convert");
702        let Event::EventD(ev) = event else {
703            panic!("expected EventD event");
704        };
705        assert_eq!(ev.alert_type(), Some(AlertType::Warning));
706    }
707
708    #[test]
709    fn eventd_aggregation_key_propagates() {
710        let event = check_data_to_event_for_tests(Data::Event(ProtoEvent {
711            aggregation_key: "agg-key-1".to_string(),
712            ..Default::default()
713        }))
714        .expect("event should convert");
715        let Event::EventD(ev) = event else {
716            panic!("expected EventD event");
717        };
718        assert_eq!(ev.aggregation_key(), Some("agg-key-1"));
719    }
720
721    #[test]
722    fn eventd_source_type_name_propagates() {
723        let event = check_data_to_event_for_tests(Data::Event(ProtoEvent {
724            source_type_name: "my-source".to_string(),
725            ..Default::default()
726        }))
727        .expect("event should convert");
728        let Event::EventD(ev) = event else {
729            panic!("expected EventD event");
730        };
731        assert_eq!(ev.source_type_name(), Some("my-source"));
732    }
733
734    #[test]
735    fn eventd_unspecified_proto_keeps_saluki_defaults() {
736        // A default-initialized ProtoEvent has priority=0 (Unspecified), alert_type=0 (Unspecified),
737        // and all strings empty. Our mapping treats Unspecified as "source did not set it", so
738        // `EventD::new`'s defaults (priority=Normal, alert_type=Info) survive, while the empty
739        // string fields stay unset.
740        let event = check_data_to_event_for_tests(Data::Event(ProtoEvent::default())).expect("event should convert");
741        let Event::EventD(ev) = event else {
742            panic!("expected EventD event");
743        };
744        assert_eq!(ev.priority(), Some(Priority::Normal));
745        assert_eq!(ev.alert_type(), Some(AlertType::Info));
746        assert_eq!(ev.aggregation_key(), None);
747        assert_eq!(ev.source_type_name(), None);
748        assert_eq!(ev.hostname(), None);
749    }
750}