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