1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use saluki_api::{
6 extract::{Query, State},
7 routing::{get, Router},
8 APIHandler, StatusCode,
9};
10use saluki_common::time::get_coarse_unix_timestamp;
11use saluki_context::tags::TagSet;
12use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
13use saluki_core::{
14 components::{
15 destinations::{Destination, DestinationBuilder, DestinationContext},
16 ComponentContext,
17 },
18 data_model::event::{Event, EventType},
19};
20use saluki_error::GenericError;
21use serde::{Deserialize, Serialize, Serializer};
22use serde_json;
23use stringtheory::MetaString;
24use tokio::time::{sleep, Duration, Instant};
25use tokio::{
26 pin,
27 sync::{Mutex, OwnedMutexGuard},
28};
29use tokio::{select, sync::mpsc, sync::oneshot};
30
31type StatsRequestReceiver = mpsc::Receiver<(oneshot::Sender<StatsResponse>, u64)>;
32
33#[derive(Debug, Default, Clone, Serialize)]
34pub struct MetricSample {
35 count: u64,
36 last_seen: u64,
37}
38#[derive(Serialize)]
39enum StatsResponse {
40 AlreadyRunning {
42 try_after: u64,
44 },
45
46 Statistics(CollectedStatistics),
47}
48
49#[derive(Serialize)]
50struct CollectedStatistics {
51 start_time_unix: u64,
53
54 end_time_unix: u64,
56
57 stats: FlattenedStats,
59}
60
61#[derive(Serialize)]
62struct FlattenedMetricStat<'a> {
63 #[serde(flatten)]
64 context: &'a ContextNoOrigin,
65
66 #[serde(flatten)]
67 stats: &'a MetricSample,
68}
69
70struct FlattenedStats(HashMap<ContextNoOrigin, MetricSample>);
71
72impl Serialize for FlattenedStats {
73 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74 where
75 S: Serializer,
76 {
77 serializer.collect_seq(
78 self.0
79 .iter()
80 .map(|(context, stats)| FlattenedMetricStat { context, stats }),
81 )
82 }
83}
84
85#[derive(Clone)]
87pub struct DogStatsDStatisticsConfiguration {
88 api_handler: DogStatsDStatsAPIHandler,
89 rx: Arc<Mutex<StatsRequestReceiver>>,
90}
91#[derive(Clone)]
93pub struct DogStatsDStatsAPIHandlerState {
94 tx: Arc<mpsc::Sender<(oneshot::Sender<StatsResponse>, u64)>>,
95}
96
97#[derive(Clone)]
99pub struct DogStatsDStatsAPIHandler {
100 state: DogStatsDStatsAPIHandlerState,
101}
102
103pub struct DogStatsDStats {
105 rx: OwnedMutexGuard<StatsRequestReceiver>,
106}
107
108#[async_trait::async_trait]
109impl Destination for DogStatsDStats {
110 async fn run(mut self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
111 let mut health = context.take_health_handle();
112 let mut collection_active = false;
113 let mut stats_response_tx: Option<tokio::sync::oneshot::Sender<StatsResponse>> = None;
114 let mut current_stats: Option<HashMap<ContextNoOrigin, MetricSample>> = None;
115 let mut stats_collection_start_time = 0;
116 let mut stats_collection_end_time: u64 = 0;
117 let collection_done = sleep(std::time::Duration::ZERO);
118 pin!(collection_done);
119
120 health.mark_ready();
121
122 loop {
123 select! {
124 _ = health.live() => {
125 continue
126 },
127 Some((response_tx, collection_period_secs)) = self.rx.recv() => {
128 if collection_active {
129 let now = get_coarse_unix_timestamp();
132 saluki_antithesis::always_or_unreachable!(
133 now >= stats_collection_start_time,
134 "dsd_stats collection clock did not move backward",
135 { "now": now, "start_time": stats_collection_start_time }
136 );
137 let try_after = stats_collection_end_time.saturating_sub(now);
138
139 let _ = response_tx.send(StatsResponse::AlreadyRunning { try_after });
141 } else {
142 collection_active = true;
144 stats_collection_start_time = get_coarse_unix_timestamp();
145 stats_collection_end_time = stats_collection_start_time + collection_period_secs;
146 stats_response_tx = Some(response_tx);
147 current_stats = Some(HashMap::new());
148 collection_done.as_mut().reset(Instant::now() + Duration::from_secs(collection_period_secs));
149 }
150 },
151 maybe_events = context.events().next() => match maybe_events {
152 Some(events) => {
153 if let Some(stats) = current_stats.as_mut() {
154 for event in events {
156 if let Event::Metric(metric) = event {
157
158 let context = metric.context();
159 let new_context = ContextNoOrigin {
160 name: context.name().clone(),
161 tags: context.tags().clone(),
162 };
163
164 let timestamp = get_coarse_unix_timestamp();
165 let sample = stats.entry(new_context).or_default();
166 sample.count += 1;
167 sample.last_seen = timestamp;
168
169 }
170 }
171 }},
172 None => break,
173 },
174 _ = &mut collection_done, if collection_active => {
175 collection_active = false;
176
177 let stats = match current_stats.take() {
179 Some(stats) => stats,
180 None => continue,
181 };
182
183 let response = StatsResponse::Statistics(CollectedStatistics {
184 start_time_unix: stats_collection_start_time,
185 end_time_unix: stats_collection_end_time,
186 stats: FlattenedStats(stats),
187 });
188
189 let response_tx = match stats_response_tx.take() {
190 Some(tx) => tx,
191 None => continue,
192 };
193
194 let _ = response_tx.send(response);
196 }
197
198 }
199 }
200 Ok(())
201 }
202}
203
204#[derive(Eq, Hash, PartialEq, Serialize)]
205struct ContextNoOrigin {
206 name: MetaString,
207 tags: TagSet,
208}
209#[derive(Deserialize)]
210struct StatsQueryParams {
211 collection_duration_secs: u64,
212}
213
214impl DogStatsDStatsAPIHandler {
215 async fn stats_handler(
216 State(state): State<DogStatsDStatsAPIHandlerState>, Query(query): Query<StatsQueryParams>,
217 ) -> (StatusCode, String) {
218 const MAXIMUM_COLLECTION_DURATION_SECS: u64 = 600;
219 if query.collection_duration_secs > MAXIMUM_COLLECTION_DURATION_SECS {
220 return (
221 StatusCode::BAD_REQUEST,
222 format!(
223 "Collection duration cannot be greater than {} seconds.",
224 MAXIMUM_COLLECTION_DURATION_SECS
225 ),
226 );
227 }
228
229 let (oneshot_tx, oneshot_rx) = oneshot::channel();
230
231 state
232 .tx
233 .send((oneshot_tx, query.collection_duration_secs))
234 .await
235 .unwrap(); match oneshot_rx.await {
238 Ok(stats) => match stats {
239 StatsResponse::Statistics(collected_stats) => match serde_json::to_string(&collected_stats) {
240 Ok(json) => (StatusCode::OK, json),
241 Err(e) => (
242 StatusCode::INTERNAL_SERVER_ERROR,
243 format!("Failed to serialize stats: {}", e),
244 ),
245 },
246 StatsResponse::AlreadyRunning { try_after } => (
247 StatusCode::TOO_MANY_REQUESTS,
248 format!(
249 "Statistics collection already active. Please try again in {} seconds.",
250 try_after
251 ),
252 ),
253 },
254 Err(_) => (
255 StatusCode::INTERNAL_SERVER_ERROR,
256 "Failed to collect statistics.".to_string(),
257 ),
258 }
259 }
260}
261
262impl APIHandler for DogStatsDStatsAPIHandler {
263 type State = DogStatsDStatsAPIHandlerState;
264
265 fn generate_initial_state(&self) -> Self::State {
266 self.state.clone()
267 }
268
269 fn generate_routes(&self) -> Router<Self::State> {
270 Router::new().route("/dogstatsd/stats", get(Self::stats_handler))
271 }
272}
273
274impl DogStatsDStatisticsConfiguration {
275 pub fn new() -> Self {
277 let (tx, rx) = mpsc::channel(4);
278 let state = DogStatsDStatsAPIHandlerState { tx: Arc::new(tx) };
279 let handler = DogStatsDStatsAPIHandler { state };
280
281 Self {
282 api_handler: handler,
283 rx: Arc::new(Mutex::new(rx)),
284 }
285 }
286
287 pub fn api_handler(&self) -> DogStatsDStatsAPIHandler {
289 self.api_handler.clone()
290 }
291}
292
293#[async_trait]
294impl DestinationBuilder for DogStatsDStatisticsConfiguration {
295 fn input_event_type(&self) -> EventType {
296 EventType::Metric
297 }
298
299 async fn build(&self, _context: ComponentContext) -> Result<Box<dyn Destination + Send>, GenericError> {
300 let rx = self.rx.clone().try_lock_owned()?;
301 Ok(Box::new(DogStatsDStats { rx }))
302 }
303}
304
305impl MemoryBounds for DogStatsDStatisticsConfiguration {
306 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
307 builder
308 .minimum()
309 .with_single_value::<DogStatsDStats>("component struct");
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use std::collections::BTreeSet;
316
317 use saluki_context::tags::Tag;
318 use serde_json::json;
319
320 use super::*;
321
322 fn tag_set<const N: usize>(tags: [&'static str; N]) -> TagSet {
323 tags.into_iter().map(Tag::from_static).collect()
324 }
325
326 #[test]
327 fn collected_statistics_serialize_as_flat_metric_entries() {
328 let mut stats = HashMap::new();
331 stats.insert(
332 ContextNoOrigin {
333 name: MetaString::from("my.counter"),
334 tags: tag_set(["env:prod", "service:web"]),
335 },
336 MetricSample {
337 count: 3,
338 last_seen: 100,
339 },
340 );
341
342 let collected = CollectedStatistics {
343 start_time_unix: 10,
344 end_time_unix: 70,
345 stats: FlattenedStats(stats),
346 };
347 let json = serde_json::to_value(&collected).expect("collected statistics should serialize");
348
349 assert_eq!(json!(10), json["start_time_unix"]);
350 assert_eq!(json!(70), json["end_time_unix"]);
351
352 let entries = json["stats"].as_array().expect("stats should serialize as an array");
353 assert_eq!(1, entries.len());
354 let entry = &entries[0];
355 assert_eq!(json!("my.counter"), entry["name"]);
356 assert_eq!(json!(3), entry["count"]);
357 assert_eq!(json!(100), entry["last_seen"]);
358 let tags = entry["tags"]
359 .as_array()
360 .expect("tags should serialize as an array")
361 .iter()
362 .map(|tag| tag.as_str().expect("each tag should be a string"))
363 .collect::<BTreeSet<_>>();
364 assert_eq!(BTreeSet::from(["env:prod", "service:web"]), tags);
365 }
366
367 #[tokio::test]
368 async fn stats_handler_rejects_excessive_collection_duration() {
369 let config = DogStatsDStatisticsConfiguration::new();
372 let state = config.api_handler.state.clone();
373
374 let (status, body) = DogStatsDStatsAPIHandler::stats_handler(
375 State(state),
376 Query(StatsQueryParams {
377 collection_duration_secs: 601,
378 }),
379 )
380 .await;
381
382 assert_eq!(StatusCode::BAD_REQUEST, status);
383 assert_eq!("Collection duration cannot be greater than 600 seconds.", body);
384 }
385
386 #[tokio::test]
387 async fn collection_request_accumulates_metrics_then_responds_on_timeout() {
388 use saluki_core::accounting::{ComponentRegistry, MemoryLimiter};
389 use saluki_core::components::ComponentContext;
390 use saluki_core::data_model::event::metric::Metric;
391 use saluki_core::health::HealthRegistry;
392 use saluki_core::runtime::state::DataspaceRegistry;
393 use saluki_core::runtime::Supervisor;
394 use saluki_core::topology::interconnect::Consumer;
395 use saluki_core::topology::{EventsBuffer, TopologyContext};
396 use tokio::runtime::Handle;
397 use tokio::time::timeout;
398
399 let config = DogStatsDStatisticsConfiguration::new();
401 let request_tx = config.api_handler.state.tx.clone();
402
403 let component_context = ComponentContext::test_destination("test");
404 let destination = config
405 .build(component_context.clone())
406 .await
407 .expect("dsd_stats destination should build");
408
409 let (events_tx, events_rx) = mpsc::channel::<EventsBuffer>(4);
411 let consumer = Consumer::new(component_context.clone(), events_rx);
412 let topology_context = TopologyContext::new(
413 Arc::from("test"),
414 MemoryLimiter::noop(),
415 HealthRegistry::new(),
416 Handle::current(),
417 DataspaceRegistry::new(),
418 );
419 let health = HealthRegistry::new()
420 .register_component(&saluki_core::support::SubsystemIdentifier::from_dotted("test"))
421 .expect("component was not previously registered");
422 let supervisor_handle = Supervisor::new("test").expect("valid supervisor name").handle();
423 let context = DestinationContext::new(
424 &topology_context,
425 &component_context,
426 ComponentRegistry::default(),
427 health,
428 consumer,
429 supervisor_handle,
430 );
431
432 let run_handle = tokio::spawn(async move { destination.run(context).await });
433
434 let (response_tx, response_rx) = oneshot::channel();
438 request_tx
439 .send((response_tx, 1))
440 .await
441 .expect("collection request should be accepted");
442 tokio::task::yield_now().await;
443
444 let mut events = EventsBuffer::default();
446 assert!(events
447 .try_push(Event::Metric(Metric::counter("dsd.stats.repeated", 1.0)))
448 .is_none());
449 assert!(events
450 .try_push(Event::Metric(Metric::counter("dsd.stats.repeated", 1.0)))
451 .is_none());
452 assert!(events
453 .try_push(Event::Metric(Metric::counter("dsd.stats.single", 1.0)))
454 .is_none());
455 events_tx.send(events).await.expect("metrics should be accepted");
456 tokio::task::yield_now().await;
457
458 let response = timeout(Duration::from_secs(5), response_rx)
461 .await
462 .expect("collection response should arrive before timeout")
463 .expect("collection response channel should remain open");
464
465 let collected = match response {
466 StatsResponse::Statistics(collected) => collected,
467 StatsResponse::AlreadyRunning { .. } => panic!("first request should not report an active collection"),
468 };
469 let samples = collected.stats.0;
470 assert_eq!(2, samples.len(), "each distinct context should have its own sample");
471
472 let repeated = samples
473 .iter()
474 .find(|(ctx, _)| ctx.name.as_ref() == "dsd.stats.repeated")
475 .map(|(_, sample)| sample)
476 .expect("repeated context should be collected");
477 assert_eq!(2, repeated.count, "the repeated context should be counted twice");
478
479 let single = samples
480 .iter()
481 .find(|(ctx, _)| ctx.name.as_ref() == "dsd.stats.single")
482 .map(|(_, sample)| sample)
483 .expect("single context should be collected");
484 assert_eq!(1, single.count);
485
486 drop(events_tx);
488 timeout(Duration::from_secs(1), run_handle)
489 .await
490 .expect("run task should stop before timeout")
491 .expect("run task should not panic")
492 .expect("run should complete cleanly");
493 }
494}