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