relay_quotas/
quota.rs

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