Skip to main content

relay_quotas/
quota.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{self, Write};
3use std::hash::{Hash, Hasher};
4use std::ops::Deref;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use fnv::FnvHasher;
9use relay_base_schema::metrics::MetricNamespace;
10use relay_base_schema::organization::OrganizationId;
11use relay_base_schema::project::{ProjectId, ProjectKey};
12use serde::ser::SerializeSeq;
13use serde::{Deserialize, Serialize};
14use smallvec::SmallVec;
15
16#[doc(inline)]
17pub use relay_base_schema::data_category::{CategoryUnit, DataCategory};
18
19/// Data scoping information for rate limiting and quota enforcement.
20///
21/// [`Scoping`] holds all the identifiers needed to attribute data to specific
22/// organizations, projects, and keys. This allows the rate limiting and quota
23/// systems to enforce limits at the appropriate scope levels.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub struct Scoping {
26    /// The organization id.
27    pub organization_id: OrganizationId,
28
29    /// The project id.
30    pub project_id: ProjectId,
31
32    /// The DSN public key.
33    pub project_key: ProjectKey,
34
35    /// The public key's internal id.
36    pub key_id: Option<u64>,
37}
38
39impl Scoping {
40    /// Creates an [`ItemScoping`] for a specific data category in this scope.
41    ///
42    /// The returned item scoping contains a reference to this scope and the provided
43    /// data category. This is a cheap operation that allows for efficient rate limiting
44    /// of individual items.
45    pub fn item(&self, category: DataCategory) -> ItemScoping {
46        ItemScoping {
47            category,
48            scoping: *self,
49            namespace: MetricNamespaceScoping::None,
50            dimensions: DimensionMap::default(),
51        }
52    }
53
54    /// Creates an [`ItemScoping`] for a specific data category, along with specified dimensions
55    pub fn item_with_dimensions(
56        &self,
57        category: DataCategory,
58        dimensions: DimensionMap,
59    ) -> ItemScoping {
60        ItemScoping {
61            category,
62            scoping: *self,
63            namespace: MetricNamespaceScoping::None,
64            dimensions,
65        }
66    }
67
68    /// Creates an [`ItemScoping`] specifically for metric buckets in this scope.
69    ///
70    /// The returned item scoping contains a reference to this scope, the
71    /// [`DataCategory::MetricBucket`] category, and the provided metric namespace.
72    /// This is specialized for handling metrics with namespaces.
73    pub fn metric_bucket(&self, namespace: MetricNamespace) -> ItemScoping {
74        ItemScoping {
75            category: DataCategory::MetricBucket,
76            scoping: *self,
77            namespace: MetricNamespaceScoping::Some(namespace),
78            dimensions: DimensionMap::default(),
79        }
80    }
81}
82
83/// Describes the metric namespace scoping of an item.
84///
85/// This enum is used within [`ItemScoping`] to represent the metric namespace of an item.
86/// It handles the different cases: no namespace, a specific namespace, or any namespace.
87#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, PartialOrd)]
88pub enum MetricNamespaceScoping {
89    /// The item does not contain metrics of any namespace.
90    ///
91    /// This should only be used for non-metric items.
92    #[default]
93    None,
94
95    /// The item contains metrics of a specific namespace.
96    Some(MetricNamespace),
97
98    /// The item contains metrics of any namespace.
99    ///
100    /// The specific namespace is not known or relevant. This can be used to check rate
101    /// limits or quotas that should apply to any namespace.
102    Any,
103}
104
105impl MetricNamespaceScoping {
106    /// Checks if the given namespace matches this namespace scoping.
107    ///
108    /// Returns `true` in the following cases:
109    /// - If `self` is [`MetricNamespaceScoping::Some`] with the same namespace
110    /// - If `self` is [`MetricNamespaceScoping::Any`], matching any namespace
111    pub fn matches(&self, namespace: MetricNamespace) -> bool {
112        match self {
113            Self::None => false,
114            Self::Some(ns) => *ns == namespace,
115            Self::Any => true,
116        }
117    }
118}
119
120impl From<MetricNamespace> for MetricNamespaceScoping {
121    fn from(namespace: MetricNamespace) -> Self {
122        Self::Some(namespace)
123    }
124}
125
126/// Data categorization and scoping information for a single item.
127///
128/// [`ItemScoping`] combines a data category, scoping information, and optional
129/// metric namespace to fully define an item for rate limiting purposes.
130#[derive(Debug, Clone, Eq, PartialEq)]
131pub struct ItemScoping {
132    /// The data category of the item.
133    pub category: DataCategory,
134
135    /// Scoping of the data.
136    pub scoping: Scoping,
137
138    /// Namespace for metric items, requiring [`DataCategory::MetricBucket`].
139    pub namespace: MetricNamespaceScoping,
140
141    /// Dimensions this item can be matched on.
142    pub dimensions: DimensionMap,
143}
144
145/// A map of Dimension -> String
146#[derive(Default, Debug, Clone, Eq, PartialEq, PartialOrd, Ord)]
147#[cfg_attr(test, derive(serde::Serialize))]
148pub struct DimensionMap {
149    dimensions: Arc<BTreeMap<Dimension, DimensionMapValue>>,
150    hash: u64,
151}
152
153/// The value stored in the DimensionMap.
154#[derive(Default, Debug, Eq, PartialEq, PartialOrd, Ord)]
155#[cfg_attr(test, derive(serde::Serialize))]
156pub struct DimensionMapValue {
157    /// The actual string value of the dimension.
158    pub value: String,
159
160    /// The hash of both the name of the key this value corresponds to, and the value itself.
161    pub key_value_hash: u64,
162}
163
164impl DimensionMapValue {
165    fn new(value: String, key_value_hash: u64) -> Self {
166        Self {
167            value,
168            key_value_hash,
169        }
170    }
171}
172
173impl<const N: usize> From<[(Dimension, String); N]> for DimensionMap {
174    fn from(arr: [(Dimension, String); N]) -> Self {
175        let dimensions = BTreeMap::from(arr);
176        Self::new(dimensions)
177    }
178}
179
180impl<const N: usize> From<[(Dimension, &str); N]> for DimensionMap {
181    fn from(arr: [(Dimension, &str); N]) -> Self {
182        let owned = arr.map(|(k, v)| (k, v.to_owned()));
183        let dimensions = BTreeMap::from(owned);
184        Self::new(dimensions)
185    }
186}
187
188impl FromIterator<(Dimension, String)> for DimensionMap {
189    fn from_iter<T: IntoIterator<Item = (Dimension, String)>>(iter: T) -> DimensionMap {
190        let dimensions = BTreeMap::from_iter(iter);
191
192        Self::new(dimensions)
193    }
194}
195
196impl Deref for DimensionMap {
197    type Target = BTreeMap<Dimension, DimensionMapValue>;
198
199    fn deref(&self) -> &Self::Target {
200        self.dimensions.as_ref()
201    }
202}
203
204impl DimensionMap {
205    /// The hash of an empty DimensionMap.
206    pub const EMPTY_MAP_HASH: u64 = 0;
207
208    /// Construct a new DimensionMap from the supplied BTreeMap.
209    pub fn new(dimensions: BTreeMap<Dimension, String>) -> DimensionMap {
210        let mut hash = Self::EMPTY_MAP_HASH;
211
212        let dimensions = dimensions
213            .into_iter()
214            .map(|(k, v)| {
215                let mut h = FnvHasher::with_key(0);
216                k.hash(&mut h);
217                v.hash(&mut h);
218
219                // We construct a total hash over all the ordered, individually-hashed (dim,value) pairs
220                // by using an XOR, rather than using a single FnvHasher, because we need to have the
221                // individual element hashes available for projection, and we'd like projection to be
222                // really fast (without having to invoke another FnvHasher pass,) so opting for a simple
223                // XOR here.  This should be safe, because the number of dimensions is likely to remain
224                // small (< 64), and the number of possible dimension combinations will remain small.
225                // If Dimensions gets big _and_ we see large sets of dimensions (> 10), this should
226                // be revisited.
227                hash ^= h.finish();
228
229                (k, DimensionMapValue::new(v, h.finish()))
230            })
231            .collect::<BTreeMap<_, _>>()
232            .into();
233
234        Self { dimensions, hash }
235    }
236
237    /// Returns the hash of this DimensionMap's dimensions and values.
238    pub fn dimension_hashes(&self) -> u64 {
239        self.hash
240    }
241
242    /// Returns true iff this map contains the specified dimension.
243    pub fn has_dimension(&self, dim: &Dimension) -> bool {
244        self.dimensions.contains_key(dim)
245    }
246
247    /// Returns a hashed value, using all and only the dimensions/values present in the supplied
248    /// `set` and their corresponding value in this DimensionMap.  If `set` contains a
249    /// dimension not present in this map, None is returned.
250    pub fn project(&self, set: &BTreeSet<Dimension>) -> Option<u64> {
251        let mut hash = Self::EMPTY_MAP_HASH;
252
253        for dim in set {
254            let val = self.dimensions.get(dim)?;
255            // Doing an XOR here instead of invoking another hasher for speed; see related comment
256            // in DimensionMap's constructor.
257            hash ^= val.key_value_hash;
258        }
259
260        Some(hash)
261    }
262
263    /// Converts the dimensions of this item scoping, for the supplied quota, into a string of the
264    /// dimension name and hashed value.  This looks like ':key1:hash1:key2:hash2'.
265    /// This function assumes that the quota passed to it already matches this ItemScoping.
266    /// If the
267    pub fn to_string(&self, quota: &Quota) -> Option<String> {
268        let mut result = String::new();
269
270        let Some(group_by) = &quota.group_by else {
271            return None;
272        };
273
274        for dim in group_by.dimensions.iter() {
275            if let Some((key, val)) = self.dimensions.get_key_value(dim) {
276                let mut hasher = fnv::FnvHasher::with_key(1);
277                val.value.hash(&mut hasher);
278                let h = &hasher.finish();
279
280                write!(&mut result, ":{key}:{h}").expect("should be infallible");
281            }
282        }
283
284        if result.is_empty() {
285            return None;
286        }
287
288        result.into()
289    }
290}
291
292impl std::ops::Deref for ItemScoping {
293    type Target = Scoping;
294
295    fn deref(&self) -> &Self::Target {
296        &self.scoping
297    }
298}
299
300impl ItemScoping {
301    /// Returns the identifier for the given quota scope.
302    ///
303    /// Maps the quota scope type to the corresponding identifier from this scoping,
304    /// or `None` if the scope type doesn't have an applicable identifier.
305    pub fn scope_id(&self, scope: QuotaScope) -> Option<u64> {
306        match scope {
307            QuotaScope::Organization => Some(self.organization_id.value()),
308            QuotaScope::Project => Some(self.project_id.value()),
309            QuotaScope::Key => self.key_id,
310            QuotaScope::Unknown => None,
311        }
312    }
313
314    /// Checks whether the category matches any of the quota's categories.
315    pub(crate) fn matches_categories(&self, categories: DataCategories) -> bool {
316        // An empty list of categories means that this quota matches all categories. Note that we
317        // skip `Unknown` categories silently. If the list of categories only contains `Unknown`s,
318        // we do **not** match, since apparently the quota is meant for some data this Relay does
319        // not support yet.
320        categories.is_empty() || categories.contains(&self.category)
321    }
322
323    /// Checks whether this item matches all of the supplied quota's dimensions.
324    pub(crate) fn matches_dimensions(&self, group_by: &Option<GroupBy>) -> bool {
325        let Some(gb) = group_by else { return true };
326
327        gb.dimensions
328            .iter()
329            .all(|dim| self.dimensions.has_dimension(dim))
330    }
331
332    /// Checks whether this item has every dimension of the supplied quota_dimensions, with the same
333    /// value.  If quota_dimensions is empty, this returns true.
334    pub(crate) fn satisfies_quota_dimensions(&self, quota_dimensions: &DimensionMap) -> bool {
335        quota_dimensions
336            .iter()
337            .all(|(dim, value)| self.dimensions.get(dim) == Some(value))
338    }
339
340    /// Returns `true` if the rate limit namespace matches the namespace of the item.
341    ///
342    /// Matching behavior depends on the passed namespaces and the namespace of the scoping:
343    ///  - If the list of namespaces is empty, this check always returns `true`.
344    ///  - If the list of namespaces contains at least one namespace, a namespace on the scoping is
345    ///    required. [`MetricNamespaceScoping::None`] will not match.
346    ///  - If the namespace of this scoping is [`MetricNamespaceScoping::Any`], this check will
347    ///    always return true.
348    ///  - Otherwise, an exact match of the scoping's namespace must be found in the list.
349    ///
350    /// `namespace` can be either a slice, an iterator, or a reference to an
351    /// `Option<MetricNamespace>`. In case of `None`, this method behaves like an empty list and
352    /// permits any namespace.
353    pub(crate) fn matches_namespaces<'a, I>(&self, namespaces: I) -> bool
354    where
355        I: IntoIterator<Item = &'a MetricNamespace>,
356    {
357        let mut iter = namespaces.into_iter().peekable();
358        iter.peek().is_none() || iter.any(|ns| self.namespace.matches(*ns))
359    }
360}
361
362/// An efficient container for data categories that avoids allocations.
363///
364/// It is a read only and has set like properties, allowing for fast comparisons.
365#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
366pub struct DataCategories(u64);
367
368impl Serialize for DataCategories {
369    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
370    where
371        S: serde::Serializer,
372    {
373        let mut ser = serializer.serialize_seq(Some(self.len()))?;
374        for category in self.iter() {
375            ser.serialize_element(&category)?;
376        }
377        ser.end()
378    }
379}
380
381impl DataCategories {
382    fn category_to_mask(category: DataCategory) -> u64 {
383        if matches!(category, DataCategory::Unknown) {
384            // Handle Unknown by throwing it into the last bit.
385            1u64 << 63
386        } else {
387            1u64 << (category as u8)
388        }
389    }
390
391    fn bit_number_to_category(bit_number: u8) -> DataCategory {
392        bit_number.try_into().unwrap_or(DataCategory::Unknown)
393    }
394
395    /// Creates new and empty [`DataCategories`].
396    pub fn new() -> Self {
397        Default::default()
398    }
399
400    /// Creates a new [`DataCategories`] from the supplied slice of [`DataCategory`] values.
401    pub fn from_slice(slice: &[DataCategory]) -> Self {
402        let mut categories = 0;
403        for category in slice {
404            categories |= DataCategories::category_to_mask(*category);
405        }
406
407        Self(categories)
408    }
409
410    /// Adds a data category to [`Self`].
411    ///
412    /// Returns `None` if the category was already contained, otherwise creates a new [`Self`] with
413    /// the `category` added.
414    pub fn add(&self, category: DataCategory) -> Option<Self> {
415        let category_mask = DataCategories::category_to_mask(category);
416
417        if (self.0 & category_mask) != 0 {
418            return None;
419        }
420
421        Some(Self(self.0 | category_mask))
422    }
423
424    /// Returns true iff the category is contained.
425    pub fn contains(&self, category: &DataCategory) -> bool {
426        let category_mask = DataCategories::category_to_mask(*category);
427        (self.0 & category_mask) != 0
428    }
429
430    /// Returns an iterator over this [`DataCategories`] container.
431    pub fn iter(&self) -> DataCategoryIterator {
432        // We start our iteration from the lsb, so the number of trailings zeroes is our 0-based
433        // starting index.
434        let bit_start = self.0.trailing_zeros();
435
436        // Shift our bitfield so that the first bit is in the 0th place.
437        let (categories, _) = self.0.overflowing_shr(bit_start);
438        DataCategoryIterator {
439            categories,
440            current_bit: bit_start as u8,
441        }
442    }
443
444    /// Returns the number of categories in this container.
445    pub fn len(&self) -> usize {
446        self.0.count_ones() as usize
447    }
448
449    /// Returns true iff this container contains no categories.
450    pub fn is_empty(&self) -> bool {
451        self.0 == 0
452    }
453}
454
455/// An iterator over a [`DataCategories`] container.
456pub struct DataCategoryIterator {
457    categories: u64,
458    current_bit: u8,
459}
460
461impl Iterator for DataCategoryIterator {
462    type Item = DataCategory;
463
464    fn next(&mut self) -> Option<Self::Item> {
465        if self.categories == 0 {
466            return None;
467        }
468        let category = DataCategories::bit_number_to_category(self.current_bit);
469
470        // Consume the last bit
471        self.categories &= u64::MAX - 1;
472
473        // Find the next set bit
474        let trailing_zeroes = self.categories.trailing_zeros() as u8;
475
476        // Put that bit in the 0th (to-consume) position
477        (self.categories, _) = self.categories.overflowing_shr(trailing_zeroes as u32);
478
479        // Update the current bit state
480        self.current_bit += trailing_zeroes;
481
482        Some(category)
483    }
484}
485
486impl<'de> Deserialize<'de> for DataCategories {
487    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
488    where
489        D: serde::Deserializer<'de>,
490    {
491        SmallVec::<[DataCategory; 12]>::deserialize(deserializer).map(Self::from)
492    }
493}
494
495impl<const N: usize> From<SmallVec<[DataCategory; N]>> for DataCategories {
496    fn from(categories: SmallVec<[DataCategory; N]>) -> Self {
497        Self::from_slice(&categories)
498    }
499}
500
501impl<const N: usize> From<[DataCategory; N]> for DataCategories {
502    fn from(categories: [DataCategory; N]) -> Self {
503        Self::from_slice(&categories)
504    }
505}
506
507impl FromIterator<DataCategory> for DataCategories {
508    fn from_iter<T: IntoIterator<Item = DataCategory>>(iter: T) -> Self {
509        let v: SmallVec<[DataCategory; 12]> = iter.into_iter().collect();
510        Self::from_slice(&v)
511    }
512}
513
514/// The scope at which a quota is applied.
515///
516/// Defines the granularity at which quotas are enforced, from organizations
517/// down to individual project keys. This enum only defines the type of scope,
518/// not the specific instance.
519///
520/// This type is directly related to [`crate::rate_limit::RateLimitScope`], which
521/// includes the specific scope identifiers.
522#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
523#[serde(rename_all = "lowercase")]
524pub enum QuotaScope {
525    /// The organization level.
526    ///
527    /// This is the top-level scope.
528    Organization,
529
530    /// The project level.
531    ///
532    /// Projects are contained within organizations.
533    Project,
534
535    /// The project key level (corresponds to a DSN).
536    ///
537    /// This is the most specific scope level and is contained within projects.
538    Key,
539
540    /// Any scope type not recognized by this Relay.
541    #[serde(other)]
542    Unknown,
543}
544
545impl QuotaScope {
546    /// Returns the quota scope corresponding to the given name string.
547    ///
548    /// If the string doesn't match any known scope, returns [`QuotaScope::Unknown`].
549    pub fn from_name(string: &str) -> Self {
550        match string {
551            "organization" => Self::Organization,
552            "project" => Self::Project,
553            "key" => Self::Key,
554            _ => Self::Unknown,
555        }
556    }
557
558    /// Returns the canonical string name of this scope.
559    ///
560    /// This is the lowercase string representation used in serialization.
561    pub fn name(self) -> &'static str {
562        match self {
563            Self::Key => "key",
564            Self::Project => "project",
565            Self::Organization => "organization",
566            Self::Unknown => "unknown",
567        }
568    }
569}
570
571impl fmt::Display for QuotaScope {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        write!(f, "{}", self.name())
574    }
575}
576
577impl FromStr for QuotaScope {
578    type Err = ();
579
580    fn from_str(string: &str) -> Result<Self, Self::Err> {
581        Ok(Self::from_name(string))
582    }
583}
584
585fn default_scope() -> QuotaScope {
586    QuotaScope::Organization
587}
588
589/// A machine-readable reason code for rate limits.
590///
591/// Reason codes provide a standardized way to communicate why a particular
592/// item was rate limited.
593#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
594pub struct ReasonCode(Arc<str>);
595
596impl ReasonCode {
597    /// Creates a new reason code from a string.
598    ///
599    /// This method is primarily intended for testing. In production, reason codes
600    /// should typically be deserialized from quota configurations rather than
601    /// constructed manually.
602    pub fn new<S: Into<Arc<str>>>(code: S) -> Self {
603        Self(code.into())
604    }
605
606    /// Returns the string representation of this reason code.
607    pub fn as_str(&self) -> &str {
608        &self.0
609    }
610}
611
612impl fmt::Display for ReasonCode {
613    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614        self.0.fmt(f)
615    }
616}
617
618/// Configuration for a data ingestion quota.
619///
620/// A quota defines restrictions on data ingestion based on data categories, scopes,
621/// and time windows. The system applies multiple quotas to incoming data, and items
622/// are counted against all matching quotas based on their categories.
623///
624/// Quotas can either:
625/// - Reject all data (`limit` = 0)
626/// - Limit data to a specific quantity per time window (`limit` > 0)
627/// - Count data without limiting it (`limit` = None)
628///
629/// Different quotas may apply at different scope levels (organization, project, key).
630#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
631#[serde(rename_all = "camelCase")]
632pub struct Quota {
633    /// The unique identifier for counting this quota.
634    ///
635    /// Required for all quotas except those with `limit` = 0, which are statically enforced.
636    #[serde(default)]
637    pub id: Option<Arc<str>>,
638
639    /// Data categories this quota applies to.
640    ///
641    /// If missing or empty, this quota applies to all data categories.
642    #[serde(default)]
643    pub categories: DataCategories,
644
645    /// The scope level at which this quota is enforced.
646    ///
647    /// The quota is enforced separately within each instance of this scope
648    /// (e.g., for each project key separately). Defaults to [`QuotaScope::Organization`].
649    #[serde(default = "default_scope")]
650    pub scope: QuotaScope,
651
652    /// Specific scope instance identifier this quota applies to.
653    ///
654    /// If set, this quota only applies to the specified scope instance
655    /// (e.g., a specific project key). Requires `scope` to be set explicitly.
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub scope_id: Option<Arc<str>>,
658
659    /// Maximum number of events allowed within the time window.
660    ///
661    /// Possible values:
662    /// - `Some(0)`: Reject all matching events
663    /// - `Some(n)`: Allow up to n events per time window
664    /// - `None`: Unlimited quota (counts but doesn't limit)
665    ///
666    /// Requires `window` to be set if the limit is not 0.
667    #[serde(default)]
668    pub limit: Option<u64>,
669
670    /// The time window in seconds for quota enforcement.
671    ///
672    /// Required in all cases except `limit` = 0, since those quotas
673    /// are not measured over time.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub window: Option<u64>,
676
677    /// The metric namespace this quota applies to.
678    ///
679    /// If `None`, it matches any namespace.
680    pub namespace: Option<MetricNamespace>,
681
682    /// A machine-readable reason code returned when this quota is exceeded.
683    ///
684    /// Required for all quotas except those with `limit` = None, since
685    /// unlimited quotas can never be exceeded.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub reason_code: Option<ReasonCode>,
688
689    /// The optional list of additional dimensions that this quota will be counted by.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub group_by: Option<GroupBy>,
692}
693
694/// The dimensions that a quota can key on--this is for things like monitors, that require even
695/// more fine-grained rate-limiting.
696#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
697#[serde(rename_all = "camelCase")]
698pub struct GroupBy {
699    /// The maximum number combinations of dimensions on this quota to allow.
700    pub max_cardinality: u32,
701
702    /// The list of dimensions this quota will use.
703    pub dimensions: Arc<BTreeSet<Dimension>>,
704}
705
706/// The kinds of dimensions that can be applied to a given quota.
707#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord, Hash)]
708#[serde(rename_all = "camelCase")]
709pub enum Dimension {
710    /// The environment used for an monitor check-in.
711    CheckInEnvironment,
712
713    /// The slug used in a monitor check-in.
714    CheckInSlug,
715
716    /// An unknown dimension.
717    ///
718    /// Quotas with unknown dimensions are not applied.
719    #[serde(other)]
720    Unknown,
721}
722
723impl fmt::Display for Dimension {
724    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
725        match self {
726            Dimension::CheckInEnvironment => f.write_str("check_in.environment"),
727            Dimension::CheckInSlug => f.write_str("check_in.slug"),
728            Dimension::Unknown => f.write_str("unknown"),
729        }
730    }
731}
732
733impl Quota {
734    /// Returns whether this quota is valid for tracking.
735    ///
736    /// A quota is considered invalid if any of the following conditions are true:
737    ///  - The quota only applies to [`DataCategory::Unknown`] data categories.
738    ///  - The quota is counted (not limit `0`) but specifies categories with different units.
739    ///  - The quota references an unsupported namespace.
740    ///  - The dimensions contain Unknown, or are empty.
741    pub fn is_valid(&self) -> bool {
742        if self.namespace == Some(MetricNamespace::Unsupported) {
743            return false;
744        }
745
746        if let Some(group_by) = &self.group_by {
747            if group_by.dimensions.is_empty() {
748                return false;
749            }
750
751            for dim in group_by.dimensions.iter() {
752                if *dim == Dimension::Unknown {
753                    return false;
754                }
755            }
756        }
757
758        let mut units = self
759            .categories
760            .iter()
761            .filter_map(CategoryUnit::from_category);
762
763        match units.next() {
764            // There are only unknown categories, which is always invalid
765            None if !self.categories.is_empty() => false,
766            // This is a reject all quota, which is always valid
767            _ if self.limit == Some(0) => true,
768            // Applies to all categories, which implies multiple units
769            None => false,
770            // There are multiple categories, which must all have the same units
771            Some(unit) => units.all(|u| u == unit),
772        }
773    }
774
775    /// Checks whether this quota's scope matches the given item scoping.
776    ///
777    /// This quota matches, if:
778    ///  - there is no `scope_id` constraint
779    ///  - the `scope_id` constraint is not numeric
780    ///  - the scope identifier matches the one from ascoping and the scope is known
781    fn matches_scope(&self, scoping: &ItemScoping) -> bool {
782        // Check for a scope identifier constraint. If there is no constraint, this means that the
783        // quota matches any scope. In case the scope is unknown, it will be coerced to the most
784        // specific scope later.
785        let Some(scope_id) = self.scope_id.as_ref() else {
786            return true;
787        };
788
789        // Check if the scope identifier in the quota is parseable. If not, this means we cannot
790        // fulfill the constraint, so the quota does not match.
791        let Ok(parsed) = scope_id.parse::<u64>() else {
792            return false;
793        };
794
795        // At this stage, require that the scope is known since we have to fulfill the constraint.
796        scoping.scope_id(self.scope) == Some(parsed)
797    }
798
799    /// Checks whether the quota's constraints match the current item.
800    ///
801    /// This method determines if this quota should be applied to a given item
802    /// based on its scope, categories, and namespace.
803    pub fn matches(&self, scoping: &ItemScoping) -> bool {
804        self.matches_scope(scoping)
805            && scoping.matches_categories(self.categories)
806            && scoping.matches_namespaces(&self.namespace)
807            && scoping.matches_dimensions(&self.group_by)
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use relay_base_schema::data_category::UnknownDataCategory;
814
815    use super::*;
816
817    #[test]
818    fn test_parse_quota_reject_all() {
819        let json = r#"{
820            "limit": 0,
821            "reasonCode": "not_yet"
822        }"#;
823
824        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
825
826        insta::assert_ron_snapshot!(quota, @r###"
827        Quota(
828          id: None,
829          categories: [],
830          scope: organization,
831          limit: Some(0),
832          namespace: None,
833          reasonCode: Some(ReasonCode("not_yet")),
834        )
835        "###);
836    }
837
838    #[test]
839    fn test_parse_quota_reject_transactions() {
840        let json = r#"{
841            "limit": 0,
842            "categories": ["transaction"],
843            "reasonCode": "not_yet"
844        }"#;
845
846        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
847
848        insta::assert_ron_snapshot!(quota, @r#"
849        Quota(
850          id: None,
851          categories: [
852            "transaction",
853          ],
854          scope: organization,
855          limit: Some(0),
856          namespace: None,
857          reasonCode: Some(ReasonCode("not_yet")),
858        )
859        "#);
860    }
861
862    #[test]
863    fn test_parse_quota_limited() {
864        let json = r#"{
865            "id": "o",
866            "limit": 4711,
867            "window": 42,
868            "reasonCode": "not_so_fast"
869        }"#;
870
871        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
872
873        insta::assert_ron_snapshot!(quota, @r###"
874        Quota(
875          id: Some("o"),
876          categories: [],
877          scope: organization,
878          limit: Some(4711),
879          window: Some(42),
880          namespace: None,
881          reasonCode: Some(ReasonCode("not_so_fast")),
882        )
883        "###);
884    }
885
886    #[test]
887    fn test_parse_quota_project() {
888        let json = r#"{
889            "id": "p",
890            "scope": "project",
891            "scopeId": "1",
892            "limit": 4711,
893            "window": 42,
894            "reasonCode": "not_so_fast"
895        }"#;
896
897        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
898
899        insta::assert_ron_snapshot!(quota, @r###"
900        Quota(
901          id: Some("p"),
902          categories: [],
903          scope: project,
904          scopeId: Some("1"),
905          limit: Some(4711),
906          window: Some(42),
907          namespace: None,
908          reasonCode: Some(ReasonCode("not_so_fast")),
909        )
910        "###);
911    }
912
913    #[test]
914    fn test_parse_quota_project_large() {
915        let json = r#"{
916            "id": "p",
917            "scope": "project",
918            "scopeId": "1",
919            "limit": 4294967296,
920            "window": 42,
921            "reasonCode": "not_so_fast"
922        }"#;
923
924        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
925
926        insta::assert_ron_snapshot!(quota, @r###"
927        Quota(
928          id: Some("p"),
929          categories: [],
930          scope: project,
931          scopeId: Some("1"),
932          limit: Some(4294967296),
933          window: Some(42),
934          namespace: None,
935          reasonCode: Some(ReasonCode("not_so_fast")),
936        )
937        "###);
938    }
939
940    #[test]
941    fn test_parse_quota_key() {
942        let json = r#"{
943            "id": "k",
944            "scope": "key",
945            "scopeId": "1",
946            "limit": 4711,
947            "window": 42,
948            "reasonCode": "not_so_fast"
949        }"#;
950
951        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
952
953        insta::assert_ron_snapshot!(quota, @r###"
954        Quota(
955          id: Some("k"),
956          categories: [],
957          scope: key,
958          scopeId: Some("1"),
959          limit: Some(4711),
960          window: Some(42),
961          namespace: None,
962          reasonCode: Some(ReasonCode("not_so_fast")),
963        )
964        "###);
965    }
966
967    #[test]
968    fn test_parse_quota_unknown_variants() {
969        let json = r#"{
970            "id": "f",
971            "categories": ["future"],
972            "scope": "future",
973            "scopeId": "1",
974            "limit": 4711,
975            "window": 42,
976            "reasonCode": "not_so_fast"
977        }"#;
978
979        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
980
981        insta::assert_ron_snapshot!(quota, @r#"
982        Quota(
983          id: Some("f"),
984          categories: [
985            "unknown",
986          ],
987          scope: unknown,
988          scopeId: Some("1"),
989          limit: Some(4711),
990          window: Some(42),
991          namespace: None,
992          reasonCode: Some(ReasonCode("not_so_fast")),
993        )
994        "#);
995    }
996
997    #[test]
998    fn test_parse_quota_unlimited() {
999        let json = r#"{
1000            "id": "o",
1001            "window": 42
1002        }"#;
1003
1004        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1005
1006        insta::assert_ron_snapshot!(quota, @r###"
1007        Quota(
1008          id: Some("o"),
1009          categories: [],
1010          scope: organization,
1011          limit: None,
1012          window: Some(42),
1013          namespace: None,
1014        )
1015        "###);
1016    }
1017
1018    #[test]
1019    fn test_quota_valid_reject_all() {
1020        let quota = Quota {
1021            id: None,
1022            categories: Default::default(),
1023            scope: QuotaScope::Organization,
1024            scope_id: None,
1025            limit: Some(0),
1026            window: None,
1027            reason_code: None,
1028            namespace: None,
1029            group_by: None,
1030        };
1031
1032        assert!(quota.is_valid());
1033    }
1034
1035    #[test]
1036    fn test_quota_invalid_only_unknown() {
1037        let quota = Quota {
1038            id: None,
1039            categories: [DataCategory::Unknown, DataCategory::Unknown].into(),
1040            scope: QuotaScope::Organization,
1041            scope_id: None,
1042            limit: Some(0),
1043            window: None,
1044            reason_code: None,
1045            namespace: None,
1046            group_by: None,
1047        };
1048
1049        assert!(!quota.is_valid());
1050    }
1051
1052    #[test]
1053    fn test_quota_valid_reject_all_mixed() {
1054        let quota = Quota {
1055            id: None,
1056            categories: [DataCategory::Error, DataCategory::Attachment].into(),
1057            scope: QuotaScope::Organization,
1058            scope_id: None,
1059            limit: Some(0),
1060            window: None,
1061            reason_code: None,
1062            namespace: None,
1063            group_by: None,
1064        };
1065
1066        assert!(quota.is_valid());
1067    }
1068
1069    #[test]
1070    fn test_quota_invalid_limited_mixed() {
1071        let quota = Quota {
1072            id: None,
1073            categories: [DataCategory::Error, DataCategory::Attachment].into(),
1074            scope: QuotaScope::Organization,
1075            scope_id: None,
1076            limit: Some(1000),
1077            window: None,
1078            reason_code: None,
1079            namespace: None,
1080            group_by: None,
1081        };
1082
1083        // This category is limited and counted, but has multiple units.
1084        assert!(!quota.is_valid());
1085    }
1086
1087    #[test]
1088    fn test_quota_invalid_unlimited_mixed() {
1089        let quota = Quota {
1090            id: None,
1091            categories: [DataCategory::Error, DataCategory::Attachment].into(),
1092            scope: QuotaScope::Organization,
1093            scope_id: None,
1094            limit: None,
1095            window: None,
1096            reason_code: None,
1097            namespace: None,
1098            group_by: None,
1099        };
1100
1101        // This category is unlimited and counted, but has multiple units.
1102        assert!(!quota.is_valid());
1103    }
1104
1105    #[test]
1106    fn test_quota_matches_no_categories() {
1107        let quota = Quota {
1108            id: None,
1109            categories: Default::default(),
1110            scope: QuotaScope::Organization,
1111            scope_id: None,
1112            limit: None,
1113            window: None,
1114            reason_code: None,
1115            namespace: None,
1116            group_by: None,
1117        };
1118
1119        assert!(quota.matches(&ItemScoping {
1120            category: DataCategory::Error,
1121            scoping: Scoping {
1122                organization_id: OrganizationId::new(42),
1123                project_id: ProjectId::new(21),
1124                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1125                key_id: Some(17),
1126            },
1127            namespace: MetricNamespaceScoping::None,
1128            dimensions: DimensionMap::default(),
1129        }));
1130    }
1131
1132    #[test]
1133    fn test_quota_matches_unknown_category() {
1134        let quota = Quota {
1135            id: None,
1136            categories: [DataCategory::Unknown].into(),
1137            scope: QuotaScope::Organization,
1138            scope_id: None,
1139            limit: None,
1140            window: None,
1141            reason_code: None,
1142            namespace: None,
1143            group_by: None,
1144        };
1145
1146        assert!(!quota.matches(&ItemScoping {
1147            category: DataCategory::Error,
1148            scoping: Scoping {
1149                organization_id: OrganizationId::new(42),
1150                project_id: ProjectId::new(21),
1151                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1152                key_id: Some(17),
1153            },
1154            namespace: MetricNamespaceScoping::None,
1155            dimensions: DimensionMap::default(),
1156        }));
1157    }
1158
1159    #[test]
1160    fn test_quota_matches_multiple_categores() {
1161        let quota = Quota {
1162            id: None,
1163            categories: [DataCategory::Unknown, DataCategory::Error].into(),
1164            scope: QuotaScope::Organization,
1165            scope_id: None,
1166            limit: None,
1167            window: None,
1168            reason_code: None,
1169            namespace: None,
1170            group_by: None,
1171        };
1172
1173        assert!(quota.matches(&ItemScoping {
1174            category: DataCategory::Error,
1175            scoping: Scoping {
1176                organization_id: OrganizationId::new(42),
1177                project_id: ProjectId::new(21),
1178                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1179                key_id: Some(17),
1180            },
1181            namespace: MetricNamespaceScoping::None,
1182            dimensions: DimensionMap::default(),
1183        }));
1184
1185        assert!(!quota.matches(&ItemScoping {
1186            category: DataCategory::Transaction,
1187            scoping: Scoping {
1188                organization_id: OrganizationId::new(42),
1189                project_id: ProjectId::new(21),
1190                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1191                key_id: Some(17),
1192            },
1193            namespace: MetricNamespaceScoping::None,
1194            dimensions: DimensionMap::default(),
1195        }));
1196    }
1197
1198    #[test]
1199    fn test_quota_matches_no_invalid_scope() {
1200        let quota = Quota {
1201            id: None,
1202            categories: Default::default(),
1203            scope: QuotaScope::Organization,
1204            scope_id: Some("not_a_number".into()),
1205            limit: None,
1206            window: None,
1207            reason_code: None,
1208            namespace: None,
1209            group_by: None,
1210        };
1211
1212        assert!(!quota.matches(&ItemScoping {
1213            category: DataCategory::Error,
1214            scoping: Scoping {
1215                organization_id: OrganizationId::new(42),
1216                project_id: ProjectId::new(21),
1217                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1218                key_id: Some(17),
1219            },
1220            namespace: MetricNamespaceScoping::None,
1221            dimensions: DimensionMap::default(),
1222        }));
1223    }
1224
1225    #[test]
1226    fn test_quota_matches_organization_scope() {
1227        let quota = Quota {
1228            id: None,
1229            categories: Default::default(),
1230            scope: QuotaScope::Organization,
1231            scope_id: Some("42".into()),
1232            limit: None,
1233            window: None,
1234            reason_code: None,
1235            namespace: None,
1236            group_by: None,
1237        };
1238
1239        assert!(quota.matches(&ItemScoping {
1240            category: DataCategory::Error,
1241            scoping: Scoping {
1242                organization_id: OrganizationId::new(42),
1243                project_id: ProjectId::new(21),
1244                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1245                key_id: Some(17),
1246            },
1247            namespace: MetricNamespaceScoping::None,
1248            dimensions: DimensionMap::default(),
1249        }));
1250
1251        assert!(!quota.matches(&ItemScoping {
1252            category: DataCategory::Error,
1253            scoping: Scoping {
1254                organization_id: OrganizationId::new(0),
1255                project_id: ProjectId::new(21),
1256                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1257                key_id: Some(17),
1258            },
1259            namespace: MetricNamespaceScoping::None,
1260            dimensions: DimensionMap::default(),
1261        }));
1262    }
1263
1264    #[test]
1265    fn test_quota_matches_project_scope() {
1266        let quota = Quota {
1267            id: None,
1268            categories: Default::default(),
1269            scope: QuotaScope::Project,
1270            scope_id: Some("21".into()),
1271            limit: None,
1272            window: None,
1273            reason_code: None,
1274            namespace: None,
1275            group_by: None,
1276        };
1277
1278        assert!(quota.matches(&ItemScoping {
1279            category: DataCategory::Error,
1280            scoping: Scoping {
1281                organization_id: OrganizationId::new(42),
1282                project_id: ProjectId::new(21),
1283                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1284                key_id: Some(17),
1285            },
1286            namespace: MetricNamespaceScoping::None,
1287            dimensions: DimensionMap::default(),
1288        }));
1289
1290        assert!(!quota.matches(&ItemScoping {
1291            category: DataCategory::Error,
1292            scoping: Scoping {
1293                organization_id: OrganizationId::new(42),
1294                project_id: ProjectId::new(0),
1295                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1296                key_id: Some(17),
1297            },
1298            namespace: MetricNamespaceScoping::None,
1299            dimensions: DimensionMap::default(),
1300        }));
1301    }
1302
1303    #[test]
1304    fn test_quota_matches_key_scope() {
1305        let quota = Quota {
1306            id: None,
1307            categories: Default::default(),
1308            scope: QuotaScope::Key,
1309            scope_id: Some("17".into()),
1310            limit: None,
1311            window: None,
1312            reason_code: None,
1313            namespace: None,
1314            group_by: None,
1315        };
1316
1317        assert!(quota.matches(&ItemScoping {
1318            category: DataCategory::Error,
1319            scoping: Scoping {
1320                organization_id: OrganizationId::new(42),
1321                project_id: ProjectId::new(21),
1322                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1323                key_id: Some(17),
1324            },
1325            namespace: MetricNamespaceScoping::None,
1326            dimensions: DimensionMap::default(),
1327        }));
1328
1329        assert!(!quota.matches(&ItemScoping {
1330            category: DataCategory::Error,
1331            scoping: Scoping {
1332                organization_id: OrganizationId::new(42),
1333                project_id: ProjectId::new(21),
1334                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1335                key_id: Some(0),
1336            },
1337            namespace: MetricNamespaceScoping::None,
1338            dimensions: DimensionMap::default(),
1339        }));
1340
1341        assert!(!quota.matches(&ItemScoping {
1342            category: DataCategory::Error,
1343            scoping: Scoping {
1344                organization_id: OrganizationId::new(42),
1345                project_id: ProjectId::new(21),
1346                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1347                key_id: None,
1348            },
1349            namespace: MetricNamespaceScoping::None,
1350            dimensions: DimensionMap::default(),
1351        }));
1352    }
1353
1354    /// Builds a monitor quota with the passed dimensions.
1355    fn dimensioned_quota(dimensions: Option<GroupBy>) -> Quota {
1356        Quota {
1357            id: Some("q".into()),
1358            categories: [DataCategory::Monitor].into(),
1359            scope: QuotaScope::Project,
1360            scope_id: None,
1361            limit: Some(10),
1362            window: Some(60),
1363            reason_code: None,
1364            namespace: None,
1365            group_by: dimensions,
1366        }
1367    }
1368
1369    /// Builds a monitor item scoping with the passed dimensions.
1370    fn dimensioned_scoping(dimensions: Option<&[(Dimension, &str)]>) -> ItemScoping {
1371        let scoping = Scoping {
1372            organization_id: OrganizationId::new(42),
1373            project_id: ProjectId::new(21),
1374            project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1375            key_id: Some(17),
1376        };
1377
1378        let owned_dims: Option<Vec<_>> =
1379            dimensions.map(|dims| dims.iter().map(|(d, v)| (*d, (*v).to_owned())).collect());
1380
1381        match owned_dims {
1382            None => scoping.item(DataCategory::Monitor),
1383            Some(mut dims) => scoping.item_with_dimensions(
1384                DataCategory::Monitor,
1385                DimensionMap::new(BTreeMap::from_iter(dims.drain(..))),
1386            ),
1387        }
1388    }
1389
1390    #[test]
1391    fn test_quota_valid_dimensions() {
1392        let quota = dimensioned_quota(
1393            GroupBy {
1394                max_cardinality: 100,
1395                dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::CheckInEnvironment])
1396                    .into(),
1397            }
1398            .into(),
1399        );
1400
1401        assert!(quota.is_valid());
1402    }
1403
1404    #[test]
1405    fn test_quota_invalid_unknown_dimension() {
1406        let quota = dimensioned_quota(
1407            GroupBy {
1408                max_cardinality: 999,
1409                dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::Unknown]).into(),
1410            }
1411            .into(),
1412        );
1413
1414        assert!(!quota.is_valid());
1415    }
1416
1417    #[test]
1418    fn test_quota_empty_dimension() {
1419        let quota = dimensioned_quota(
1420            GroupBy {
1421                max_cardinality: 999,
1422                dimensions: BTreeSet::default().into(),
1423            }
1424            .into(),
1425        );
1426
1427        assert!(!quota.is_valid());
1428    }
1429
1430    #[test]
1431    fn test_quota_matches_dimensions() {
1432        let quota = dimensioned_quota(
1433            GroupBy {
1434                max_cardinality: 999,
1435                dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1436                    .into(),
1437            }
1438            .into(),
1439        );
1440
1441        // Exactly the required dimensions, in either order.
1442        assert!(quota.matches(&dimensioned_scoping(Some(&[
1443            (Dimension::CheckInEnvironment, "prod"),
1444            (Dimension::CheckInSlug, "cron1"),
1445        ]))));
1446        assert!(quota.matches(&dimensioned_scoping(Some(&[
1447            (Dimension::CheckInSlug, "cron1"),
1448            (Dimension::CheckInEnvironment, "prod"),
1449        ]))));
1450
1451        // A subset of the required dimensions does not match.
1452        assert!(!quota.matches(&dimensioned_scoping(Some(&[(
1453            Dimension::CheckInSlug,
1454            "cron1"
1455        )]))));
1456
1457        // Neither does an empty or absent dimension set.
1458        assert!(!quota.matches(&dimensioned_scoping(Some(&[]))));
1459        assert!(!quota.matches(&dimensioned_scoping(None)));
1460    }
1461
1462    #[test]
1463    fn test_quota_without_dimensions_matches_any_item() {
1464        let quota = dimensioned_quota(None);
1465
1466        // A quota without dimensions applies to every item, dimensioned or not.
1467        assert!(quota.matches(&dimensioned_scoping(None)));
1468        assert!(quota.matches(&dimensioned_scoping(Some(&[
1469            (Dimension::CheckInEnvironment, "prod"),
1470            (Dimension::CheckInSlug, "cron1"),
1471        ]))));
1472    }
1473
1474    #[test]
1475    fn test_dimensions_as_string() {
1476        let quota = dimensioned_quota(
1477            GroupBy {
1478                max_cardinality: 999,
1479                dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1480                    .into(),
1481            }
1482            .into(),
1483        );
1484
1485        let key = dimensioned_scoping(Some(&[
1486            (Dimension::CheckInEnvironment, "prod"),
1487            (Dimension::CheckInSlug, "cron1"),
1488        ]))
1489        .dimensions
1490        .to_string(&quota)
1491        .unwrap();
1492
1493        // `:<dimension>:<hash>` per dimension, ordered by the quota's dimensions.
1494        let parts = key.split(':').collect::<Vec<_>>();
1495        assert_eq!(parts.len(), 5);
1496        assert_eq!(parts[0], "");
1497        assert_eq!(parts[1], Dimension::CheckInEnvironment.to_string());
1498        assert_eq!(parts[3], Dimension::CheckInSlug.to_string());
1499        assert!(parts[2].parse::<u64>().is_ok());
1500        assert!(parts[4].parse::<u64>().is_ok());
1501    }
1502
1503    #[test]
1504    fn test_dimensions_as_string_ignores_extra_dimensions() {
1505        let quota = dimensioned_quota(
1506            GroupBy {
1507                max_cardinality: 999,
1508                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1509            }
1510            .into(),
1511        );
1512
1513        let expected = dimensioned_scoping(Some(&[(Dimension::CheckInSlug, "cron1")]))
1514            .dimensions
1515            .to_string(&quota);
1516
1517        assert_eq!(
1518            dimensioned_scoping(Some(&[
1519                (Dimension::CheckInSlug, "cron1"),
1520                (Dimension::CheckInEnvironment, "prod"),
1521            ]))
1522            .dimensions
1523            .to_string(&quota),
1524            expected
1525        );
1526    }
1527
1528    #[test]
1529    fn test_dimensions_as_string_without_dimensions() {
1530        let dimensions = GroupBy {
1531            max_cardinality: 999,
1532            dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1533        };
1534
1535        let dimensioned_item =
1536            dimensioned_scoping(Some(&[(Dimension::CheckInEnvironment, "prod")]));
1537
1538        // Neither side having dimensions, or only one side having them, collapses to the
1539        // single "no dimensions" bucket.
1540        assert!(
1541            dimensioned_scoping(None)
1542                .dimensions
1543                .to_string(&dimensioned_quota(None))
1544                .is_none()
1545        );
1546        assert!(
1547            dimensioned_scoping(None)
1548                .dimensions
1549                .to_string(&dimensioned_quota(dimensions.clone().into()))
1550                .is_none(),
1551        );
1552        assert!(
1553            dimensioned_item
1554                .dimensions
1555                .to_string(&dimensioned_quota(None))
1556                .is_none()
1557        );
1558
1559        // So does an item which shares no dimension with the quota.
1560        assert!(
1561            dimensioned_item
1562                .dimensions
1563                .to_string(&dimensioned_quota(dimensions.into()))
1564                .is_none()
1565        );
1566    }
1567
1568    #[test]
1569    fn test_parse_quota_dimensions() {
1570        let json = r#"{
1571            "id": "o",
1572            "categories": ["monitor"],
1573            "limit": 4711,
1574            "window": 42,
1575            "groupBy": {
1576                "maxCardinality": 100,
1577                "dimensions": ["checkInSlug", "checkInEnvironment"]
1578            }
1579        }"#;
1580
1581        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1582
1583        insta::assert_ron_snapshot!(quota, @r#"
1584        Quota(
1585          id: Some("o"),
1586          categories: [
1587            "monitor",
1588          ],
1589          scope: organization,
1590          limit: Some(4711),
1591          window: Some(42),
1592          namespace: None,
1593          groupBy: Some(GroupBy(
1594            maxCardinality: 100,
1595            dimensions: [
1596              checkInEnvironment,
1597              checkInSlug,
1598            ],
1599          )),
1600        )
1601        "#);
1602
1603        assert!(quota.is_valid());
1604    }
1605
1606    #[test]
1607    fn test_parse_quota_dimensions_unknown() {
1608        let json = r#"{
1609            "id": "o",
1610            "limit": 4711,
1611            "window": 42,
1612            "groupBy": {
1613                "maxCardinality": 999,
1614                "dimensions": ["checkInSlug", "somethingNew"]
1615            }
1616        }"#;
1617
1618        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1619
1620        // Unknown dimensions parse, but invalidate the quota, since we cannot bucket by a
1621        // dimension we do not understand.
1622        insta::assert_ron_snapshot!(quota, @r#"
1623        Quota(
1624          id: Some("o"),
1625          categories: [],
1626          scope: organization,
1627          limit: Some(4711),
1628          window: Some(42),
1629          namespace: None,
1630          groupBy: Some(GroupBy(
1631            maxCardinality: 999,
1632            dimensions: [
1633              checkInSlug,
1634              unknown,
1635            ],
1636          )),
1637        )
1638        "#);
1639
1640        assert!(!quota.is_valid());
1641    }
1642
1643    #[test]
1644    fn test_data_categories_sorted_deduplicated() {
1645        let a = DataCategories::from([
1646            DataCategory::Transaction,
1647            DataCategory::Span,
1648            DataCategory::Transaction,
1649        ]);
1650        let b = DataCategories::from([
1651            DataCategory::Span,
1652            DataCategory::Transaction,
1653            DataCategory::Span,
1654        ]);
1655        let c = DataCategories::from([DataCategory::Span, DataCategory::Transaction]);
1656
1657        assert_eq!(a, b);
1658        assert_eq!(b, c);
1659        assert_eq!(a, c);
1660    }
1661
1662    #[test]
1663    fn test_data_categories_serde() {
1664        let s: DataCategories = serde_json::from_str(r#"["span", "transaction", "span"]"#).unwrap();
1665        insta::assert_json_snapshot!(s, @r#"
1666        [
1667          "transaction",
1668          "span"
1669        ]
1670        "#);
1671    }
1672
1673    #[test]
1674    fn test_data_categories_add() {
1675        let c = DataCategories::new();
1676        let c = c.add(DataCategory::Span).unwrap();
1677        assert!(c.add(DataCategory::Span).is_none());
1678        let c = c.add(DataCategory::Transaction).unwrap();
1679        assert_eq!(c, [DataCategory::Span, DataCategory::Transaction].into());
1680    }
1681
1682    #[test]
1683    fn test_reserve_last_bit_data_category() {
1684        // Ensure we don't accidentally use 63 as a valid DataCategory.
1685        let r: Result<DataCategory, UnknownDataCategory> = 63u32.try_into();
1686        assert!(r.is_err());
1687    }
1688
1689    #[test]
1690    fn test_bitmapping_identity() {
1691        assert_eq!(
1692            DataCategories::category_to_mask(DataCategory::Unknown),
1693            1u64 << 63
1694        );
1695        for i in 0..63u32 {
1696            let r: Result<DataCategory, UnknownDataCategory> = i.try_into();
1697            if let Ok(dc) = r {
1698                assert_eq!(DataCategories::bit_number_to_category(i as u8), dc);
1699                assert_eq!(DataCategories::category_to_mask(dc), 1 << i);
1700            } else {
1701                break;
1702            }
1703        }
1704    }
1705}