saluki_components/destinations/dsd_stats/
mod.rs

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    /// An existing statistics collection request is running.
43    AlreadyRunning {
44        /// Number of seconds to wait before trying again.
45        try_after: u64,
46    },
47
48    Statistics(CollectedStatistics),
49}
50
51#[derive(Serialize)]
52struct CollectedStatistics {
53    /// Start time of the collected metrics, as a Unix timestamp.
54    start_time_unix: u64,
55
56    /// End time of the collected metrics, as a Unix timestamp.
57    end_time_unix: u64,
58
59    /// Collected statistics.
60    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/// Configuration for DogStatsD statistics destination and API handler.
88#[derive(Clone)]
89pub struct DogStatsDStatisticsConfiguration {
90    api_handler: DogStatsDStatsAPIHandler,
91    rx: Arc<Mutex<StatsRequestReceiver>>,
92}
93/// State for the DogStatsD API handler.
94#[derive(Clone)]
95pub struct DogStatsDStatsAPIHandlerState {
96    tx: Arc<mpsc::Sender<(oneshot::Sender<StatsResponse>, u64)>>,
97}
98
99/// API handler for DogStatsD statistics endpoint.
100#[derive(Clone)]
101pub struct DogStatsDStatsAPIHandler {
102    state: DogStatsDStatsAPIHandlerState,
103}
104
105/// DogStatsD destination that collects metrics and processes statistics.
106pub 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                        // We're already collecting statistics for another stats request
132                        // so inform the caller they need to try again later.
133                        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                        // We don't care if we can successfully send back a response or not.
142                        let _ = response_tx.send(StatsResponse::AlreadyRunning { try_after });
143                    } else {
144                        // Start collection.
145                        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                            // We're actively collecting, so process the metrics.
157                            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                    // Build the response.
180                    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                    // We don't care if we can successfully send back a response or not.
197                    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(); // TODO: use config to set collection period
238
239        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    /// Creates a new `DogStatsDStatisticsConfiguration`.
278    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    /// Returns an API handler for DogStatsD API.
290    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        // The `/dogstatsd/stats` endpoint returns `CollectedStatistics`: a start/end window plus a flat array of
331        // per-context samples, where each entry inlines the context (`name`, `tags`) and its `count`/`last_seen`.
332        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        // The handler caps collection at 600 seconds and short-circuits longer requests with a 400 before any
372        // collection is started.
373        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        // Build the destination and grab the request sender the API handler would normally use.
401        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        // Wire up the destination context: an events channel we control and an idle health handle.
411        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        // Start a one-second collection window. Yield afterwards so the current-thread runtime lets the run loop
434        // process the request (marking collection active) before the metrics arrive; otherwise the metrics would be
435        // dropped as "not collecting".
436        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        // The same context seen twice accumulates a single entry with count 2; a distinct context yields count 1.
444        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        // The collection window elapses after one second, completing collection and sending the response; the
458        // recv is bounded well above that window so a stalled collection surfaces as a failure, not a hang.
459        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        // Closing the events channel lets the run loop terminate cleanly.
486        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}