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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub struct Scoping {
20 pub organization_id: OrganizationId,
22
23 pub project_id: ProjectId,
25
26 pub project_key: ProjectKey,
28
29 pub key_id: Option<u64>,
31}
32
33impl Scoping {
34 pub fn item(&self, category: DataCategory) -> ItemScoping {
40 ItemScoping {
41 category,
42 scoping: *self,
43 namespace: MetricNamespaceScoping::None,
44 }
45 }
46
47 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, PartialOrd)]
66pub enum MetricNamespaceScoping {
67 #[default]
71 None,
72
73 Some(MetricNamespace),
75
76 Any,
81}
82
83impl MetricNamespaceScoping {
84 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#[derive(Debug, Copy, Clone, Eq, PartialEq)]
109pub struct ItemScoping {
110 pub category: DataCategory,
112
113 pub scoping: Scoping,
115
116 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 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 pub(crate) fn matches_categories(&self, categories: &DataCategories) -> bool {
145 categories.is_empty() || categories.contains(&self.category)
150 }
151
152 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179pub enum CategoryUnit {
180 Count,
182 Bytes,
184 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
226pub type DataCategories = SmallVec<[DataCategory; 8]>;
232
233#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
242#[serde(rename_all = "lowercase")]
243pub enum QuotaScope {
244 Global,
246 Organization,
250
251 Project,
255
256 Key,
260
261 #[serde(other)]
263 Unknown,
264}
265
266impl QuotaScope {
267 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 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
317pub struct ReasonCode(String);
318
319impl ReasonCode {
320 pub fn new<S: Into<String>>(code: S) -> Self {
326 Self(code.into())
327 }
328
329 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#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
354#[serde(rename_all = "camelCase")]
355pub struct Quota {
356 #[serde(default)]
360 pub id: Option<String>,
361
362 #[serde(default = "DataCategories::new")]
366 pub categories: DataCategories,
367
368 #[serde(default = "default_scope")]
373 pub scope: QuotaScope,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub scope_id: Option<String>,
381
382 #[serde(default)]
391 pub limit: Option<u64>,
392
393 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub window: Option<u64>,
399
400 pub namespace: Option<MetricNamespace>,
404
405 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub reason_code: Option<ReasonCode>,
411}
412
413impl Quota {
414 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 None if !self.categories.is_empty() => false,
430 _ if self.limit == Some(0) => true,
432 None => false,
434 Some(unit) => units.all(|u| u == unit),
436 }
437 }
438
439 fn matches_scope(&self, scoping: ItemScoping) -> bool {
446 if self.scope == QuotaScope::Global {
447 return true;
448 }
449
450 let Some(scope_id) = self.scope_id.as_ref() else {
454 return true;
455 };
456
457 let Ok(parsed) = scope_id.parse::<u64>() else {
460 return false;
461 };
462
463 scoping.scope_id(self.scope) == Some(parsed)
465 }
466
467 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 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 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}