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::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
230pub type DataCategories = SmallVec<[DataCategory; 8]>;
236
237#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
246#[serde(rename_all = "lowercase")]
247pub enum QuotaScope {
248 Global,
250 Organization,
254
255 Project,
259
260 Key,
264
265 #[serde(other)]
267 Unknown,
268}
269
270impl QuotaScope {
271 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 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
321pub struct ReasonCode(String);
322
323impl ReasonCode {
324 pub fn new<S: Into<String>>(code: S) -> Self {
330 Self(code.into())
331 }
332
333 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#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
358#[serde(rename_all = "camelCase")]
359pub struct Quota {
360 #[serde(default)]
364 pub id: Option<String>,
365
366 #[serde(default = "DataCategories::new")]
370 pub categories: DataCategories,
371
372 #[serde(default = "default_scope")]
377 pub scope: QuotaScope,
378
379 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub scope_id: Option<String>,
385
386 #[serde(default)]
395 pub limit: Option<u64>,
396
397 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub window: Option<u64>,
403
404 pub namespace: Option<MetricNamespace>,
408
409 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub reason_code: Option<ReasonCode>,
415}
416
417impl Quota {
418 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 None if !self.categories.is_empty() => false,
434 _ if self.limit == Some(0) => true,
436 None => false,
438 Some(unit) => units.all(|u| u == unit),
440 }
441 }
442
443 fn matches_scope(&self, scoping: ItemScoping) -> bool {
450 if self.scope == QuotaScope::Global {
451 return true;
452 }
453
454 let Some(scope_id) = self.scope_id.as_ref() else {
458 return true;
459 };
460
461 let Ok(parsed) = scope_id.parse::<u64>() else {
464 return false;
465 };
466
467 scoping.scope_id(self.scope) == Some(parsed)
469 }
470
471 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 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 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}