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.iter().any(|cat| *cat == 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::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
227pub type DataCategories = SmallVec<[DataCategory; 8]>;
233
234#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
243#[serde(rename_all = "lowercase")]
244pub enum QuotaScope {
245 Global,
247 Organization,
251
252 Project,
256
257 Key,
261
262 #[serde(other)]
264 Unknown,
265}
266
267impl QuotaScope {
268 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 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
318pub struct ReasonCode(String);
319
320impl ReasonCode {
321 pub fn new<S: Into<String>>(code: S) -> Self {
327 Self(code.into())
328 }
329
330 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#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
355#[serde(rename_all = "camelCase")]
356pub struct Quota {
357 #[serde(default)]
361 pub id: Option<String>,
362
363 #[serde(default = "DataCategories::new")]
367 pub categories: DataCategories,
368
369 #[serde(default = "default_scope")]
374 pub scope: QuotaScope,
375
376 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub scope_id: Option<String>,
382
383 #[serde(default)]
392 pub limit: Option<u64>,
393
394 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub window: Option<u64>,
400
401 pub namespace: Option<MetricNamespace>,
405
406 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub reason_code: Option<ReasonCode>,
412}
413
414impl Quota {
415 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 None if !self.categories.is_empty() => false,
431 _ if self.limit == Some(0) => true,
433 None => false,
435 Some(unit) => units.all(|u| u == unit),
437 }
438 }
439
440 fn matches_scope(&self, scoping: ItemScoping) -> bool {
447 if self.scope == QuotaScope::Global {
448 return true;
449 }
450
451 let Some(scope_id) = self.scope_id.as_ref() else {
455 return true;
456 };
457
458 let Ok(parsed) = scope_id.parse::<u64>() else {
461 return false;
462 };
463
464 scoping.scope_id(self.scope) == Some(parsed)
466 }
467
468 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 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 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}