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.iter().any(|cat| *cat == 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::LogByte
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::Session => Some(Self::Count),
217            DataCategory::Attachment => Some(Self::Bytes),
218            DataCategory::ProfileDuration | DataCategory::ProfileDurationUi => {
219                Some(Self::Milliseconds)
220            }
221
222            DataCategory::Unknown => None,
223        }
224    }
225}
226
227/// An efficient container for data categories that avoids allocations.
228///
229/// [`DataCategories`] is a small-vector based collection of [`DataCategory`] values.
230/// It's optimized for the common case of having only a few categories, avoiding heap
231/// allocations in these scenarios.
232pub type DataCategories = SmallVec<[DataCategory; 8]>;
233
234/// The scope at which a quota is applied.
235///
236/// Defines the granularity at which quotas are enforced, from global (affecting all data)
237/// down to individual project keys. This enum only defines the type of scope,
238/// not the specific instance.
239///
240/// This type is directly related to [`crate::rate_limit::RateLimitScope`], which
241/// includes the specific scope identifiers.
242#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
243#[serde(rename_all = "lowercase")]
244pub enum QuotaScope {
245    /// Global scope, matching all data regardless of origin.
246    Global,
247    /// The organization level.
248    ///
249    /// This is the top-level scope.
250    Organization,
251
252    /// The project level.
253    ///
254    /// Projects are contained within organizations.
255    Project,
256
257    /// The project key level (corresponds to a DSN).
258    ///
259    /// This is the most specific scope level and is contained within projects.
260    Key,
261
262    /// Any scope type not recognized by this Relay.
263    #[serde(other)]
264    Unknown,
265}
266
267impl QuotaScope {
268    /// Returns the quota scope corresponding to the given name string.
269    ///
270    /// If the string doesn't match any known scope, returns [`QuotaScope::Unknown`].
271    pub fn from_name(string: &str) -> Self {
272        match string {
273            "global" => Self::Global,
274            "organization" => Self::Organization,
275            "project" => Self::Project,
276            "key" => Self::Key,
277            _ => Self::Unknown,
278        }
279    }
280
281    /// Returns the canonical string name of this scope.
282    ///
283    /// This is the lowercase string representation used in serialization.
284    pub fn name(self) -> &'static str {
285        match self {
286            Self::Global => "global",
287            Self::Key => "key",
288            Self::Project => "project",
289            Self::Organization => "organization",
290            Self::Unknown => "unknown",
291        }
292    }
293}
294
295impl fmt::Display for QuotaScope {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        write!(f, "{}", self.name())
298    }
299}
300
301impl FromStr for QuotaScope {
302    type Err = ();
303
304    fn from_str(string: &str) -> Result<Self, Self::Err> {
305        Ok(Self::from_name(string))
306    }
307}
308
309fn default_scope() -> QuotaScope {
310    QuotaScope::Organization
311}
312
313/// A machine-readable reason code for rate limits.
314///
315/// Reason codes provide a standardized way to communicate why a particular
316/// item was rate limited.
317#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
318pub struct ReasonCode(String);
319
320impl ReasonCode {
321    /// Creates a new reason code from a string.
322    ///
323    /// This method is primarily intended for testing. In production, reason codes
324    /// should typically be deserialized from quota configurations rather than
325    /// constructed manually.
326    pub fn new<S: Into<String>>(code: S) -> Self {
327        Self(code.into())
328    }
329
330    /// Returns the string representation of this reason code.
331    pub fn as_str(&self) -> &str {
332        &self.0
333    }
334}
335
336impl fmt::Display for ReasonCode {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        self.0.fmt(f)
339    }
340}
341
342/// Configuration for a data ingestion quota.
343///
344/// A quota defines restrictions on data ingestion based on data categories, scopes,
345/// and time windows. The system applies multiple quotas to incoming data, and items
346/// are counted against all matching quotas based on their categories.
347///
348/// Quotas can either:
349/// - Reject all data (`limit` = 0)
350/// - Limit data to a specific quantity per time window (`limit` > 0)
351/// - Count data without limiting it (`limit` = None)
352///
353/// Different quotas may apply at different scope levels (organization, project, key).
354#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
355#[serde(rename_all = "camelCase")]
356pub struct Quota {
357    /// The unique identifier for counting this quota.
358    ///
359    /// Required for all quotas except those with `limit` = 0, which are statically enforced.
360    #[serde(default)]
361    pub id: Option<String>,
362
363    /// Data categories this quota applies to.
364    ///
365    /// If missing or empty, this quota applies to all data categories.
366    #[serde(default = "DataCategories::new")]
367    pub categories: DataCategories,
368
369    /// The scope level at which this quota is enforced.
370    ///
371    /// The quota is enforced separately within each instance of this scope
372    /// (e.g., for each project key separately). Defaults to [`QuotaScope::Organization`].
373    #[serde(default = "default_scope")]
374    pub scope: QuotaScope,
375
376    /// Specific scope instance identifier this quota applies to.
377    ///
378    /// If set, this quota only applies to the specified scope instance
379    /// (e.g., a specific project key). Requires `scope` to be set explicitly.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub scope_id: Option<String>,
382
383    /// Maximum number of events allowed within the time window.
384    ///
385    /// Possible values:
386    /// - `Some(0)`: Reject all matching events
387    /// - `Some(n)`: Allow up to n events per time window
388    /// - `None`: Unlimited quota (counts but doesn't limit)
389    ///
390    /// Requires `window` to be set if the limit is not 0.
391    #[serde(default)]
392    pub limit: Option<u64>,
393
394    /// The time window in seconds for quota enforcement.
395    ///
396    /// Required in all cases except `limit` = 0, since those quotas
397    /// are not measured over time.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub window: Option<u64>,
400
401    /// The metric namespace this quota applies to.
402    ///
403    /// If `None`, it matches any namespace.
404    pub namespace: Option<MetricNamespace>,
405
406    /// A machine-readable reason code returned when this quota is exceeded.
407    ///
408    /// Required for all quotas except those with `limit` = None, since
409    /// unlimited quotas can never be exceeded.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub reason_code: Option<ReasonCode>,
412}
413
414impl Quota {
415    /// Returns whether this quota is valid for tracking.
416    ///
417    /// A quota is considered invalid if any of the following conditions are true:
418    ///  - The quota only applies to [`DataCategory::Unknown`] data categories.
419    ///  - The quota is counted (not limit `0`) but specifies categories with different units.
420    ///  - The quota references an unsupported namespace.
421    pub fn is_valid(&self) -> bool {
422        if self.namespace == Some(MetricNamespace::Unsupported) {
423            return false;
424        }
425
426        let mut units = self.categories.iter().filter_map(CategoryUnit::from);
427
428        match units.next() {
429            // There are only unknown categories, which is always invalid
430            None if !self.categories.is_empty() => false,
431            // This is a reject all quota, which is always valid
432            _ if self.limit == Some(0) => true,
433            // Applies to all categories, which implies multiple units
434            None => false,
435            // There are multiple categories, which must all have the same units
436            Some(unit) => units.all(|u| u == unit),
437        }
438    }
439
440    /// Checks whether this quota's scope matches the given item scoping.
441    ///
442    /// This quota matches, if:
443    ///  - there is no `scope_id` constraint
444    ///  - the `scope_id` constraint is not numeric
445    ///  - the scope identifier matches the one from ascoping and the scope is known
446    fn matches_scope(&self, scoping: ItemScoping) -> bool {
447        if self.scope == QuotaScope::Global {
448            return true;
449        }
450
451        // Check for a scope identifier constraint. If there is no constraint, this means that the
452        // quota matches any scope. In case the scope is unknown, it will be coerced to the most
453        // specific scope later.
454        let Some(scope_id) = self.scope_id.as_ref() else {
455            return true;
456        };
457
458        // Check if the scope identifier in the quota is parseable. If not, this means we cannot
459        // fulfill the constraint, so the quota does not match.
460        let Ok(parsed) = scope_id.parse::<u64>() else {
461            return false;
462        };
463
464        // At this stage, require that the scope is known since we have to fulfill the constraint.
465        scoping.scope_id(self.scope) == Some(parsed)
466    }
467
468    /// Checks whether the quota's constraints match the current item.
469    ///
470    /// This method determines if this quota should be applied to a given item
471    /// based on its scope, categories, and namespace.
472    pub fn matches(&self, scoping: ItemScoping) -> bool {
473        self.matches_scope(scoping)
474            && scoping.matches_categories(&self.categories)
475            && scoping.matches_namespaces(&self.namespace)
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use smallvec::smallvec;
482
483    use super::*;
484
485    #[test]
486    fn test_parse_quota_reject_all() {
487        let json = r#"{
488            "limit": 0,
489            "reasonCode": "not_yet"
490        }"#;
491
492        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
493
494        insta::assert_ron_snapshot!(quota, @r###"
495        Quota(
496          id: None,
497          categories: [],
498          scope: organization,
499          limit: Some(0),
500          namespace: None,
501          reasonCode: Some(ReasonCode("not_yet")),
502        )
503        "###);
504    }
505
506    #[test]
507    fn test_parse_quota_reject_transactions() {
508        let json = r#"{
509            "limit": 0,
510            "categories": ["transaction"],
511            "reasonCode": "not_yet"
512        }"#;
513
514        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
515
516        insta::assert_ron_snapshot!(quota, @r###"
517        Quota(
518          id: None,
519          categories: [
520            transaction,
521          ],
522          scope: organization,
523          limit: Some(0),
524          namespace: None,
525          reasonCode: Some(ReasonCode("not_yet")),
526        )
527        "###);
528    }
529
530    #[test]
531    fn test_parse_quota_limited() {
532        let json = r#"{
533            "id": "o",
534            "limit": 4711,
535            "window": 42,
536            "reasonCode": "not_so_fast"
537        }"#;
538
539        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
540
541        insta::assert_ron_snapshot!(quota, @r###"
542        Quota(
543          id: Some("o"),
544          categories: [],
545          scope: organization,
546          limit: Some(4711),
547          window: Some(42),
548          namespace: None,
549          reasonCode: Some(ReasonCode("not_so_fast")),
550        )
551        "###);
552    }
553
554    #[test]
555    fn test_parse_quota_project() {
556        let json = r#"{
557            "id": "p",
558            "scope": "project",
559            "scopeId": "1",
560            "limit": 4711,
561            "window": 42,
562            "reasonCode": "not_so_fast"
563        }"#;
564
565        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
566
567        insta::assert_ron_snapshot!(quota, @r###"
568        Quota(
569          id: Some("p"),
570          categories: [],
571          scope: project,
572          scopeId: Some("1"),
573          limit: Some(4711),
574          window: Some(42),
575          namespace: None,
576          reasonCode: Some(ReasonCode("not_so_fast")),
577        )
578        "###);
579    }
580
581    #[test]
582    fn test_parse_quota_project_large() {
583        let json = r#"{
584            "id": "p",
585            "scope": "project",
586            "scopeId": "1",
587            "limit": 4294967296,
588            "window": 42,
589            "reasonCode": "not_so_fast"
590        }"#;
591
592        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
593
594        insta::assert_ron_snapshot!(quota, @r###"
595        Quota(
596          id: Some("p"),
597          categories: [],
598          scope: project,
599          scopeId: Some("1"),
600          limit: Some(4294967296),
601          window: Some(42),
602          namespace: None,
603          reasonCode: Some(ReasonCode("not_so_fast")),
604        )
605        "###);
606    }
607
608    #[test]
609    fn test_parse_quota_key() {
610        let json = r#"{
611            "id": "k",
612            "scope": "key",
613            "scopeId": "1",
614            "limit": 4711,
615            "window": 42,
616            "reasonCode": "not_so_fast"
617        }"#;
618
619        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
620
621        insta::assert_ron_snapshot!(quota, @r###"
622        Quota(
623          id: Some("k"),
624          categories: [],
625          scope: key,
626          scopeId: Some("1"),
627          limit: Some(4711),
628          window: Some(42),
629          namespace: None,
630          reasonCode: Some(ReasonCode("not_so_fast")),
631        )
632        "###);
633    }
634
635    #[test]
636    fn test_parse_quota_unknown_variants() {
637        let json = r#"{
638            "id": "f",
639            "categories": ["future"],
640            "scope": "future",
641            "scopeId": "1",
642            "limit": 4711,
643            "window": 42,
644            "reasonCode": "not_so_fast"
645        }"#;
646
647        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
648
649        insta::assert_ron_snapshot!(quota, @r###"
650        Quota(
651          id: Some("f"),
652          categories: [
653            unknown,
654          ],
655          scope: unknown,
656          scopeId: Some("1"),
657          limit: Some(4711),
658          window: Some(42),
659          namespace: None,
660          reasonCode: Some(ReasonCode("not_so_fast")),
661        )
662        "###);
663    }
664
665    #[test]
666    fn test_parse_quota_unlimited() {
667        let json = r#"{
668            "id": "o",
669            "window": 42
670        }"#;
671
672        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
673
674        insta::assert_ron_snapshot!(quota, @r###"
675        Quota(
676          id: Some("o"),
677          categories: [],
678          scope: organization,
679          limit: None,
680          window: Some(42),
681          namespace: None,
682        )
683        "###);
684    }
685
686    #[test]
687    fn test_quota_valid_reject_all() {
688        let quota = Quota {
689            id: None,
690            categories: DataCategories::new(),
691            scope: QuotaScope::Organization,
692            scope_id: None,
693            limit: Some(0),
694            window: None,
695            reason_code: None,
696            namespace: None,
697        };
698
699        assert!(quota.is_valid());
700    }
701
702    #[test]
703    fn test_quota_invalid_only_unknown() {
704        let quota = Quota {
705            id: None,
706            categories: smallvec![DataCategory::Unknown, DataCategory::Unknown],
707            scope: QuotaScope::Organization,
708            scope_id: None,
709            limit: Some(0),
710            window: None,
711            reason_code: None,
712            namespace: None,
713        };
714
715        assert!(!quota.is_valid());
716    }
717
718    #[test]
719    fn test_quota_valid_reject_all_mixed() {
720        let quota = Quota {
721            id: None,
722            categories: smallvec![DataCategory::Error, DataCategory::Attachment],
723            scope: QuotaScope::Organization,
724            scope_id: None,
725            limit: Some(0),
726            window: None,
727            reason_code: None,
728            namespace: None,
729        };
730
731        assert!(quota.is_valid());
732    }
733
734    #[test]
735    fn test_quota_invalid_limited_mixed() {
736        let quota = Quota {
737            id: None,
738            categories: smallvec![DataCategory::Error, DataCategory::Attachment],
739            scope: QuotaScope::Organization,
740            scope_id: None,
741            limit: Some(1000),
742            window: None,
743            reason_code: None,
744            namespace: None,
745        };
746
747        // This category is limited and counted, but has multiple units.
748        assert!(!quota.is_valid());
749    }
750
751    #[test]
752    fn test_quota_invalid_unlimited_mixed() {
753        let quota = Quota {
754            id: None,
755            categories: smallvec![DataCategory::Error, DataCategory::Attachment],
756            scope: QuotaScope::Organization,
757            scope_id: None,
758            limit: None,
759            window: None,
760            reason_code: None,
761            namespace: None,
762        };
763
764        // This category is unlimited and counted, but has multiple units.
765        assert!(!quota.is_valid());
766    }
767
768    #[test]
769    fn test_quota_matches_no_categories() {
770        let quota = Quota {
771            id: None,
772            categories: DataCategories::new(),
773            scope: QuotaScope::Organization,
774            scope_id: None,
775            limit: None,
776            window: None,
777            reason_code: None,
778            namespace: None,
779        };
780
781        assert!(quota.matches(ItemScoping {
782            category: DataCategory::Error,
783            scoping: Scoping {
784                organization_id: OrganizationId::new(42),
785                project_id: ProjectId::new(21),
786                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
787                key_id: Some(17),
788            },
789            namespace: MetricNamespaceScoping::None,
790        }));
791    }
792
793    #[test]
794    fn test_quota_matches_unknown_category() {
795        let quota = Quota {
796            id: None,
797            categories: smallvec![DataCategory::Unknown],
798            scope: QuotaScope::Organization,
799            scope_id: None,
800            limit: None,
801            window: None,
802            reason_code: None,
803            namespace: None,
804        };
805
806        assert!(!quota.matches(ItemScoping {
807            category: DataCategory::Error,
808            scoping: Scoping {
809                organization_id: OrganizationId::new(42),
810                project_id: ProjectId::new(21),
811                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
812                key_id: Some(17),
813            },
814            namespace: MetricNamespaceScoping::None,
815        }));
816    }
817
818    #[test]
819    fn test_quota_matches_multiple_categores() {
820        let quota = Quota {
821            id: None,
822            categories: smallvec![DataCategory::Unknown, DataCategory::Error],
823            scope: QuotaScope::Organization,
824            scope_id: None,
825            limit: None,
826            window: None,
827            reason_code: None,
828            namespace: None,
829        };
830
831        assert!(quota.matches(ItemScoping {
832            category: DataCategory::Error,
833            scoping: Scoping {
834                organization_id: OrganizationId::new(42),
835                project_id: ProjectId::new(21),
836                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
837                key_id: Some(17),
838            },
839            namespace: MetricNamespaceScoping::None,
840        }));
841
842        assert!(!quota.matches(ItemScoping {
843            category: DataCategory::Transaction,
844            scoping: Scoping {
845                organization_id: OrganizationId::new(42),
846                project_id: ProjectId::new(21),
847                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
848                key_id: Some(17),
849            },
850            namespace: MetricNamespaceScoping::None,
851        }));
852    }
853
854    #[test]
855    fn test_quota_matches_no_invalid_scope() {
856        let quota = Quota {
857            id: None,
858            categories: DataCategories::new(),
859            scope: QuotaScope::Organization,
860            scope_id: Some("not_a_number".to_owned()),
861            limit: None,
862            window: None,
863            reason_code: None,
864            namespace: None,
865        };
866
867        assert!(!quota.matches(ItemScoping {
868            category: DataCategory::Error,
869            scoping: Scoping {
870                organization_id: OrganizationId::new(42),
871                project_id: ProjectId::new(21),
872                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
873                key_id: Some(17),
874            },
875            namespace: MetricNamespaceScoping::None,
876        }));
877    }
878
879    #[test]
880    fn test_quota_matches_organization_scope() {
881        let quota = Quota {
882            id: None,
883            categories: DataCategories::new(),
884            scope: QuotaScope::Organization,
885            scope_id: Some("42".to_owned()),
886            limit: None,
887            window: None,
888            reason_code: None,
889            namespace: None,
890        };
891
892        assert!(quota.matches(ItemScoping {
893            category: DataCategory::Error,
894            scoping: Scoping {
895                organization_id: OrganizationId::new(42),
896                project_id: ProjectId::new(21),
897                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
898                key_id: Some(17),
899            },
900            namespace: MetricNamespaceScoping::None,
901        }));
902
903        assert!(!quota.matches(ItemScoping {
904            category: DataCategory::Error,
905            scoping: Scoping {
906                organization_id: OrganizationId::new(0),
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
915    #[test]
916    fn test_quota_matches_project_scope() {
917        let quota = Quota {
918            id: None,
919            categories: DataCategories::new(),
920            scope: QuotaScope::Project,
921            scope_id: Some("21".to_owned()),
922            limit: None,
923            window: None,
924            reason_code: None,
925            namespace: None,
926        };
927
928        assert!(quota.matches(ItemScoping {
929            category: DataCategory::Error,
930            scoping: Scoping {
931                organization_id: OrganizationId::new(42),
932                project_id: ProjectId::new(21),
933                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
934                key_id: Some(17),
935            },
936            namespace: MetricNamespaceScoping::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(0),
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_key_scope() {
953        let quota = Quota {
954            id: None,
955            categories: DataCategories::new(),
956            scope: QuotaScope::Key,
957            scope_id: Some("17".to_owned()),
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(42),
979                project_id: ProjectId::new(21),
980                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
981                key_id: Some(0),
982            },
983            namespace: MetricNamespaceScoping::None,
984        }));
985
986        assert!(!quota.matches(ItemScoping {
987            category: DataCategory::Error,
988            scoping: Scoping {
989                organization_id: OrganizationId::new(42),
990                project_id: ProjectId::new(21),
991                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
992                key_id: None,
993            },
994            namespace: MetricNamespaceScoping::None,
995        }));
996    }
997}