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