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