1use std::collections::HashMap;
8use std::sync::OnceLock;
9use std::{sync::Arc, time::Duration};
10
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use datadog_agent_commons::ipc::{
14 client::RemoteAgentClient,
15 config::RemoteAgentClientConfiguration,
16 session::{SessionId, SessionIdHandle},
17};
18use datadog_protos::agent::v1::{
19 event::Details as RemoteAgentEventDetails, Event as RemoteAgentEvent, InvalidApiKeyEvent,
20 ReportRemoteAgentEventRequest,
21};
22use datadog_protos::agent::{
23 config_event,
24 flare::v1::{flare_provider_server::*, *},
25 status::v1::{status_provider_server::*, *},
26 telemetry::v1::{get_telemetry_response::*, telemetry_provider_server::*, *},
27 ConfigSetting as AgentConfigSetting, ConfigSnapshot,
28};
29use futures::StreamExt;
30use process_memory::Querier as MemoryQuerier;
31use prost_types::value::Kind;
32use saluki_api::{DynamicRoute, EndpointType};
33use saluki_common::sync::shutdown::ShutdownHandle;
34use saluki_common::task::spawn_traced_named;
35use saluki_config::dynamic::{ConfigSetting, ConfigUpdate, Provenance};
36use saluki_core::{
37 diagnostic::{subscribe_events, DiagnosticCollector, DiagnosticDetails, DiagnosticEvent},
38 observability::metrics::{
39 get_shared_metrics_state, AggregatedMetricsProcessor, AggregatedMetricsState, Reflector, RemapperRule,
40 TelemetryProcessor,
41 },
42 runtime::{
43 state::{DataspaceRegistry, DataspaceUpdate, IdentifierFilter, Subscription},
44 InitializationError, Supervisable, SupervisorFuture,
45 },
46};
47use saluki_error::{generic_error, GenericError};
48use saluki_io::net::{GrpcTargetAddress, ListenAddress};
49use serde_json::{Map, Value};
50use tokio::task::spawn_blocking;
51use tokio::time::{timeout, Instant};
52use tokio::{
53 select,
54 sync::{mpsc, oneshot, Mutex},
55 time::{interval, MissedTickBehavior},
56};
57use tonic::{server::NamedService, service::Routes, Status};
58use tracing::{debug, error, info, warn};
59
60mod status;
61pub use self::status::{StatusBuilder, StatusSectionProvider, StatusSectionWriter};
62
63const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
64const REFRESH_FAILED_RETRY_INTERVAL: Duration = Duration::from_secs(5);
65
66const SESSION_ID_METADATA_KEY: &str = "session_id";
67
68pub struct RemoteAgentBootstrap {
73 client: RemoteAgentClient,
74 session_id: SessionIdHandle,
75 internal_metrics: Reflector<AggregatedMetricsProcessor>,
76 dataspace: Arc<OnceLock<DataspaceRegistry>>,
77}
78
79impl RemoteAgentBootstrap {
80 pub async fn new(
100 client_config: &RemoteAgentClientConfiguration, secure_api_listen_address: &ListenAddress,
101 ) -> Result<Self, GenericError> {
102 let api_listen_addr = GrpcTargetAddress::try_from_listen_addr(secure_api_listen_address)
103 .ok_or_else(|| generic_error!("Failed to get valid gRPC target address from secure API listen address."))?;
104
105 let service_names = vec![
107 <StatusProviderServer<()> as NamedService>::NAME.to_string(),
108 <FlareProviderServer<()> as NamedService>::NAME.to_string(),
109 <TelemetryProviderServer<()> as NamedService>::NAME.to_string(),
110 ];
111
112 let (state, init_reg_rx) = RemoteAgentState::new(api_listen_addr, service_names);
113 let session_id = state.session_id.clone();
114
115 let client = RemoteAgentClient::connect(client_config).await?;
119 let task_name = format!("{}-remote-agent-task", saluki_metadata::get_app_details().identifier());
120 spawn_traced_named(task_name, run_remote_agent_registration_loop(client.clone(), state));
121
122 match init_reg_rx.await {
123 Ok(Ok(())) => (),
124 Ok(Err(e)) => return Err(e),
125 Err(_) => {
126 return Err(generic_error!(
127 "Failed to initialize remote agent state. Registration task failed unexpectedly."
128 ))
129 }
130 }
131
132 Ok(Self {
133 client,
134 session_id,
135 internal_metrics: get_shared_metrics_state(),
136 dataspace: Arc::new(OnceLock::new()),
137 })
138 }
139
140 fn build_impl(
141 &self, status_sections: Vec<Arc<dyn StatusSectionProvider>>, remapper_rules: Vec<RemapperRule>,
142 ) -> RemoteAgentImpl {
143 RemoteAgentImpl {
144 started: Utc::now(),
145 internal_metrics: self.internal_metrics.clone(),
146 processor: telemetry_processor(remapper_rules),
147 status_sections,
148 session_id: self.session_id.clone(),
149 dataspace: Arc::clone(&self.dataspace),
150 }
151 }
152
153 pub fn create_dataspace_anchor(&self) -> DataspaceAnchorWorker {
161 DataspaceAnchorWorker {
162 dataspace: Arc::clone(&self.dataspace),
163 }
164 }
165
166 pub fn create_event_reporter(&self) -> RemoteAgentEventReporter {
168 RemoteAgentEventReporter {
169 client: self.client.clone(),
170 session_id: self.session_id.clone(),
171 }
172 }
173
174 pub fn create_services_worker(
184 &self, status_sections: Vec<Arc<dyn StatusSectionProvider>>, telemetry_rules: Vec<RemapperRule>,
185 ) -> RemoteAgentServicesWorker {
186 let routes = Routes::new(StatusProviderServer::new(self.build_impl(status_sections, Vec::new())))
187 .add_service(FlareProviderServer::new(self.build_impl(Vec::new(), Vec::new())))
188 .add_service(TelemetryProviderServer::new(
189 self.build_impl(Vec::new(), telemetry_rules),
190 ));
191
192 RemoteAgentServicesWorker { routes }
193 }
194
195 pub fn create_config_stream(&self) -> mpsc::Receiver<ConfigUpdate> {
201 let (sender, receiver) = mpsc::channel(100);
202
203 let client = self.client.clone();
204 let session_id = self.session_id.clone();
205
206 tokio::spawn(run_config_stream_event_loop(client, sender, session_id));
207
208 receiver
209 }
210}
211
212struct RemoteAgentState {
213 pid: u32,
214 display_name: String,
215 flavor: String,
216 api_listen_addr: String,
217 session_id: SessionIdHandle,
218 service_names: Vec<String>,
219 initial_registration_tx: Option<oneshot::Sender<Result<(), GenericError>>>,
220}
221
222impl RemoteAgentState {
223 fn new(
224 api_listen_addr: GrpcTargetAddress, service_names: Vec<String>,
225 ) -> (Self, oneshot::Receiver<Result<(), GenericError>>) {
226 let app_details = saluki_metadata::get_app_details();
227 let display_name = app_details.full_name().to_string();
228 let flavor = app_details.full_name().replace(" ", "_").to_lowercase();
229
230 let (init_reg_tx, init_reg_rx) = oneshot::channel();
231
232 let state = Self {
233 pid: std::process::id(),
234 display_name,
235 flavor,
236 api_listen_addr: api_listen_addr.to_string(),
237 session_id: SessionIdHandle::empty(),
238 service_names,
239 initial_registration_tx: Some(init_reg_tx),
240 };
241
242 (state, init_reg_rx)
243 }
244}
245
246async fn run_remote_agent_registration_loop(mut client: RemoteAgentClient, mut state: RemoteAgentState) {
247 let mut loop_timer = interval(DEFAULT_REFRESH_INTERVAL);
248 loop_timer.set_missed_tick_behavior(MissedTickBehavior::Delay);
249
250 debug!("Remote Agent registration task started.");
251
252 loop {
253 loop_timer.tick().await;
254
255 match state.session_id.get() {
256 Some(session_id) => {
257 debug!(%session_id, "Refreshing registration with Datadog Agent.");
258
259 if client.refresh_remote_agent(&session_id).await.is_err() {
260 loop_timer.reset_after(REFRESH_FAILED_RETRY_INTERVAL);
261 state.session_id.update(None);
262 warn!("Failed to refresh registration with the Datadog Agent. Resetting session ID and attempting to re-register shortly.");
263
264 continue;
265 }
266 }
267 None => {
268 match client
269 .register_remote_agent(
270 state.pid,
271 &state.display_name,
272 &state.flavor,
273 &state.api_listen_addr,
274 state.service_names.clone(),
275 )
276 .await
277 {
278 Ok(resp) => {
279 let resp = resp.into_inner();
280 let new_session_id = match SessionId::new(&resp.session_id) {
281 Ok(session_id) => session_id,
282 Err(e) => {
283 warn!(error = %e, "Received invalid session ID from Datadog Agent after registation. Registration will be retried periodically in the background.");
284 loop_timer.reset_after(DEFAULT_REFRESH_INTERVAL);
285 continue;
286 }
287 };
288 let new_refresh_interval = resp.recommended_refresh_interval_secs;
289 info!(session_id = %new_session_id, "Successfully registered with the Datadog Agent. Refreshing every {} seconds.", new_refresh_interval);
290
291 state.session_id.update(Some(new_session_id));
292 loop_timer.reset_after(Duration::from_secs(new_refresh_interval as u64));
293
294 if let Some(tx) = state.initial_registration_tx.take() {
295 let _ = tx.send(Ok(()));
296 }
297 }
298 Err(e) => {
299 warn!(error = %e, "Failed to register with the Datadog Agent. Registration will be retried periodically in the background.");
300 loop_timer.reset_after(DEFAULT_REFRESH_INTERVAL);
301
302 if let Some(tx) = state.initial_registration_tx.take() {
303 let _ = tx.send(Err(e));
304 }
305 }
306 }
307 }
308 }
309 }
310}
311
312async fn run_config_stream_event_loop(
313 mut client: RemoteAgentClient, sender: mpsc::Sender<ConfigUpdate>, session_id: SessionIdHandle,
314) {
315 loop {
316 debug!("Establishing a new config stream connection to the Core Agent.");
317
318 let current_session_id = session_id.wait_for_update().await;
322
323 let mut stream = client.stream_config_events(¤t_session_id);
324 while let Some(result) = stream.next().await {
325 match result {
326 Ok(event) => {
327 let update = match event.event {
328 Some(config_event::Event::Snapshot(snapshot)) => {
329 Some(ConfigUpdate::Snapshot(snapshot_to_settings(&snapshot)))
330 }
331 Some(config_event::Event::Update(update)) => update
332 .setting
333 .as_ref()
334 .map(|setting| ConfigUpdate::Partial(setting_to_config_setting(setting))),
335 None => {
336 error!("Received a configuration update event with no data.");
337 None
338 }
339 };
340
341 if let Some(update) = update {
342 if sender.send(update).await.is_err() {
343 warn!("Dynamic configuration channel closed. Config stream shutting down.");
344 return;
345 }
346 }
347 }
348 Err(e) => {
349 error!("Error while reading config event stream: {}.", e);
350 }
351 }
352 }
353
354 debug!("Config stream ended, retrying in 5 seconds...");
355 tokio::time::sleep(Duration::from_secs(5)).await;
356 }
357}
358
359const AGENT_DEFAULT_SOURCE: &str = "default";
361const AGENT_DECLARED_ONLY_SOURCE: &str = "schema";
363const AGENT_UNSET_SOURCES: [&str; 2] = [AGENT_DEFAULT_SOURCE, AGENT_DECLARED_ONLY_SOURCE];
364
365fn setting_to_config_setting(setting: &AgentConfigSetting) -> ConfigSetting {
367 let provenance = if AGENT_UNSET_SOURCES.contains(&setting.source.as_str()) {
368 Provenance::Default
369 } else {
370 Provenance::Explicit
371 };
372
373 ConfigSetting::new(
374 setting.key.clone(),
375 proto_value_to_serde_value(&setting.value),
376 provenance,
377 )
378}
379
380fn snapshot_to_settings(snapshot: &ConfigSnapshot) -> Vec<ConfigSetting> {
382 snapshot.settings.iter().map(setting_to_config_setting).collect()
383}
384
385fn proto_value_to_serde_value(proto_val: &Option<prost_types::Value>) -> Value {
387 let Some(kind) = proto_val.as_ref().and_then(|v| v.kind.as_ref()) else {
388 return Value::Null;
389 };
390
391 match kind {
392 Kind::NullValue(_) => Value::Null,
393 Kind::NumberValue(n) => {
394 if n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
395 Value::from(*n as i64)
396 } else {
397 Value::from(*n)
398 }
399 }
400 Kind::StringValue(s) => Value::String(s.clone()),
401 Kind::BoolValue(b) => Value::Bool(*b),
402 Kind::StructValue(s) => {
403 let json_map: Map<String, Value> = s
404 .fields
405 .iter()
406 .map(|(k, v)| (k.clone(), proto_value_to_serde_value(&Some(v.clone()))))
407 .collect();
408 Value::Object(json_map)
409 }
410 Kind::ListValue(l) => {
411 let json_list: Vec<Value> = l
412 .values
413 .iter()
414 .map(|v| proto_value_to_serde_value(&Some(v.clone())))
415 .collect();
416 Value::Array(json_list)
417 }
418 }
419}
420
421struct RemoteAgentImpl {
423 started: DateTime<Utc>,
424 internal_metrics: Reflector<AggregatedMetricsProcessor>,
425 processor: Option<Mutex<TelemetryProcessor>>,
426 status_sections: Vec<Arc<dyn StatusSectionProvider>>,
427 session_id: SessionIdHandle,
428 dataspace: Arc<OnceLock<DataspaceRegistry>>,
429}
430
431fn telemetry_processor(remapper_rules: Vec<RemapperRule>) -> Option<Mutex<TelemetryProcessor>> {
436 (!remapper_rules.is_empty()).then(|| Mutex::new(TelemetryProcessor::new().with_remapper_rules(remapper_rules)))
437}
438
439async fn render_telemetry(processor: Option<&Mutex<TelemetryProcessor>>, state: &AggregatedMetricsState) -> String {
443 match processor {
444 Some(processor) => processor.lock().await.process(state),
445 None => String::new(),
446 }
447}
448
449impl RemoteAgentImpl {
450 async fn session_id_middleware<Resp, Next>(&self, next: Next) -> Result<tonic::Response<Resp>, Status>
451 where
452 Next: AsyncFnOnce() -> Result<tonic::Response<Resp>, Status>,
453 {
454 let metadata_session_id = self
455 .session_id
456 .get()
457 .ok_or(Status::failed_precondition(
458 "session ID not set; must be registered with Core Agent",
459 ))?
460 .to_grpc_header_value();
461
462 next().await.map(|mut resp| {
463 resp.metadata_mut().append(SESSION_ID_METADATA_KEY, metadata_session_id);
464 resp
465 })
466 }
467}
468
469#[async_trait]
470impl StatusProvider for RemoteAgentImpl {
471 async fn get_status_details(
472 &self, _request: tonic::Request<GetStatusDetailsRequest>,
473 ) -> Result<tonic::Response<GetStatusDetailsResponse>, Status> {
474 return self
475 .session_id_middleware(async || {
476 let app_details = saluki_metadata::get_app_details();
477
478 let mut builder = StatusBuilder::new();
479 builder
480 .main_section()
481 .set_field("Version", app_details.version().raw())
482 .set_field("Git Commit", app_details.git_hash())
483 .set_field("Architecture", app_details.target_arch())
484 .set_field("Started", self.started.to_rfc3339());
485
486 if let Some(agent_version) = datadog_agent_commons::agent_version::version_string() {
488 builder
489 .main_section()
490 .set_field("Built Against Agent Version", agent_version);
491 }
492
493 for section in &self.status_sections {
494 section.write_status(&mut builder);
495 }
496
497 Ok(tonic::Response::new(builder.into_response()))
498 })
499 .await;
500 }
501}
502
503#[async_trait]
504impl TelemetryProvider for RemoteAgentImpl {
505 async fn get_telemetry(
506 &self, _request: tonic::Request<GetTelemetryRequest>,
507 ) -> Result<tonic::Response<GetTelemetryResponse>, Status> {
508 return self
509 .session_id_middleware(async || {
510 let prom_text = render_telemetry(self.processor.as_ref(), self.internal_metrics.state()).await;
511
512 Ok(tonic::Response::new(GetTelemetryResponse {
513 payload: Some(Payload::PromText(prom_text)),
514 }))
515 })
516 .await;
517 }
518}
519
520#[cfg(all(
526 feature = "taskdump",
527 target_os = "linux",
528 any(
529 target_arch = "aarch64",
530 target_arch = "x86",
531 target_arch = "x86_64",
532 target_arch = "s390x"
533 )
534))]
535const TASK_DUMP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
536
537const DIAGNOSTIC_COLLECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
543
544const DIAGNOSTIC_ARTIFACT_MAX_BYTES: usize = 5 * 1024 * 1024; const DIAGNOSTIC_TRUNCATION_MARKER: &[u8] = b"\n\n[... truncated: artifact exceeded the 5 MiB per-file limit ...]\n";
553
554fn cap_artifact_data(mut data: Vec<u8>) -> Vec<u8> {
556 if data.len() > DIAGNOSTIC_ARTIFACT_MAX_BYTES {
557 data.truncate(DIAGNOSTIC_ARTIFACT_MAX_BYTES);
558 data.extend_from_slice(DIAGNOSTIC_TRUNCATION_MARKER);
559 }
560 data
561}
562
563#[cfg(target_os = "linux")]
567fn fd_count() -> String {
568 std::fs::read_dir("/proc/self/fd")
569 .map(|entries| entries.count().to_string())
570 .unwrap_or_else(|_| "unavailable".to_string())
571}
572
573#[cfg(not(target_os = "linux"))]
574fn fd_count() -> String {
575 "unavailable".to_string()
576}
577
578#[cfg(target_os = "linux")]
583fn thread_count() -> String {
584 std::fs::read_to_string("/proc/self/status")
585 .ok()
586 .and_then(|s| {
587 s.lines()
588 .find(|l| l.starts_with("Threads:"))
589 .and_then(|l| l.split_whitespace().nth(1).map(str::to_string))
590 })
591 .unwrap_or_else(|| "unavailable".to_string())
592}
593
594#[cfg(not(target_os = "linux"))]
595fn thread_count() -> String {
596 "unavailable".to_string()
597}
598
599#[async_trait]
600impl FlareProvider for RemoteAgentImpl {
601 async fn get_flare_files(
602 &self, _request: tonic::Request<GetFlareFilesRequest>,
603 ) -> Result<tonic::Response<GetFlareFilesResponse>, Status> {
604 return self
605 .session_id_middleware(async || {
606 let mut files: HashMap<String, Vec<u8>> = HashMap::new();
607
608 if let Some(dataspace) = self.dataspace.get() {
610 let collectors = dataspace.current_values::<DiagnosticCollector>(IdentifierFilter::all());
611 let total_collectors = collectors.len();
612 let deadline = Instant::now() + DIAGNOSTIC_COLLECT_TIMEOUT;
613
614 for collector in collectors {
615 if Instant::now() >= deadline {
616 break;
617 }
618
619 let artifact_name = collector.artifact_name().to_string();
620 let remaining = deadline.saturating_duration_since(Instant::now());
621
622 let collect = timeout(
623 remaining,
624 spawn_blocking(move || collector.collect()),
625 );
626 let artifact_data = match collect.await {
627 Ok(Ok(data)) => data,
628 Ok(Err(e)) => format!("collection panicked for '{artifact_name}': {e}").into_bytes(),
629 Err(_) => format!("collection timed out for '{artifact_name}'").into_bytes(),
630 };
631
632 files.insert(artifact_name, cap_artifact_data(artifact_data));
633 }
634
635 if files.len() < total_collectors {
636 warn!("Diagnostic artifact collection timed out; collected {}/{total_collectors} artifacts.", files.len());
637 }
638 }
639
640 let pid = std::process::id();
648 let uptime = Utc::now().signed_duration_since(self.started);
649 let args: Vec<String> = std::env::args().collect();
650 let rss_display = MemoryQuerier::default()
651 .resident_set_size()
652 .map(|b| b.to_string())
653 .unwrap_or_else(|| "unavailable".to_string());
654 let process_info = format!(
655 "pid: {pid}\nuptime_seconds: {uptime}\nrss_bytes: {rss_display}\nargs: {args:?}\nfd_count: {fd_count}\nthread_count: {thread_count}\n",
656 uptime = uptime.num_seconds(),
657 fd_count = fd_count(),
658 thread_count = thread_count(),
659 );
660 files.insert("runtime_debug_info.log".to_string(), cap_artifact_data(process_info.into_bytes()));
661
662 #[cfg(all(
668 feature = "taskdump",
669 target_os = "linux",
670 any(
671 target_arch = "aarch64",
672 target_arch = "x86",
673 target_arch = "x86_64",
674 target_arch = "s390x"
675 )
676 ))]
677 {
678 let task_dump = match timeout(
679 TASK_DUMP_TIMEOUT,
680 tokio::runtime::Handle::current().dump(),
681 )
682 .await
683 {
684 Ok(dump) => {
685 let mut output = String::new();
686 for (i, task) in dump.tasks().iter().enumerate() {
687 output.push_str(&format!("task {i}:\n{}\n", task.trace()));
688 }
689 output
690 }
691 Err(_) => format!(
692 "task dump timed out after {}ms — a runtime worker may be blocked",
693 TASK_DUMP_TIMEOUT.as_millis()
694 ),
695 };
696 files.insert("runtime-dump.txt".to_string(), cap_artifact_data(task_dump.into_bytes()));
697 }
698
699 Ok(tonic::Response::new(GetFlareFilesResponse { files }))
700 })
701 .await;
702 }
703}
704
705pub struct DataspaceAnchorWorker {
712 dataspace: Arc<OnceLock<DataspaceRegistry>>,
713}
714
715#[async_trait]
716impl Supervisable for DataspaceAnchorWorker {
717 fn name(&self) -> &str {
718 "flare-dataspace-anchor"
719 }
720
721 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
722 if let Some(ds) = DataspaceRegistry::try_current() {
723 let _ = self.dataspace.set(ds);
724 }
725
726 Ok(Box::pin(async move {
727 process_shutdown.await;
728 Ok(())
729 }))
730 }
731}
732
733pub struct RemoteAgentServicesWorker {
738 routes: Routes,
739}
740
741#[async_trait]
742impl Supervisable for RemoteAgentServicesWorker {
743 fn name(&self) -> &str {
744 "remote-agent-services"
745 }
746
747 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
748 let route = DynamicRoute::grpc(EndpointType::Privileged, self.routes.clone());
749
750 Ok(Box::pin(async move {
751 let dataspace =
752 DataspaceRegistry::try_current().ok_or_else(|| generic_error!("Dataspace not available."))?;
753
754 dataspace.assert(route, "remote-agent-services");
755
756 process_shutdown.await;
757 Ok(())
758 }))
759 }
760}
761
762pub struct RemoteAgentEventReporter {
768 client: RemoteAgentClient,
769 session_id: SessionIdHandle,
770}
771
772#[async_trait]
773impl Supervisable for RemoteAgentEventReporter {
774 fn name(&self) -> &str {
775 "remote_agent_event_reporter"
776 }
777
778 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
779 let client = self.client.clone();
780 let session_id = self.session_id.clone();
781
782 Ok(Box::pin(async move {
783 let subscription = subscribe_events(IdentifierFilter::all())
784 .map_err(|e| generic_error!("Failed to subscribe to diagnostic events: {}", e))?;
785
786 select! {
787 _ = process_shutdown => Ok(()),
788 result = run_event_reporter(client, session_id, subscription) => result,
789 }
790 }))
791 }
792}
793
794async fn run_event_reporter(
795 mut client: RemoteAgentClient, session_id: SessionIdHandle, mut subscription: Subscription<DiagnosticEvent>,
796) -> Result<(), GenericError> {
797 debug!("Remote Agent event reporter started.");
798
799 let mut invalid_api_key_reports = InvalidApiKeyReportThrottle::default();
800
801 while let Some(update) = subscription.recv().await {
802 let DataspaceUpdate::Message(_, event) = update else {
803 continue;
804 };
805
806 let Some(remote_agent_event) = diagnostic_to_remote_agent_event(&event) else {
807 debug!("Received diagnostic event with no remote agent event mapping; skipping.");
808 continue;
809 };
810
811 let Some(session_id) = session_id.get() else {
812 debug!("No active remote agent session; dropping diagnostic event.");
813 continue;
814 };
815
816 let invalid_api_key = matches!(event.details(), &DiagnosticDetails::InvalidApiKey);
817 if invalid_api_key && !invalid_api_key_reports.is_allowed() {
818 debug!("Throttling an InvalidApiKey event report.");
819 continue;
820 }
821
822 let request = ReportRemoteAgentEventRequest {
823 session_id: session_id.as_str().to_string(),
824 events: vec![remote_agent_event],
825 };
826
827 match client.report_remote_agent_event(request).await {
828 Ok(_) => {
829 if invalid_api_key {
831 invalid_api_key_reports.record();
832 }
833 }
834 Err(e) => {
835 warn!(error = %e, "Failed to report remote agent event to the Datadog Agent.");
836 }
837 }
838 }
839
840 debug!("Remote Agent event reporter stopped.");
841 Ok(())
842}
843
844const INVALID_API_KEY_REPORT_INTERVAL: Duration = Duration::from_secs(30);
846
847#[derive(Debug, Default)]
852struct InvalidApiKeyReportThrottle {
853 last_reported: Option<Instant>,
854}
855
856impl InvalidApiKeyReportThrottle {
857 fn is_allowed(&self) -> bool {
858 self.last_reported
859 .is_none_or(|last| last.elapsed() >= INVALID_API_KEY_REPORT_INTERVAL)
860 }
861
862 fn record(&mut self) {
863 self.last_reported = Some(Instant::now());
864 }
865}
866
867fn diagnostic_to_remote_agent_event(event: &DiagnosticEvent) -> Option<RemoteAgentEvent> {
872 let details = match event.details() {
873 DiagnosticDetails::InvalidApiKey => RemoteAgentEventDetails::InvalidApiKey(InvalidApiKeyEvent {}),
874 _ => return None,
876 };
877
878 Some(RemoteAgentEvent {
879 message: event.message().to_string(),
880 details: Some(details),
881 })
882}
883
884#[cfg(test)]
885mod tests {
886 use saluki_core::{
887 data_model::event::{
888 metric::{context::Context, Metric},
889 Event,
890 },
891 observability::metrics::{MetricsSnapshot, Processor as _},
892 };
893
894 use super::*;
895
896 #[test]
897 fn cap_artifact_data_leaves_small_payloads_unchanged() {
898 let input = b"hello world".to_vec();
899 let output = cap_artifact_data(input.clone());
900 assert_eq!(output, input);
901 }
902
903 #[test]
904 fn cap_artifact_data_truncates_at_limit_and_appends_marker() {
905 let input = vec![b'x'; DIAGNOSTIC_ARTIFACT_MAX_BYTES + 1024];
906 let output = cap_artifact_data(input);
907
908 assert_eq!(
910 output.len(),
911 DIAGNOSTIC_ARTIFACT_MAX_BYTES + DIAGNOSTIC_TRUNCATION_MARKER.len()
912 );
913
914 assert!(output[..DIAGNOSTIC_ARTIFACT_MAX_BYTES].iter().all(|&b| b == b'x'));
916
917 assert!(output.ends_with(DIAGNOSTIC_TRUNCATION_MARKER));
919 }
920
921 #[test]
922 fn cap_artifact_data_at_exact_limit_is_not_truncated() {
923 let input = vec![b'y'; DIAGNOSTIC_ARTIFACT_MAX_BYTES];
924 let output = cap_artifact_data(input.clone());
925 assert_eq!(output, input);
926 }
927
928 fn metrics_state(names: &[&'static str]) -> AggregatedMetricsState {
929 let processor = AggregatedMetricsProcessor;
930 let state = processor.build_initial_state();
931 let upserts = names
932 .iter()
933 .map(|name| Event::Metric(Metric::counter(Context::from_static_parts(name, &[]), 1.0)))
934 .collect();
935 processor.process(
936 MetricsSnapshot {
937 upserts,
938 evictions: Vec::new(),
939 },
940 &state,
941 );
942 state
943 }
944
945 #[tokio::test]
946 async fn telemetry_without_remapper_rules_is_empty() {
947 let state = metrics_state(&["test.events_received_total"]);
948 let processor = telemetry_processor(Vec::new());
949
950 assert_eq!(render_telemetry(processor.as_ref(), &state).await, "");
951 }
952
953 #[tokio::test]
954 async fn telemetry_only_reports_metrics_matched_by_remapper_rules() {
955 let state = metrics_state(&["test.events_received_total", "test.unmatched_total"]);
956 let processor = telemetry_processor(vec![RemapperRule::by_name(
957 "test.events_received_total",
958 "events_received",
959 )]);
960
961 let prom_text = render_telemetry(processor.as_ref(), &state).await;
962 assert!(prom_text.contains("events_received 1"), "{prom_text}");
963 assert!(!prom_text.contains("unmatched"), "{prom_text}");
964 }
965
966 fn agent_setting(source: &str, key: &str, value: &str) -> AgentConfigSetting {
967 AgentConfigSetting {
968 source: source.to_string(),
969 key: key.to_string(),
970 value: Some(prost_types::Value {
971 kind: Some(Kind::StringValue(value.to_string())),
972 }),
973 }
974 }
975
976 #[test]
977 fn an_agent_default_is_marked_as_a_default() {
978 let setting = setting_to_config_setting(&agent_setting(
979 AGENT_DEFAULT_SOURCE,
980 "dd_url",
981 "https://app.datadoghq.com",
982 ));
983
984 assert_eq!(setting.key, "dd_url");
985 assert_eq!(setting.value, Value::from("https://app.datadoghq.com"));
986 assert_eq!(setting.provenance, Provenance::Default);
987 }
988
989 #[test]
990 fn a_schema_setting_is_marked_as_a_default() {
991 let setting = setting_to_config_setting(&AgentConfigSetting {
992 source: "schema".to_string(),
993 key: "api_key".to_string(),
994 value: None,
995 });
996
997 assert_eq!(setting.value, Value::Null);
998 assert_eq!(setting.provenance, Provenance::Default);
999 }
1000
1001 #[test]
1002 fn null_values_are_preserved_with_their_provenance() {
1003 for source in [AGENT_DEFAULT_SOURCE, "schema", "file", "remote-config"] {
1004 let setting = setting_to_config_setting(&AgentConfigSetting {
1005 source: source.to_string(),
1006 key: "api_key".to_string(),
1007 value: Some(prost_types::Value {
1008 kind: Some(Kind::NullValue(0)),
1009 }),
1010 });
1011
1012 let expected_provenance = if [AGENT_DEFAULT_SOURCE, "schema"].contains(&source) {
1013 Provenance::Default
1014 } else {
1015 Provenance::Explicit
1016 };
1017 assert_eq!(setting.value, Value::Null);
1018 assert_eq!(setting.provenance, expected_provenance, "source {source}");
1019 }
1020 }
1021
1022 #[test]
1023 fn an_empty_string_value_is_kept_with_its_provenance() {
1024 for (source, provenance) in [
1026 ("file", Provenance::Explicit),
1027 ("default", Provenance::Default),
1028 ("schema", Provenance::Default),
1029 ] {
1030 let setting = setting_to_config_setting(&agent_setting(source, "site", ""));
1031
1032 assert_eq!(setting.value, Value::from(""));
1033 assert_eq!(setting.provenance, provenance);
1034 }
1035 }
1036
1037 #[test]
1038 fn operator_supplied_sources_are_marked_as_explicit() {
1039 for source in [
1041 "file",
1042 "environment-variable",
1043 "remote-config",
1044 "cli",
1045 "source-from-the-future",
1046 ] {
1047 let setting = setting_to_config_setting(&agent_setting(source, "dd_url", "https://app.datadoghq.eu"));
1048
1049 assert_eq!(
1050 setting.provenance,
1051 Provenance::Explicit,
1052 "source {source} should be explicit"
1053 );
1054 }
1055 }
1056
1057 #[test]
1058 fn snapshot_settings_keep_order_values_and_provenance() {
1059 let snapshot = ConfigSnapshot {
1060 origin: "core-agent".to_string(),
1061 sequence_id: 1,
1062 settings: vec![
1063 agent_setting("file", "site", "datadoghq.eu"),
1064 agent_setting("default", "dd_url", "https://app.datadoghq.com"),
1065 agent_setting(AGENT_DECLARED_ONLY_SOURCE, "api_key", ""),
1066 ],
1067 };
1068
1069 let settings = snapshot_to_settings(&snapshot);
1070
1071 assert_eq!(
1072 settings,
1073 vec![
1074 ConfigSetting::explicit("site", Value::from("datadoghq.eu")),
1075 ConfigSetting::new("dd_url", Value::from("https://app.datadoghq.com"), Provenance::Default),
1076 ConfigSetting::new("api_key", Value::from(""), Provenance::Default),
1077 ]
1078 );
1079 }
1080
1081 #[test]
1082 fn diagnostic_to_remote_agent_event_maps_invalid_api_key() {
1083 let event = DiagnosticEvent::new("credentials rejected", DiagnosticDetails::InvalidApiKey);
1084 let converted =
1085 diagnostic_to_remote_agent_event(&event).expect("InvalidApiKey should map to a remote agent event");
1086
1087 assert_eq!(converted.message, "credentials rejected");
1088 assert!(matches!(
1089 converted.details,
1090 Some(RemoteAgentEventDetails::InvalidApiKey(_))
1091 ));
1092 }
1093
1094 #[tokio::test(start_paused = true)]
1095 async fn invalid_api_key_reports_are_throttled_to_thirty_seconds() {
1096 let mut throttle = InvalidApiKeyReportThrottle::default();
1097
1098 assert!(throttle.is_allowed());
1099 throttle.record();
1100 assert!(!throttle.is_allowed());
1101
1102 tokio::time::advance(Duration::from_secs(30)).await;
1103 assert!(throttle.is_allowed());
1104 throttle.record();
1105 assert!(!throttle.is_allowed());
1106 }
1107
1108 #[tokio::test(start_paused = true)]
1109 async fn a_report_slightly_before_the_window_elapses_is_held() {
1110 let mut throttle = InvalidApiKeyReportThrottle::default();
1111
1112 throttle.record();
1113 tokio::time::advance(Duration::from_secs(29)).await;
1114 assert!(!throttle.is_allowed());
1115 }
1116
1117 #[test]
1118 fn a_report_that_was_never_delivered_does_not_open_the_throttle_window() {
1119 let mut throttle = InvalidApiKeyReportThrottle::default();
1120
1121 assert!(throttle.is_allowed());
1122 assert!(throttle.is_allowed());
1123 assert!(throttle.is_allowed());
1124
1125 throttle.record();
1126 assert!(!throttle.is_allowed());
1127 }
1128}