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#[derive(Debug)]
41pub struct ChecksIPCConfiguration {
42 default_hostname: MetaString,
43 grpc_endpoint: ListenAddress,
44}
45
46impl ChecksIPCConfiguration {
47 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 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 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 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 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 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 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 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}