Skip to main content

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