relay_quotas/
quota.rs

1use std::fmt;
2use std::str::FromStr;
3use std::sync::Arc;
4
5use relay_base_schema::metrics::MetricNamespace;
6use relay_base_schema::organization::OrganizationId;
7use relay_base_schema::project::{ProjectId, ProjectKey};
8use serde::{Deserialize, Serialize};
9use smallvec::SmallVec;
10
11#[doc(inline)]
12pub use relay_base_schema::data_category::{CategoryUnit, DataCategory};
13
14/// Data scoping information for rate limiting and quota enforcement.
15///
16/// [`Scoping`] holds all the identifiers needed to attribute data to specific
17/// organizations, projects, and keys. This allows the rate limiting and quota
18/// systems to enforce limits at the appropriate scope levels.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct Scoping {
21    /// The organization id.
22    pub organization_id: OrganizationId,
23
24    /// The project id.
25    pub project_id: ProjectId,
26
27    /// The DSN public key.
28    pub project_key: ProjectKey,
29
30    /// The public key's internal id.
31    pub key_id: Option<u64>,
32}
33
34impl Scoping {
35    /// Creates an [`ItemScoping`] for a specific data category in this scope.
36    ///
37    /// The returned item scoping contains a reference to this scope and the provided
38    /// data category. This is a cheap operation that allows for efficient rate limiting
39    /// of individual items.
40    pub fn item(&self, category: DataCategory) -> ItemScoping {
41        ItemScoping {
42            category,
43            scoping: *self,
44            namespace: MetricNamespaceScoping::None,
45        }
46    }
47
48    /// Creates an [`ItemScoping`] specifically for metric buckets in this scope.
49    ///
50    /// The returned item scoping contains a reference to this scope, the
51    /// [`DataCategory::MetricBucket`] category, and the provided metric namespace.
52    /// This is specialized for handling metrics with namespaces.
53    pub fn metric_bucket(&self, namespace: MetricNamespace) -> ItemScoping {
54        ItemScoping {
55            category: DataCategory::MetricBucket,
56            scoping: *self,
57            namespace: MetricNamespaceScoping::Some(namespace),
58        }
59    }
60}
61
62/// Describes the metric namespace scoping of an item.
63///
64/// This enum is used within [`ItemScoping`] to represent the metric namespace of an item.
65/// It handles the different cases: no namespace, a specific namespace, or any namespace.
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, PartialOrd)]
67pub enum MetricNamespaceScoping {
68    /// The item does not contain metrics of any namespace.
69    ///
70    /// This should only be used for non-metric items.
71    #[default]
72    None,
73
74    /// The item contains metrics of a specific namespace.
75    Some(MetricNamespace),
76
77    /// The item contains metrics of any namespace.
78    ///
79    /// The specific namespace is not known or relevant. This can be used to check rate
80    /// limits or quotas that should apply to any namespace.
81    Any,
82}
83
84impl MetricNamespaceScoping {
85    /// Checks if the given namespace matches this namespace scoping.
86    ///
87    /// Returns `true` in the following cases:
88    /// - If `self` is [`MetricNamespaceScoping::Some`] with the same namespace
89    /// - If `self` is [`MetricNamespaceScoping::Any`], matching any namespace
90    pub fn matches(&self, namespace: MetricNamespace) -> bool {
91        match self {
92            Self::None => false,
93            Self::Some(ns) => *ns == namespace,
94            Self::Any => true,
95        }
96    }
97}
98
99impl From<MetricNamespace> for MetricNamespaceScoping {
100    fn from(namespace: MetricNamespace) -> Self {
101        Self::Some(namespace)
102    }
103}
104
105/// Data categorization and scoping information for a single item.
106///
107/// [`ItemScoping`] combines a data category, scoping information, and optional
108/// metric namespace to fully define an item for rate limiting purposes.
109#[derive(Debug, Copy, Clone, Eq, PartialEq)]
110pub struct ItemScoping {
111    /// The data category of the item.
112    pub category: DataCategory,
113
114    /// Scoping of the data.
115    pub scoping: Scoping,
116
117    /// Namespace for metric items, requiring [`DataCategory::MetricBucket`].
118    pub namespace: MetricNamespaceScoping,
119}
120
121impl std::ops::Deref for ItemScoping {
122    type Target = Scoping;
123
124    fn deref(&self) -> &Self::Target {
125        &self.scoping
126    }
127}
128
129impl ItemScoping {
130    /// Returns the identifier for the given quota scope.
131    ///
132    /// Maps the quota scope type to the corresponding identifier from this scoping,
133    /// or `None` if the scope type doesn't have an applicable identifier.
134    pub fn scope_id(&self, scope: QuotaScope) -> Option<u64> {
135        match scope {
136            QuotaScope::Global => None,
137            QuotaScope::Organization => Some(self.organization_id.value()),
138            QuotaScope::Project => Some(self.project_id.value()),
139            QuotaScope::Key => self.key_id,
140            QuotaScope::Unknown => None,
141        }
142    }
143
144    /// Checks whether the category matches any of the quota's categories.
145    pub(crate) fn matches_categories(&self, categories: &[DataCategory]) -> bool {
146        // An empty list of categories means that this quota matches all categories. Note that we
147        // skip `Unknown` categories silently. If the list of categories only contains `Unknown`s,
148        // we do **not** match, since apparently the quota is meant for some data this Relay does
149        // not support yet.
150        categories.is_empty() || categories.contains(&self.category)
151    }
152
153    /// Returns `true` if the rate limit namespace matches the namespace of the item.
154    ///
155    /// Matching behavior depends on the passed namespaces and the namespace of the scoping:
156    ///  - If the list of namespaces is empty, this check always returns `true`.
157    ///  - If the list of namespaces contains at least one namespace, a namespace on the scoping is
158    ///    required. [`MetricNamespaceScoping::None`] will not match.
159    ///  - If the namespace of this scoping is [`MetricNamespaceScoping::Any`], this check will
160    ///    always return true.
161    ///  - Otherwise, an exact match of the scoping's namespace must be found in the list.
162    ///
163    /// `namespace` can be either a slice, an iterator, or a reference to an
164    /// `Option<MetricNamespace>`. In case of `None`, this method behaves like an empty list and
165    /// permits any namespace.
166    pub(crate) fn matches_namespaces<'a, I>(&self, namespaces: I) -> bool
167    where
168        I: IntoIterator<Item = &'a MetricNamespace>,
169    {
170        let mut iter = namespaces.into_iter().peekable();
171        iter.peek().is_none() || iter.any(|ns| self.namespace.matches(*ns))
172    }
173}
174
175/// An efficient container for data categories that avoids allocations.
176///
177/// It is a read only and has set like properties, allowing for fast comparisons.
178#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize)]
179#[serde(transparent)]
180pub struct DataCategories(Arc<[DataCategory]>);
181
182impl DataCategories {
183    /// Creates new and empty [`DataCategories`].
184    pub fn new() -> Self {
185        Default::default()
186    }
187
188    /// Creates a new [`Self`] from a [`SmallVec`].
189    ///
190    /// Sorts and de-duplicates the contents to uphold the invariants of the type.
191    fn new_sort_and_dedup<const N: usize>(mut s: SmallVec<[DataCategory; N]>) -> Self {
192        s.sort_unstable();
193        s.dedup();
194        Self(s.as_slice().into())
195    }
196
197    /// Adds a data category to [`Self`].
198    ///
199    /// Returns `None` if the category was already contained, otherwise creates a new [`Self`] with
200    /// the `category` added.
201    pub fn add(&self, category: DataCategory) -> Option<Self> {
202        // We know the list of contained data categories is small -> we can just do a linear search
203        // instead of a binary search.
204        if self.0.contains(&category) {
205            return None;
206        }
207
208        let mut new = SmallVec::<[DataCategory; 12]>::from(&*self.0);
209        new.push(category);
210        Some(new.into())
211    }
212}
213
214impl std::ops::Deref for DataCategories {
215    type Target = [DataCategory];
216
217    fn deref(&self) -> &Self::Target {
218        &self.0
219    }
220}
221
222impl<'de> Deserialize<'de> for DataCategories {
223    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
224    where
225        D: serde::Deserializer<'de>,
226    {
227        SmallVec::<[DataCategory; 12]>::deserialize(deserializer).map(Self::new_sort_and_dedup)
228    }
229}
230
231impl<const N: usize> From<SmallVec<[DataCategory; N]>> for DataCategories {
232    fn from(categories: SmallVec<[DataCategory; N]>) -> Self {
233        Self::new_sort_and_dedup(categories)
234    }
235}
236
237impl<const N: usize> From<[DataCategory; N]> for DataCategories {
238    fn from(categories: [DataCategory; N]) -> Self {
239        Self::new_sort_and_dedup(SmallVec::from_buf(categories))
240    }
241}
242
243impl FromIterator<DataCategory> for DataCategories {
244    fn from_iter<T: IntoIterator<Item = DataCategory>>(iter: T) -> Self {
245        let v: SmallVec<[DataCategory; 12]> = iter.into_iter().collect();
246        Self::new_sort_and_dedup(v)
247    }
248}
249
250/// The scope at which a quota is applied.
251///
252/// Defines the granularity at which quotas are enforced, from global (affecting all data)
253/// down to individual project keys. This enum only defines the type of scope,
254/// not the specific instance.
255///
256/// This type is directly related to [`crate::rate_limit::RateLimitScope`], which
257/// includes the specific scope identifiers.
258#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
259#[serde(rename_all = "lowercase")]
260pub enum QuotaScope {
261    /// Global scope, matching all data regardless of origin.
262    Global,
263    /// The organization level.
264    ///
265    /// This is the top-level scope.
266    Organization,
267
268    /// The project level.
269    ///
270    /// Projects are contained within organizations.
271    Project,
272
273    /// The project key level (corresponds to a DSN).
274    ///
275    /// This is the most specific scope level and is contained within projects.
276    Key,
277
278    /// Any scope type not recognized by this Relay.
279    #[serde(other)]
280    Unknown,
281}
282
283impl QuotaScope {
284    /// Returns the quota scope corresponding to the given name string.
285    ///
286    /// If the string doesn't match any known scope, returns [`QuotaScope::Unknown`].
287    pub fn from_name(string: &str) -> Self {
288        match string {
289            "global" => Self::Global,
290            "organization" => Self::Organization,
291            "project" => Self::Project,
292            "key" => Self::Key,
293            _ => Self::Unknown,
294        }
295    }
296
297    /// Returns the canonical string name of this scope.
298    ///
299    /// This is the lowercase string representation used in serialization.
300    pub fn name(self) -> &'static str {
301        match self {
302            Self::Global => "global",
303            Self::Key => "key",
304            Self::Project => "project",
305            Self::Organization => "organization",
306            Self::Unknown => "unknown",
307        }
308    }
309}
310
311impl fmt::Display for QuotaScope {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        write!(f, "{}", self.name())
314    }
315}
316
317impl FromStr for QuotaScope {
318    type Err = ();
319
320    fn from_str(string: &str) -> Result<Self, Self::Err> {
321        Ok(Self::from_name(string))
322    }
323}
324
325fn default_scope() -> QuotaScope {
326    QuotaScope::Organization
327}
328
329/// A machine-readable reason code for rate limits.
330///
331/// Reason codes provide a standardized way to communicate why a particular
332/// item was rate limited.
333#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
334pub struct ReasonCode(Arc<str>);
335
336impl ReasonCode {
337    /// Creates a new reason code from a string.
338    ///
339    /// This method is primarily intended for testing. In production, reason codes
340    /// should typically be deserialized from quota configurations rather than
341    /// constructed manually.
342    pub fn new<S: Into<Arc<str>>>(code: S) -> Self {
343        Self(code.into())
344    }
345
346    /// Returns the string representation of this reason code.
347    pub fn as_str(&self) -> &str {
348        &self.0
349    }
350}
351
352impl fmt::Display for ReasonCode {
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        self.0.fmt(f)
355    }
356}
357
358/// Configuration for a data ingestion quota.
359///
360/// A quota defines restrictions on data ingestion based on data categories, scopes,
361/// and time windows. The system applies multiple quotas to incoming data, and items
362/// are counted against all matching quotas based on their categories.
363///
364/// Quotas can either:
365/// - Reject all data (`limit` = 0)
366/// - Limit data to a specific quantity per time window (`limit` > 0)
367/// - Count data without limiting it (`limit` = None)
368///
369/// Different quotas may apply at different scope levels (organization, project, key).
370#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
371#[serde(rename_all = "camelCase")]
372pub struct Quota {
373    /// The unique identifier for counting this quota.
374    ///
375    /// Required for all quotas except those with `limit` = 0, which are statically enforced.
376    #[serde(default)]
377    pub id: Option<Arc<str>>,
378
379    /// Data categories this quota applies to.
380    ///
381    /// If missing or empty, this quota applies to all data categories.
382    #[serde(default)]
383    pub categories: DataCategories,
384
385    /// The scope level at which this quota is enforced.
386    ///
387    /// The quota is enforced separately within each instance of this scope
388    /// (e.g., for each project key separately). Defaults to [`QuotaScope::Organization`].
389    #[serde(default = "default_scope")]
390    pub scope: QuotaScope,
391
392    /// Specific scope instance identifier this quota applies to.
393    ///
394    /// If set, this quota only applies to the specified scope instance
395    /// (e.g., a specific project key). Requires `scope` to be set explicitly.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub scope_id: Option<Arc<str>>,
398
399    /// Maximum number of events allowed within the time window.
400    ///
401    /// Possible values:
402    /// - `Some(0)`: Reject all matching events
403    /// - `Some(n)`: Allow up to n events per time window
404    /// - `None`: Unlimited quota (counts but doesn't limit)
405    ///
406    /// Requires `window` to be set if the limit is not 0.
407    #[serde(default)]
408    pub limit: Option<u64>,
409
410    /// The time window in seconds for quota enforcement.
411    ///
412    /// Required in all cases except `limit` = 0, since those quotas
413    /// are not measured over time.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub window: Option<u64>,
416
417    /// The metric namespace this quota applies to.
418    ///
419    /// If `None`, it matches any namespace.
420    pub namespace: Option<MetricNamespace>,
421
422    /// A machine-readable reason code returned when this quota is exceeded.
423    ///
424    /// Required for all quotas except those with `limit` = None, since
425    /// unlimited quotas can never be exceeded.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub reason_code: Option<ReasonCode>,
428}
429
430impl Quota {
431    /// Returns whether this quota is valid for tracking.
432    ///
433    /// A quota is considered invalid if any of the following conditions are true:
434    ///  - The quota only applies to [`DataCategory::Unknown`] data categories.
435    ///  - The quota is counted (not limit `0`) but specifies categories with different units.
436    ///  - The quota references an unsupported namespace.
437    pub fn is_valid(&self) -> bool {
438        if self.namespace == Some(MetricNamespace::Unsupported) {
439            return false;
440        }
441
442        let mut units = self
443            .categories
444            .iter()
445            .filter_map(CategoryUnit::from_category);
446
447        match units.next() {
448            // There are only unknown categories, which is always invalid
449            None if !self.categories.is_empty() => false,
450            // This is a reject all quota, which is always valid
451            _ if self.limit == Some(0) => true,
452            // Applies to all categories, which implies multiple units
453            None => false,
454            // There are multiple categories, which must all have the same units
455            Some(unit) => units.all(|u| u == unit),
456        }
457    }
458
459    /// Checks whether this quota's scope matches the given item scoping.
460    ///
461    /// This quota matches, if:
462    ///  - there is no `scope_id` constraint
463    ///  - the `scope_id` constraint is not numeric
464    ///  - the scope identifier matches the one from ascoping and the scope is known
465    fn matches_scope(&self, scoping: ItemScoping) -> bool {
466        if self.scope == QuotaScope::Global {
467            return true;
468        }
469
470        // Check for a scope identifier constraint. If there is no constraint, this means that the
471        // quota matches any scope. In case the scope is unknown, it will be coerced to the most
472        // specific scope later.
473        let Some(scope_id) = self.scope_id.as_ref() else {
474            return true;
475        };
476
477        // Check if the scope identifier in the quota is parseable. If not, this means we cannot
478        // fulfill the constraint, so the quota does not match.
479        let Ok(parsed) = scope_id.parse::<u64>() else {
480            return false;
481        };
482
483        // At this stage, require that the scope is known since we have to fulfill the constraint.
484        scoping.scope_id(self.scope) == Some(parsed)
485    }
486
487    /// Checks whether the quota's constraints match the current item.
488    ///
489    /// This method determines if this quota should be applied to a given item
490    /// based on its scope, categories, and namespace.
491    pub fn matches(&self, scoping: ItemScoping) -> bool {
492        self.matches_scope(scoping)
493            && scoping.matches_categories(&self.categories)
494            && scoping.matches_namespaces(&self.namespace)
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_parse_quota_reject_all() {
504        let json = r#"{
505            "limit": 0,
506            "reasonCode": "not_yet"
507        }"#;
508
509        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
510
511        insta::assert_ron_snapshot!(quota, @r###"
512        Quota(
513          id: None,
514          categories: [],
515          scope: organization,
516          limit: Some(0),
517          namespace: None,
518          reasonCode: Some(ReasonCode("not_yet")),
519        )
520        "###);
521    }
522
523    #[test]
524    fn test_parse_quota_reject_transactions() {
525        let json = r#"{
526            "limit": 0,
527            "categories": ["transaction"],
528            "reasonCode": "not_yet"
529        }"#;
530
531        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
532
533        insta::assert_ron_snapshot!(quota, @r#"
534        Quota(
535          id: None,
536          categories: [
537            "transaction",
538          ],
539          scope: organization,
540          limit: Some(0),
541          namespace: None,
542          reasonCode: Some(ReasonCode("not_yet")),
543        )
544        "#);
545    }
546
547    #[test]
548    fn test_parse_quota_limited() {
549        let json = r#"{
550            "id": "o",
551            "limit": 4711,
552            "window": 42,
553            "reasonCode": "not_so_fast"
554        }"#;
555
556        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
557
558        insta::assert_ron_snapshot!(quota, @r###"
559        Quota(
560          id: Some("o"),
561          categories: [],
562          scope: organization,
563          limit: Some(4711),
564          window: Some(42),
565          namespace: None,
566          reasonCode: Some(ReasonCode("not_so_fast")),
567        )
568        "###);
569    }
570
571    #[test]
572    fn test_parse_quota_project() {
573        let json = r#"{
574            "id": "p",
575            "scope": "project",
576            "scopeId": "1",
577            "limit": 4711,
578            "window": 42,
579            "reasonCode": "not_so_fast"
580        }"#;
581
582        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
583
584        insta::assert_ron_snapshot!(quota, @r###"
585        Quota(
586          id: Some("p"),
587          categories: [],
588          scope: project,
589          scopeId: Some("1"),
590          limit: Some(4711),
591          window: Some(42),
592          namespace: None,
593          reasonCode: Some(ReasonCode("not_so_fast")),
594        )
595        "###);
596    }
597
598    #[test]
599    fn test_parse_quota_project_large() {
600        let json = r#"{
601            "id": "p",
602            "scope": "project",
603            "scopeId": "1",
604            "limit": 4294967296,
605            "window": 42,
606            "reasonCode": "not_so_fast"
607        }"#;
608
609        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
610
611        insta::assert_ron_snapshot!(quota, @r###"
612        Quota(
613          id: Some("p"),
614          categories: [],
615          scope: project,
616          scopeId: Some("1"),
617          limit: Some(4294967296),
618          window: Some(42),
619          namespace: None,
620          reasonCode: Some(ReasonCode("not_so_fast")),
621        )
622        "###);
623    }
624
625    #[test]
626    fn test_parse_quota_key() {
627        let json = r#"{
628            "id": "k",
629            "scope": "key",
630            "scopeId": "1",
631            "limit": 4711,
632            "window": 42,
633            "reasonCode": "not_so_fast"
634        }"#;
635
636        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
637
638        insta::assert_ron_snapshot!(quota, @r###"
639        Quota(
640          id: Some("k"),
641          categories: [],
642          scope: key,
643          scopeId: Some("1"),
644          limit: Some(4711),
645          window: Some(42),
646          namespace: None,
647          reasonCode: Some(ReasonCode("not_so_fast")),
648        )
649        "###);
650    }
651
652    #[test]
653    fn test_parse_quota_unknown_variants() {
654        let json = r#"{
655            "id": "f",
656            "categories": ["future"],
657            "scope": "future",
658            "scopeId": "1",
659            "limit": 4711,
660            "window": 42,
661            "reasonCode": "not_so_fast"
662        }"#;
663
664        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
665
666        insta::assert_ron_snapshot!(quota, @r#"
667        Quota(
668          id: Some("f"),
669          categories: [
670            "unknown",
671          ],
672          scope: unknown,
673          scopeId: Some("1"),
674          limit: Some(4711),
675          window: Some(42),
676          namespace: None,
677          reasonCode: Some(ReasonCode("not_so_fast")),
678        )
679        "#);
680    }
681
682    #[test]
683    fn test_parse_quota_unlimited() {
684        let json = r#"{
685            "id": "o",
686            "window": 42
687        }"#;
688
689        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
690
691        insta::assert_ron_snapshot!(quota, @r###"
692        Quota(
693          id: Some("o"),
694          categories: [],
695          scope: organization,
696          limit: None,
697          window: Some(42),
698          namespace: None,
699        )
700        "###);
701    }
702
703    #[test]
704    fn test_quota_valid_reject_all() {
705        let quota = Quota {
706            id: None,
707            categories: Default::default(),
708            scope: QuotaScope::Organization,
709            scope_id: None,
710            limit: Some(0),
711            window: None,
712            reason_code: None,
713            namespace: None,
714        };
715
716        assert!(quota.is_valid());
717    }
718
719    #[test]
720    fn test_quota_invalid_only_unknown() {
721        let quota = Quota {
722            id: None,
723            categories: [DataCategory::Unknown, DataCategory::Unknown].into(),
724            scope: QuotaScope::Organization,
725            scope_id: None,
726            limit: Some(0),
727            window: None,
728            reason_code: None,
729            namespace: None,
730        };
731
732        assert!(!quota.is_valid());
733    }
734
735    #[test]
736    fn test_quota_valid_reject_all_mixed() {
737        let quota = Quota {
738            id: None,
739            categories: [DataCategory::Error, DataCategory::Attachment].into(),
740            scope: QuotaScope::Organization,
741            scope_id: None,
742            limit: Some(0),
743            window: None,
744            reason_code: None,
745            namespace: None,
746        };
747
748        assert!(quota.is_valid());
749    }
750
751    #[test]
752    fn test_quota_invalid_limited_mixed() {
753        let quota = Quota {
754            id: None,
755            categories: [DataCategory::Error, DataCategory::Attachment].into(),
756            scope: QuotaScope::Organization,
757            scope_id: None,
758            limit: Some(1000),
759            window: None,
760            reason_code: None,
761            namespace: None,
762        };
763
764        // This category is limited and counted, but has multiple units.
765        assert!(!quota.is_valid());
766    }
767
768    #[test]
769    fn test_quota_invalid_unlimited_mixed() {
770        let quota = Quota {
771            id: None,
772            categories: [DataCategory::Error, DataCategory::Attachment].into(),
773            scope: QuotaScope::Organization,
774            scope_id: None,
775            limit: None,
776            window: None,
777            reason_code: None,
778            namespace: None,
779        };
780
781        // This category is unlimited and counted, but has multiple units.
782        assert!(!quota.is_valid());
783    }
784
785    #[test]
786    fn test_quota_matches_no_categories() {
787        let quota = Quota {
788            id: None,
789            categories: Default::default(),
790            scope: QuotaScope::Organization,
791            scope_id: None,
792            limit: None,
793            window: None,
794            reason_code: None,
795            namespace: None,
796        };
797
798        assert!(quota.matches(ItemScoping {
799            category: DataCategory::Error,
800            scoping: Scoping {
801                organization_id: OrganizationId::new(42),
802                project_id: ProjectId::new(21),
803                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
804                key_id: Some(17),
805            },
806            namespace: MetricNamespaceScoping::None,
807        }));
808    }
809
810    #[test]
811    fn test_quota_matches_unknown_category() {
812        let quota = Quota {
813            id: None,
814            categories: [DataCategory::Unknown].into(),
815            scope: QuotaScope::Organization,
816            scope_id: None,
817            limit: None,
818            window: None,
819            reason_code: None,
820            namespace: None,
821        };
822
823        assert!(!quota.matches(ItemScoping {
824            category: DataCategory::Error,
825            scoping: Scoping {
826                organization_id: OrganizationId::new(42),
827                project_id: ProjectId::new(21),
828                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
829                key_id: Some(17),
830            },
831            namespace: MetricNamespaceScoping::None,
832        }));
833    }
834
835    #[test]
836    fn test_quota_matches_multiple_categores() {
837        let quota = Quota {
838            id: None,
839            categories: [DataCategory::Unknown, DataCategory::Error].into(),
840            scope: QuotaScope::Organization,
841            scope_id: None,
842            limit: None,
843            window: None,
844            reason_code: None,
845            namespace: None,
846        };
847
848        assert!(quota.matches(ItemScoping {
849            category: DataCategory::Error,
850            scoping: Scoping {
851                organization_id: OrganizationId::new(42),
852                project_id: ProjectId::new(21),
853                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
854                key_id: Some(17),
855            },
856            namespace: MetricNamespaceScoping::None,
857        }));
858
859        assert!(!quota.matches(ItemScoping {
860            category: DataCategory::Transaction,
861            scoping: Scoping {
862                organization_id: OrganizationId::new(42),
863                project_id: ProjectId::new(21),
864                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
865                key_id: Some(17),
866            },
867            namespace: MetricNamespaceScoping::None,
868        }));
869    }
870
871    #[test]
872    fn test_quota_matches_no_invalid_scope() {
873        let quota = Quota {
874            id: None,
875            categories: Default::default(),
876            scope: QuotaScope::Organization,
877            scope_id: Some("not_a_number".into()),
878            limit: None,
879            window: None,
880            reason_code: None,
881            namespace: None,
882        };
883
884        assert!(!quota.matches(ItemScoping {
885            category: DataCategory::Error,
886            scoping: Scoping {
887                organization_id: OrganizationId::new(42),
888                project_id: ProjectId::new(21),
889                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
890                key_id: Some(17),
891            },
892            namespace: MetricNamespaceScoping::None,
893        }));
894    }
895
896    #[test]
897    fn test_quota_matches_organization_scope() {
898        let quota = Quota {
899            id: None,
900            categories: Default::default(),
901            scope: QuotaScope::Organization,
902            scope_id: Some("42".into()),
903            limit: None,
904            window: None,
905            reason_code: None,
906            namespace: None,
907        };
908
909        assert!(quota.matches(ItemScoping {
910            category: DataCategory::Error,
911            scoping: Scoping {
912                organization_id: OrganizationId::new(42),
913                project_id: ProjectId::new(21),
914                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
915                key_id: Some(17),
916            },
917            namespace: MetricNamespaceScoping::None,
918        }));
919
920        assert!(!quota.matches(ItemScoping {
921            category: DataCategory::Error,
922            scoping: Scoping {
923                organization_id: OrganizationId::new(0),
924                project_id: ProjectId::new(21),
925                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
926                key_id: Some(17),
927            },
928            namespace: MetricNamespaceScoping::None,
929        }));
930    }
931
932    #[test]
933    fn test_quota_matches_project_scope() {
934        let quota = Quota {
935            id: None,
936            categories: Default::default(),
937            scope: QuotaScope::Project,
938            scope_id: Some("21".into()),
939            limit: None,
940            window: None,
941            reason_code: None,
942            namespace: None,
943        };
944
945        assert!(quota.matches(ItemScoping {
946            category: DataCategory::Error,
947            scoping: Scoping {
948                organization_id: OrganizationId::new(42),
949                project_id: ProjectId::new(21),
950                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
951                key_id: Some(17),
952            },
953            namespace: MetricNamespaceScoping::None,
954        }));
955
956        assert!(!quota.matches(ItemScoping {
957            category: DataCategory::Error,
958            scoping: Scoping {
959                organization_id: OrganizationId::new(42),
960                project_id: ProjectId::new(0),
961                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
962                key_id: Some(17),
963            },
964            namespace: MetricNamespaceScoping::None,
965        }));
966    }
967
968    #[test]
969    fn test_quota_matches_key_scope() {
970        let quota = Quota {
971            id: None,
972            categories: Default::default(),
973            scope: QuotaScope::Key,
974            scope_id: Some("17".into()),
975            limit: None,
976            window: None,
977            reason_code: None,
978            namespace: None,
979        };
980
981        assert!(quota.matches(ItemScoping {
982            category: DataCategory::Error,
983            scoping: Scoping {
984                organization_id: OrganizationId::new(42),
985                project_id: ProjectId::new(21),
986                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
987                key_id: Some(17),
988            },
989            namespace: MetricNamespaceScoping::None,
990        }));
991
992        assert!(!quota.matches(ItemScoping {
993            category: DataCategory::Error,
994            scoping: Scoping {
995                organization_id: OrganizationId::new(42),
996                project_id: ProjectId::new(21),
997                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
998                key_id: Some(0),
999            },
1000            namespace: MetricNamespaceScoping::None,
1001        }));
1002
1003        assert!(!quota.matches(ItemScoping {
1004            category: DataCategory::Error,
1005            scoping: Scoping {
1006                organization_id: OrganizationId::new(42),
1007                project_id: ProjectId::new(21),
1008                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1009                key_id: None,
1010            },
1011            namespace: MetricNamespaceScoping::None,
1012        }));
1013    }
1014
1015    #[test]
1016    fn test_data_categories_sorted_deduplicated() {
1017        let a = DataCategories::from([
1018            DataCategory::Transaction,
1019            DataCategory::Span,
1020            DataCategory::Transaction,
1021        ]);
1022        let b = DataCategories::from([
1023            DataCategory::Span,
1024            DataCategory::Transaction,
1025            DataCategory::Span,
1026        ]);
1027        let c = DataCategories::from([DataCategory::Span, DataCategory::Transaction]);
1028
1029        assert_eq!(a, b);
1030        assert_eq!(b, c);
1031        assert_eq!(a, c);
1032    }
1033
1034    #[test]
1035    fn test_data_categories_serde() {
1036        let s: DataCategories = serde_json::from_str(r#"["span", "transaction", "span"]"#).unwrap();
1037        insta::assert_json_snapshot!(s, @r#"
1038        [
1039          "transaction",
1040          "span"
1041        ]
1042        "#);
1043    }
1044
1045    #[test]
1046    fn test_data_categories_add() {
1047        let c = DataCategories::new();
1048        let c = c.add(DataCategory::Span).unwrap();
1049        assert!(c.add(DataCategory::Span).is_none());
1050        let c = c.add(DataCategory::Transaction).unwrap();
1051        assert_eq!(c, [DataCategory::Span, DataCategory::Transaction].into());
1052    }
1053}