Skip to main content

relay_server/services/metrics/
aggregator.rs

1use std::pin::Pin;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::{Duration, SystemTime};
5
6use hashbrown::HashMap;
7use hashbrown::hash_map::Entry;
8use relay_base_schema::project::ProjectKey;
9use relay_config::AggregatorServiceConfig;
10use relay_metrics::Bucket;
11use relay_metrics::aggregator::{self, AggregateMetricsError, AggregatorConfig, Partition};
12use relay_quotas::{RateLimits, Scoping};
13use relay_system::{Controller, FromMessage, Interface, NoResponse, Recipient, Service};
14use tokio::time::{Instant, Sleep};
15
16use crate::services::projects::cache::ProjectCacheHandle;
17use crate::services::projects::project::{ProjectInfo, ProjectState};
18use crate::statsd::{RelayCounters, RelayTimers};
19
20/// Aggregator for metric buckets.
21///
22/// Buckets are flushed to a receiver after their time window and a grace period have passed.
23/// Metrics with a recent timestamp are given a longer grace period than backdated metrics, which
24/// are flushed after a shorter debounce delay. See [`AggregatorServiceConfig`] for configuration options.
25///
26/// Internally, the aggregator maintains a continuous flush cycle every 100ms. It guarantees that
27/// all elapsed buckets belonging to the same [`ProjectKey`] are flushed together.
28///
29/// Receivers must implement a handler for the [`FlushBuckets`] message.
30#[derive(Debug)]
31pub enum Aggregator {
32    /// Merge the buckets.
33    MergeBuckets(MergeBuckets),
34}
35
36impl Aggregator {
37    /// Returns the name of the message variant.
38    pub fn variant(&self) -> &'static str {
39        match self {
40            Aggregator::MergeBuckets(_) => "MergeBuckets",
41        }
42    }
43}
44
45impl Interface for Aggregator {}
46
47impl FromMessage<MergeBuckets> for Aggregator {
48    type Response = NoResponse;
49    fn from_message(message: MergeBuckets, _: ()) -> Self {
50        Self::MergeBuckets(message)
51    }
52}
53
54/// A message containing a vector of buckets to be flushed.
55///
56/// Handlers must respond to this message with a `Result`:
57/// - If flushing has succeeded or the buckets should be dropped for any reason, respond with `Ok`.
58/// - If flushing fails and should be retried at a later time, respond with `Err` containing the
59///   failed buckets. They will be merged back into the aggregator and flushed at a later time.
60#[derive(Clone, Debug)]
61pub struct FlushBuckets {
62    /// The partition to which the buckets belong.
63    pub partition_key: u32,
64    /// The buckets to be flushed.
65    pub buckets: HashMap<ProjectKey, ProjectBuckets>,
66}
67
68/// Metric buckets with additional project.
69#[derive(Debug, Clone)]
70pub struct ProjectBuckets {
71    /// The metric buckets to encode.
72    pub buckets: Vec<Bucket>,
73    /// Scoping of the project.
74    pub scoping: Scoping,
75    /// Project info for extracting quotas.
76    pub project_info: Arc<ProjectInfo>,
77    /// Currently cached rate limits.
78    pub rate_limits: Arc<RateLimits>,
79}
80
81impl Extend<Bucket> for ProjectBuckets {
82    fn extend<T: IntoIterator<Item = Bucket>>(&mut self, iter: T) {
83        self.buckets.extend(iter)
84    }
85}
86
87/// Service implementing the [`Aggregator`] interface.
88pub struct AggregatorService {
89    aggregator: aggregator::Aggregator,
90    receiver: Option<Recipient<FlushBuckets, NoResponse>>,
91    project_cache: ProjectCacheHandle,
92    config: AggregatorServiceConfig,
93    can_accept_metrics: Arc<AtomicBool>,
94    next_flush: Pin<Box<Sleep>>,
95}
96
97impl AggregatorService {
98    /// Create a new aggregator service and connect it to `receiver`.
99    ///
100    /// The aggregator will flush a list of buckets to the receiver in regular intervals based on
101    /// the given `config`.
102    pub fn new(
103        config: AggregatorServiceConfig,
104        receiver: Option<Recipient<FlushBuckets, NoResponse>>,
105        project_cache: ProjectCacheHandle,
106    ) -> Self {
107        Self::named("default".to_owned(), config, receiver, project_cache)
108    }
109
110    /// Like [`Self::new`], but with a provided name.
111    pub(crate) fn named(
112        name: String,
113        config: AggregatorServiceConfig,
114        receiver: Option<Recipient<FlushBuckets, NoResponse>>,
115        project_cache: ProjectCacheHandle,
116    ) -> Self {
117        let aggregator = aggregator::Aggregator::named(name, &config.aggregator);
118        Self {
119            receiver,
120            config,
121            can_accept_metrics: Arc::new(AtomicBool::new(true)),
122            aggregator,
123            project_cache,
124            next_flush: Box::pin(tokio::time::sleep(Duration::from_secs(0))),
125        }
126    }
127
128    pub fn handle(&self) -> AggregatorHandle {
129        AggregatorHandle {
130            can_accept_metrics: Arc::clone(&self.can_accept_metrics),
131        }
132    }
133
134    /// Sends the [`FlushBuckets`] message to the receiver in the fire and forget fashion. It is up
135    /// to the receiver to send the [`MergeBuckets`] message back if buckets could not be flushed
136    /// and we require another re-try.
137    ///
138    /// Returns when the next flush should be attempted.
139    fn try_flush(&mut self) -> Duration {
140        let partition = match self.aggregator.try_flush_next(SystemTime::now()) {
141            Ok(partition) => partition,
142            Err(duration) => return duration,
143        };
144        self.can_accept_metrics.store(true, Ordering::Relaxed);
145
146        self.flush_partition(partition);
147
148        self.aggregator.next_flush_at(SystemTime::now())
149    }
150
151    fn flush_partition(&mut self, partition: Partition) {
152        let Some(receiver) = &self.receiver else {
153            return;
154        };
155
156        let mut buckets_by_project = hashbrown::HashMap::new();
157
158        let partition_key = partition.partition_key;
159        for (project_key, bucket) in partition {
160            let s = match buckets_by_project.entry(project_key) {
161                Entry::Occupied(occupied_entry) => occupied_entry.into_mut(),
162                Entry::Vacant(vacant_entry) => {
163                    let project = self.project_cache.get(project_key);
164
165                    let project_info = match project.state() {
166                        ProjectState::Enabled(info) => Arc::clone(info),
167                        // The dummy state should never happen, as a proxy Relay must not use the
168                        // metrics aggregator.
169                        ProjectState::Dummy => {
170                            relay_log::error!(
171                                tags.aggregator = self.aggregator.name(),
172                                tags.project_key = project_key.as_str(),
173                                "metrics aggregator requires a project config"
174                            );
175                            // Drop the bucket.
176                            continue;
177                        }
178                        ProjectState::Disabled => continue, // Drop the bucket.
179                        ProjectState::Pending => {
180                            // Return to the aggregator, which will assign a new flush time.
181                            if let Err(error) = self.aggregator.merge(project_key, bucket) {
182                                relay_log::error!(
183                                    tags.aggregator = self.aggregator.name(),
184                                    tags.project_key = project_key.as_str(),
185                                    bucket.error = &error as &dyn std::error::Error,
186                                    "failed to return metric bucket back to the aggregator"
187                                );
188                            }
189                            relay_statsd::metric!(
190                                counter(RelayCounters::ProjectStateFlushMetricsNoProject) += 1
191                            );
192                            continue;
193                        }
194                    };
195
196                    let rate_limits = project.rate_limits().current_limits();
197                    let Some(scoping) = project_info.scoping(project_key) else {
198                        // This should never happen, at this point we should always have a valid
199                        // project with the necessary information to construct a scoping.
200                        //
201                        // Ideally we enforce this through the type system in the future.
202                        relay_log::error!(
203                            tags.project_key = project_key.as_str(),
204                            "dropping buckets because of missing scope",
205                        );
206                        continue;
207                    };
208
209                    vacant_entry.insert(ProjectBuckets {
210                        buckets: Vec::new(),
211                        scoping,
212                        project_info,
213                        rate_limits,
214                    })
215                }
216            };
217
218            s.buckets.push(bucket);
219        }
220
221        if !buckets_by_project.is_empty() {
222            relay_log::debug!(
223                "flushing buckets for {} projects in partition {partition_key}",
224                buckets_by_project.len()
225            );
226
227            receiver.send(FlushBuckets {
228                partition_key,
229                buckets: buckets_by_project,
230            });
231        }
232    }
233
234    fn handle_merge_buckets(&mut self, msg: MergeBuckets) {
235        let MergeBuckets {
236            project_key,
237            buckets,
238        } = msg;
239
240        for mut bucket in buckets.into_iter() {
241            if !validate_bucket(&mut bucket, &self.config) {
242                continue;
243            };
244
245            match self.aggregator.merge(project_key, bucket) {
246                // Ignore invalid timestamp errors and drop the bucket.
247                Err(AggregateMetricsError::InvalidTimestamp(_)) => {}
248                Err(AggregateMetricsError::TotalLimitExceeded) => {
249                    relay_log::error!(
250                        tags.aggregator = self.aggregator.name(),
251                        "aggregator limit exceeded"
252                    );
253                    self.can_accept_metrics.store(false, Ordering::Relaxed);
254                    break;
255                }
256                Err(AggregateMetricsError::ProjectLimitExceeded) => {
257                    relay_log::error!(
258                        tags.aggregator = self.aggregator.name(),
259                        tags.project_key = project_key.as_str(),
260                        "project metrics limit exceeded for project {project_key}"
261                    );
262                    break;
263                }
264                Err(error) => {
265                    relay_log::error!(
266                        tags.aggregator = self.aggregator.name(),
267                        tags.project_key = project_key.as_str(),
268                        bucket.error = &error as &dyn std::error::Error,
269                        "failed to aggregate metric bucket"
270                    );
271                }
272                Ok(()) => {}
273            };
274        }
275    }
276
277    fn handle_message(&mut self, message: Aggregator) {
278        match message {
279            Aggregator::MergeBuckets(msg) => self.handle_merge_buckets(msg),
280        }
281    }
282
283    fn handle_shutdown(&mut self) {
284        relay_log::info!(
285            "Shutting down metrics aggregator {}",
286            self.aggregator.name()
287        );
288
289        // Create a new aggregator with very aggressive flush parameters.
290        let aggregator = aggregator::Aggregator::named(
291            self.aggregator.name().to_owned(),
292            &AggregatorConfig {
293                bucket_interval: 1,
294                aggregator_size: 1,
295                initial_delay: 0,
296                ..self.config.aggregator
297            },
298        );
299
300        let previous = std::mem::replace(&mut self.aggregator, aggregator);
301
302        let mut partitions = 0;
303        for partition in previous.into_partitions() {
304            self.flush_partition(partition);
305            partitions += 1;
306        }
307        relay_log::debug!("Force flushed {partitions} partitions");
308
309        // Reset the next flush time, to the time of the new aggregator.
310        self.next_flush
311            .as_mut()
312            .reset(Instant::now() + self.aggregator.next_flush_at(SystemTime::now()));
313    }
314}
315
316impl Service for AggregatorService {
317    type Interface = Aggregator;
318
319    async fn run(mut self, mut rx: relay_system::Receiver<Self::Interface>) {
320        let mut shutdown = Controller::shutdown_handle();
321
322        macro_rules! timed {
323            ($task:expr, $body:expr) => {{
324                let task_name = $task;
325                relay_statsd::metric!(
326                    timer(RelayTimers::AggregatorServiceDuration),
327                    task = task_name,
328                    aggregator = self.aggregator.name(),
329                    { $body }
330                )
331            }};
332        }
333
334        loop {
335            tokio::select! {
336                biased;
337
338                _ = &mut self.next_flush => timed!(
339                    "try_flush", {
340                        let next = self.try_flush();
341                        self.next_flush.as_mut().reset(Instant::now() + next);
342                    }
343                ),
344                Some(message) = rx.recv() => timed!(message.variant(), self.handle_message(message)),
345                _ = shutdown.notified() => timed!("shutdown", self.handle_shutdown()),
346
347                else => break,
348            }
349        }
350    }
351}
352
353impl Drop for AggregatorService {
354    fn drop(&mut self) {
355        if !self.aggregator.is_empty() {
356            relay_log::error!(
357                tags.aggregator = self.aggregator.name(),
358                "metrics aggregator dropping buckets"
359            );
360            relay_statsd::metric!(
361                counter(RelayCounters::BucketsDropped) += 1,
362                aggregator = self.aggregator.name(),
363            );
364        }
365    }
366}
367
368/// A message containing a list of [`Bucket`]s to be inserted into the aggregator.
369#[derive(Debug)]
370pub struct MergeBuckets {
371    pub project_key: ProjectKey,
372    pub buckets: Vec<Bucket>,
373}
374
375impl MergeBuckets {
376    /// Creates a new message containing a list of [`Bucket`]s.
377    pub fn new(project_key: ProjectKey, buckets: Vec<Bucket>) -> Self {
378        Self {
379            project_key,
380            buckets,
381        }
382    }
383}
384
385/// Provides sync access to the state of the [`AggregatorService`].
386#[derive(Debug, Clone)]
387pub struct AggregatorHandle {
388    can_accept_metrics: Arc<AtomicBool>,
389}
390
391impl AggregatorHandle {
392    /// Returns `true` if the aggregator can still accept metrics.
393    pub fn can_accept_metrics(&self) -> bool {
394        self.can_accept_metrics.load(Ordering::Relaxed)
395    }
396}
397
398/// Validates the metric name and its tags are correct.
399///
400/// Returns `false` if the metric should be dropped.
401fn validate_bucket(bucket: &mut Bucket, config: &AggregatorServiceConfig) -> bool {
402    if bucket.name.len() > config.max_name_length {
403        relay_log::debug!(
404            "Invalid metric name, too long (> {}): {:?}",
405            config.max_name_length,
406            bucket.name
407        );
408        return false;
409    }
410
411    bucket.tags.retain(|tag_key, tag_value| {
412        if tag_key.len() > config.max_tag_key_length {
413            relay_log::debug!("Invalid metric tag key {tag_key:?}");
414            return false;
415        }
416        if bytecount::num_chars(tag_value.as_bytes()) > config.max_tag_value_length {
417            relay_log::debug!("Invalid metric tag value");
418            return false;
419        }
420
421        true
422    });
423
424    true
425}
426
427#[cfg(test)]
428mod tests {
429    use std::collections::BTreeMap;
430    use std::sync::{Arc, RwLock};
431
432    use relay_base_schema::organization::OrganizationId;
433    use relay_base_schema::project::ProjectId;
434    use relay_common::time::UnixTimestamp;
435    use relay_metrics::{BucketMetadata, BucketValue, aggregator::AggregatorConfig};
436
437    use super::*;
438
439    #[derive(Default)]
440    struct ReceivedData {
441        buckets: Vec<Bucket>,
442    }
443
444    struct TestInterface(FlushBuckets);
445
446    impl Interface for TestInterface {}
447
448    impl FromMessage<FlushBuckets> for TestInterface {
449        type Response = NoResponse;
450
451        fn from_message(message: FlushBuckets, _: ()) -> Self {
452            Self(message)
453        }
454    }
455
456    #[derive(Clone, Default)]
457    struct TestReceiver {
458        data: Arc<RwLock<ReceivedData>>,
459        reject_all: bool,
460    }
461
462    impl TestReceiver {
463        fn add_buckets(&self, buckets: HashMap<ProjectKey, ProjectBuckets>) {
464            let buckets = buckets.into_values().flat_map(|s| s.buckets);
465            self.data.write().unwrap().buckets.extend(buckets);
466        }
467
468        fn bucket_count(&self) -> usize {
469            self.data.read().unwrap().buckets.len()
470        }
471    }
472
473    impl Service for TestReceiver {
474        type Interface = TestInterface;
475
476        async fn run(self, mut rx: relay_system::Receiver<Self::Interface>) {
477            while let Some(message) = rx.recv().await {
478                let buckets = message.0.buckets;
479                relay_log::debug!(?buckets, "received buckets");
480                if !self.reject_all {
481                    self.add_buckets(buckets);
482                }
483            }
484        }
485    }
486
487    fn some_bucket() -> Bucket {
488        let timestamp = UnixTimestamp::from_secs(999994711);
489        Bucket {
490            timestamp,
491            width: 0,
492            name: "c:transactions/foo".into(),
493            value: BucketValue::counter(42.into()),
494            tags: BTreeMap::new(),
495            metadata: BucketMetadata::new(timestamp),
496        }
497    }
498
499    #[tokio::test(start_paused = true)]
500    async fn test_flush_bucket() {
501        relay_test::setup();
502
503        let project_key = ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap();
504
505        let receiver = TestReceiver::default();
506        let recipient = receiver.clone().start_detached().recipient();
507        let project_cache = ProjectCacheHandle::for_test();
508        project_cache.test_set_project_state(
509            project_key,
510            ProjectState::Enabled({
511                Arc::new(ProjectInfo {
512                    // Minimum necessary to get a valid scoping.
513                    project_id: Some(ProjectId::new(3)),
514                    organization_id: Some(OrganizationId::new(1)),
515                    ..Default::default()
516                })
517            }),
518        );
519
520        let config = AggregatorServiceConfig {
521            aggregator: AggregatorConfig {
522                bucket_interval: 1,
523                initial_delay: 0,
524                ..Default::default()
525            },
526            ..Default::default()
527        };
528        let aggregator =
529            AggregatorService::new(config, Some(recipient), project_cache).start_detached();
530
531        let mut bucket = some_bucket();
532        bucket.timestamp = UnixTimestamp::now();
533
534        aggregator.send(MergeBuckets::new(project_key, vec![bucket]));
535
536        // Nothing flushed.
537        assert_eq!(receiver.bucket_count(), 0);
538
539        // Wait until flush delay has passed. It is up to 2s: 1s for the current bucket
540        // and 1s for the flush shift. Adding 100ms buffer.
541        tokio::time::sleep(Duration::from_millis(2100)).await;
542        // receiver must have 1 bucket flushed
543        assert_eq!(receiver.bucket_count(), 1);
544    }
545
546    fn test_config() -> AggregatorServiceConfig {
547        AggregatorServiceConfig {
548            max_name_length: 200,
549            max_tag_key_length: 200,
550            max_tag_value_length: 200,
551            ..Default::default()
552        }
553    }
554
555    #[test]
556    fn test_validate_bucket_key_str_length() {
557        relay_test::setup();
558        let mut short_metric = Bucket {
559            timestamp: UnixTimestamp::now(),
560            name: "c:transactions/a_short_metric".into(),
561            tags: BTreeMap::new(),
562            metadata: Default::default(),
563            width: 0,
564            value: BucketValue::Counter(0.into()),
565        };
566        assert!(validate_bucket(&mut short_metric, &test_config()));
567
568        let mut long_metric = Bucket {
569            timestamp: UnixTimestamp::now(),
570            name: "c:transactions/long_name_a_very_long_name_its_super_long_really_but_like_super_long_probably_the_longest_name_youve_seen_and_even_the_longest_name_ever_its_extremly_long_i_cant_tell_how_long_it_is_because_i_dont_have_that_many_fingers_thus_i_cant_count_the_many_characters_this_long_name_is".into(),
571            tags: BTreeMap::new(),
572            metadata: Default::default(),
573            width: 0,
574            value: BucketValue::Counter(0.into()),
575        };
576        assert!(!validate_bucket(&mut long_metric, &test_config()));
577
578        let mut short_metric_long_tag_key  = Bucket {
579            timestamp: UnixTimestamp::now(),
580            name: "c:transactions/a_short_metric_with_long_tag_key".into(),
581            tags: BTreeMap::from([("i_run_out_of_creativity_so_here_we_go_Lorem_Ipsum_is_simply_dummy_text_of_the_printing_and_typesetting_industry_Lorem_Ipsum_has_been_the_industrys_standard_dummy_text_ever_since_the_1500s_when_an_unknown_printer_took_a_galley_of_type_and_scrambled_it_to_make_a_type_specimen_book".into(), "tag_value".into())]),
582            metadata: Default::default(),
583            width: 0,
584            value: BucketValue::Counter(0.into()),
585        };
586        assert!(validate_bucket(
587            &mut short_metric_long_tag_key,
588            &test_config()
589        ));
590        assert_eq!(short_metric_long_tag_key.tags.len(), 0);
591
592        let mut short_metric_long_tag_value  = Bucket {
593            timestamp: UnixTimestamp::now(),
594            name: "c:transactions/a_short_metric_with_long_tag_value".into(),
595            tags: BTreeMap::from([("tag_key".into(), "i_run_out_of_creativity_so_here_we_go_Lorem_Ipsum_is_simply_dummy_text_of_the_printing_and_typesetting_industry_Lorem_Ipsum_has_been_the_industrys_standard_dummy_text_ever_since_the_1500s_when_an_unknown_printer_took_a_galley_of_type_and_scrambled_it_to_make_a_type_specimen_book".into())]),
596            metadata: Default::default(),
597            width: 0,
598            value: BucketValue::Counter(0.into()),
599        };
600        assert!(validate_bucket(
601            &mut short_metric_long_tag_value,
602            &test_config()
603        ));
604        assert_eq!(short_metric_long_tag_value.tags.len(), 0);
605    }
606
607    #[test]
608    fn test_validate_tag_values_special_chars() {
609        relay_test::setup();
610
611        let tag_value = "x".repeat(199) + "ΓΈ";
612        assert_eq!(tag_value.chars().count(), 200); // Should be allowed
613
614        let mut short_metric = Bucket {
615            timestamp: UnixTimestamp::now(),
616            name: "c:transactions/a_short_metric".into(),
617            tags: BTreeMap::from([("foo".into(), tag_value.clone())]),
618            metadata: Default::default(),
619            width: 0,
620            value: BucketValue::Counter(0.into()),
621        };
622        assert!(validate_bucket(&mut short_metric, &test_config()));
623        assert_eq!(short_metric.tags["foo"], tag_value);
624    }
625}