1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{self, Write};
3use std::hash::{Hash, Hasher};
4use std::str::FromStr;
5use std::sync::Arc;
6
7use relay_base_schema::metrics::MetricNamespace;
8use relay_base_schema::organization::OrganizationId;
9use relay_base_schema::project::{ProjectId, ProjectKey};
10use serde::ser::SerializeSeq;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13
14#[doc(inline)]
15pub use relay_base_schema::data_category::{CategoryUnit, DataCategory};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct Scoping {
24 pub organization_id: OrganizationId,
26
27 pub project_id: ProjectId,
29
30 pub project_key: ProjectKey,
32
33 pub key_id: Option<u64>,
35}
36
37impl Scoping {
38 pub fn item(&self, category: DataCategory) -> ItemScoping {
44 ItemScoping {
45 category,
46 scoping: *self,
47 namespace: MetricNamespaceScoping::None,
48 dimensions: DimensionMap::default(),
49 }
50 }
51
52 pub fn item_with_dimensions(
54 &self,
55 category: DataCategory,
56 dimensions: DimensionMap,
57 ) -> ItemScoping {
58 ItemScoping {
59 category,
60 scoping: *self,
61 namespace: MetricNamespaceScoping::None,
62 dimensions,
63 }
64 }
65
66 pub fn metric_bucket(&self, namespace: MetricNamespace) -> ItemScoping {
72 ItemScoping {
73 category: DataCategory::MetricBucket,
74 scoping: *self,
75 namespace: MetricNamespaceScoping::Some(namespace),
76 dimensions: DimensionMap::default(),
77 }
78 }
79}
80
81#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, PartialOrd)]
86pub enum MetricNamespaceScoping {
87 #[default]
91 None,
92
93 Some(MetricNamespace),
95
96 Any,
101}
102
103impl MetricNamespaceScoping {
104 pub fn matches(&self, namespace: MetricNamespace) -> bool {
110 match self {
111 Self::None => false,
112 Self::Some(ns) => *ns == namespace,
113 Self::Any => true,
114 }
115 }
116}
117
118impl From<MetricNamespace> for MetricNamespaceScoping {
119 fn from(namespace: MetricNamespace) -> Self {
120 Self::Some(namespace)
121 }
122}
123
124#[derive(Debug, Clone, Eq, PartialEq)]
129pub struct ItemScoping {
130 pub category: DataCategory,
132
133 pub scoping: Scoping,
135
136 pub namespace: MetricNamespaceScoping,
138
139 pub dimensions: DimensionMap,
141}
142
143#[derive(Default, Debug, Clone, Eq, PartialEq)]
145pub struct DimensionMap(Arc<BTreeMap<Dimension, String>>);
146
147impl<const N: usize> From<[(Dimension, String); N]> for DimensionMap {
148 fn from(arr: [(Dimension, String); N]) -> Self {
149 Self(Arc::new(BTreeMap::from(arr)))
150 }
151}
152
153impl FromIterator<(Dimension, String)> for DimensionMap {
154 fn from_iter<T: IntoIterator<Item = (Dimension, String)>>(iter: T) -> DimensionMap {
155 Self(Arc::new(BTreeMap::from_iter(iter)))
156 }
157}
158
159impl DimensionMap {
160 pub fn to_string(&self, quota: &Quota) -> Option<String> {
165 let mut result = String::new();
166
167 let Some(group_by) = "a.group_by else {
168 return None;
169 };
170
171 for dim in group_by.dimensions.iter() {
172 if let Some((key, val)) = self.0.get_key_value(dim) {
173 let mut hasher = fnv::FnvHasher::with_key(1);
174 val.hash(&mut hasher);
175 let h = &hasher.finish();
176
177 write!(&mut result, ":{key}:{h}").expect("should be infallible");
178 }
179 }
180
181 if result.is_empty() {
182 return None;
183 }
184
185 result.into()
186 }
187}
188
189impl std::ops::Deref for ItemScoping {
190 type Target = Scoping;
191
192 fn deref(&self) -> &Self::Target {
193 &self.scoping
194 }
195}
196
197impl ItemScoping {
198 pub fn scope_id(&self, scope: QuotaScope) -> Option<u64> {
203 match scope {
204 QuotaScope::Organization => Some(self.organization_id.value()),
205 QuotaScope::Project => Some(self.project_id.value()),
206 QuotaScope::Key => self.key_id,
207 QuotaScope::Unknown => None,
208 }
209 }
210
211 pub(crate) fn matches_categories(&self, categories: DataCategories) -> bool {
213 categories.is_empty() || categories.contains(&self.category)
218 }
219
220 pub(crate) fn matches_dimensions(&self, group_by: &Option<GroupBy>) -> bool {
222 let Some(gb) = group_by else { return true };
223
224 gb.dimensions
225 .iter()
226 .all(|dim| self.dimensions.0.contains_key(dim))
227 }
228
229 pub(crate) fn matches_namespaces<'a, I>(&self, namespaces: I) -> bool
243 where
244 I: IntoIterator<Item = &'a MetricNamespace>,
245 {
246 let mut iter = namespaces.into_iter().peekable();
247 iter.peek().is_none() || iter.any(|ns| self.namespace.matches(*ns))
248 }
249}
250
251#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Hash)]
255pub struct DataCategories(u64);
256
257impl Serialize for DataCategories {
258 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
259 where
260 S: serde::Serializer,
261 {
262 let mut ser = serializer.serialize_seq(Some(self.len()))?;
263 for category in self.iter() {
264 ser.serialize_element(&category)?;
265 }
266 ser.end()
267 }
268}
269
270impl DataCategories {
271 fn category_to_mask(category: DataCategory) -> u64 {
272 if matches!(category, DataCategory::Unknown) {
273 1u64 << 63
275 } else {
276 1u64 << (category as u8)
277 }
278 }
279
280 fn bit_number_to_category(bit_number: u8) -> DataCategory {
281 bit_number.try_into().unwrap_or(DataCategory::Unknown)
282 }
283
284 pub fn new() -> Self {
286 Default::default()
287 }
288
289 pub fn from_slice(slice: &[DataCategory]) -> Self {
291 let mut categories = 0;
292 for category in slice {
293 categories |= DataCategories::category_to_mask(*category);
294 }
295
296 Self(categories)
297 }
298
299 pub fn add(&self, category: DataCategory) -> Option<Self> {
304 let category_mask = DataCategories::category_to_mask(category);
305
306 if (self.0 & category_mask) != 0 {
307 return None;
308 }
309
310 Some(Self(self.0 | category_mask))
311 }
312
313 pub fn contains(&self, category: &DataCategory) -> bool {
315 let category_mask = DataCategories::category_to_mask(*category);
316 (self.0 & category_mask) != 0
317 }
318
319 pub fn iter(&self) -> DataCategoryIterator {
321 let bit_start = self.0.trailing_zeros();
324
325 let (categories, _) = self.0.overflowing_shr(bit_start);
327 DataCategoryIterator {
328 categories,
329 current_bit: bit_start as u8,
330 }
331 }
332
333 pub fn len(&self) -> usize {
335 self.0.count_ones() as usize
336 }
337
338 pub fn is_empty(&self) -> bool {
340 self.0 == 0
341 }
342}
343
344pub struct DataCategoryIterator {
346 categories: u64,
347 current_bit: u8,
348}
349
350impl Iterator for DataCategoryIterator {
351 type Item = DataCategory;
352
353 fn next(&mut self) -> Option<Self::Item> {
354 if self.categories == 0 {
355 return None;
356 }
357 let category = DataCategories::bit_number_to_category(self.current_bit);
358
359 self.categories &= u64::MAX - 1;
361
362 let trailing_zeroes = self.categories.trailing_zeros() as u8;
364
365 (self.categories, _) = self.categories.overflowing_shr(trailing_zeroes as u32);
367
368 self.current_bit += trailing_zeroes;
370
371 Some(category)
372 }
373}
374
375impl<'de> Deserialize<'de> for DataCategories {
376 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
377 where
378 D: serde::Deserializer<'de>,
379 {
380 SmallVec::<[DataCategory; 12]>::deserialize(deserializer).map(Self::from)
381 }
382}
383
384impl<const N: usize> From<SmallVec<[DataCategory; N]>> for DataCategories {
385 fn from(categories: SmallVec<[DataCategory; N]>) -> Self {
386 Self::from_slice(&categories)
387 }
388}
389
390impl<const N: usize> From<[DataCategory; N]> for DataCategories {
391 fn from(categories: [DataCategory; N]) -> Self {
392 Self::from_slice(&categories)
393 }
394}
395
396impl FromIterator<DataCategory> for DataCategories {
397 fn from_iter<T: IntoIterator<Item = DataCategory>>(iter: T) -> Self {
398 let v: SmallVec<[DataCategory; 12]> = iter.into_iter().collect();
399 Self::from_slice(&v)
400 }
401}
402
403#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
412#[serde(rename_all = "lowercase")]
413pub enum QuotaScope {
414 Organization,
418
419 Project,
423
424 Key,
428
429 #[serde(other)]
431 Unknown,
432}
433
434impl QuotaScope {
435 pub fn from_name(string: &str) -> Self {
439 match string {
440 "organization" => Self::Organization,
441 "project" => Self::Project,
442 "key" => Self::Key,
443 _ => Self::Unknown,
444 }
445 }
446
447 pub fn name(self) -> &'static str {
451 match self {
452 Self::Key => "key",
453 Self::Project => "project",
454 Self::Organization => "organization",
455 Self::Unknown => "unknown",
456 }
457 }
458}
459
460impl fmt::Display for QuotaScope {
461 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462 write!(f, "{}", self.name())
463 }
464}
465
466impl FromStr for QuotaScope {
467 type Err = ();
468
469 fn from_str(string: &str) -> Result<Self, Self::Err> {
470 Ok(Self::from_name(string))
471 }
472}
473
474fn default_scope() -> QuotaScope {
475 QuotaScope::Organization
476}
477
478#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
483pub struct ReasonCode(Arc<str>);
484
485impl ReasonCode {
486 pub fn new<S: Into<Arc<str>>>(code: S) -> Self {
492 Self(code.into())
493 }
494
495 pub fn as_str(&self) -> &str {
497 &self.0
498 }
499}
500
501impl fmt::Display for ReasonCode {
502 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503 self.0.fmt(f)
504 }
505}
506
507#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
520#[serde(rename_all = "camelCase")]
521pub struct Quota {
522 #[serde(default)]
526 pub id: Option<Arc<str>>,
527
528 #[serde(default)]
532 pub categories: DataCategories,
533
534 #[serde(default = "default_scope")]
539 pub scope: QuotaScope,
540
541 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub scope_id: Option<Arc<str>>,
547
548 #[serde(default)]
557 pub limit: Option<u64>,
558
559 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub window: Option<u64>,
565
566 pub namespace: Option<MetricNamespace>,
570
571 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub reason_code: Option<ReasonCode>,
577
578 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub group_by: Option<GroupBy>,
581}
582
583#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
586#[serde(rename_all = "camelCase")]
587pub struct GroupBy {
588 pub max_cardinality: u32,
590
591 pub dimensions: Arc<BTreeSet<Dimension>>,
593}
594
595#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord, Hash)]
597#[serde(rename_all = "camelCase")]
598pub enum Dimension {
599 CheckInEnvironment,
601
602 CheckInSlug,
604
605 #[serde(other)]
609 Unknown,
610}
611
612impl fmt::Display for Dimension {
613 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614 match self {
615 Dimension::CheckInEnvironment => f.write_str("check_in.environment"),
616 Dimension::CheckInSlug => f.write_str("check_in.slug"),
617 Dimension::Unknown => f.write_str("unknown"),
618 }
619 }
620}
621
622impl Quota {
623 pub fn is_valid(&self) -> bool {
631 if self.namespace == Some(MetricNamespace::Unsupported) {
632 return false;
633 }
634
635 if let Some(group_by) = &self.group_by {
636 if group_by.dimensions.is_empty() {
637 return false;
638 }
639
640 for dim in group_by.dimensions.iter() {
641 if *dim == Dimension::Unknown {
642 return false;
643 }
644 }
645 }
646
647 let mut units = self
648 .categories
649 .iter()
650 .filter_map(CategoryUnit::from_category);
651
652 match units.next() {
653 None if !self.categories.is_empty() => false,
655 _ if self.limit == Some(0) => true,
657 None => false,
659 Some(unit) => units.all(|u| u == unit),
661 }
662 }
663
664 fn matches_scope(&self, scoping: &ItemScoping) -> bool {
671 let Some(scope_id) = self.scope_id.as_ref() else {
675 return true;
676 };
677
678 let Ok(parsed) = scope_id.parse::<u64>() else {
681 return false;
682 };
683
684 scoping.scope_id(self.scope) == Some(parsed)
686 }
687
688 pub fn matches(&self, scoping: &ItemScoping) -> bool {
693 self.matches_scope(scoping)
694 && scoping.matches_categories(self.categories)
695 && scoping.matches_namespaces(&self.namespace)
696 && scoping.matches_dimensions(&self.group_by)
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use relay_base_schema::data_category::UnknownDataCategory;
703
704 use super::*;
705
706 #[test]
707 fn test_parse_quota_reject_all() {
708 let json = r#"{
709 "limit": 0,
710 "reasonCode": "not_yet"
711 }"#;
712
713 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
714
715 insta::assert_ron_snapshot!(quota, @r###"
716 Quota(
717 id: None,
718 categories: [],
719 scope: organization,
720 limit: Some(0),
721 namespace: None,
722 reasonCode: Some(ReasonCode("not_yet")),
723 )
724 "###);
725 }
726
727 #[test]
728 fn test_parse_quota_reject_transactions() {
729 let json = r#"{
730 "limit": 0,
731 "categories": ["transaction"],
732 "reasonCode": "not_yet"
733 }"#;
734
735 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
736
737 insta::assert_ron_snapshot!(quota, @r#"
738 Quota(
739 id: None,
740 categories: [
741 "transaction",
742 ],
743 scope: organization,
744 limit: Some(0),
745 namespace: None,
746 reasonCode: Some(ReasonCode("not_yet")),
747 )
748 "#);
749 }
750
751 #[test]
752 fn test_parse_quota_limited() {
753 let json = r#"{
754 "id": "o",
755 "limit": 4711,
756 "window": 42,
757 "reasonCode": "not_so_fast"
758 }"#;
759
760 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
761
762 insta::assert_ron_snapshot!(quota, @r###"
763 Quota(
764 id: Some("o"),
765 categories: [],
766 scope: organization,
767 limit: Some(4711),
768 window: Some(42),
769 namespace: None,
770 reasonCode: Some(ReasonCode("not_so_fast")),
771 )
772 "###);
773 }
774
775 #[test]
776 fn test_parse_quota_project() {
777 let json = r#"{
778 "id": "p",
779 "scope": "project",
780 "scopeId": "1",
781 "limit": 4711,
782 "window": 42,
783 "reasonCode": "not_so_fast"
784 }"#;
785
786 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
787
788 insta::assert_ron_snapshot!(quota, @r###"
789 Quota(
790 id: Some("p"),
791 categories: [],
792 scope: project,
793 scopeId: Some("1"),
794 limit: Some(4711),
795 window: Some(42),
796 namespace: None,
797 reasonCode: Some(ReasonCode("not_so_fast")),
798 )
799 "###);
800 }
801
802 #[test]
803 fn test_parse_quota_project_large() {
804 let json = r#"{
805 "id": "p",
806 "scope": "project",
807 "scopeId": "1",
808 "limit": 4294967296,
809 "window": 42,
810 "reasonCode": "not_so_fast"
811 }"#;
812
813 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
814
815 insta::assert_ron_snapshot!(quota, @r###"
816 Quota(
817 id: Some("p"),
818 categories: [],
819 scope: project,
820 scopeId: Some("1"),
821 limit: Some(4294967296),
822 window: Some(42),
823 namespace: None,
824 reasonCode: Some(ReasonCode("not_so_fast")),
825 )
826 "###);
827 }
828
829 #[test]
830 fn test_parse_quota_key() {
831 let json = r#"{
832 "id": "k",
833 "scope": "key",
834 "scopeId": "1",
835 "limit": 4711,
836 "window": 42,
837 "reasonCode": "not_so_fast"
838 }"#;
839
840 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
841
842 insta::assert_ron_snapshot!(quota, @r###"
843 Quota(
844 id: Some("k"),
845 categories: [],
846 scope: key,
847 scopeId: Some("1"),
848 limit: Some(4711),
849 window: Some(42),
850 namespace: None,
851 reasonCode: Some(ReasonCode("not_so_fast")),
852 )
853 "###);
854 }
855
856 #[test]
857 fn test_parse_quota_unknown_variants() {
858 let json = r#"{
859 "id": "f",
860 "categories": ["future"],
861 "scope": "future",
862 "scopeId": "1",
863 "limit": 4711,
864 "window": 42,
865 "reasonCode": "not_so_fast"
866 }"#;
867
868 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
869
870 insta::assert_ron_snapshot!(quota, @r#"
871 Quota(
872 id: Some("f"),
873 categories: [
874 "unknown",
875 ],
876 scope: unknown,
877 scopeId: Some("1"),
878 limit: Some(4711),
879 window: Some(42),
880 namespace: None,
881 reasonCode: Some(ReasonCode("not_so_fast")),
882 )
883 "#);
884 }
885
886 #[test]
887 fn test_parse_quota_unlimited() {
888 let json = r#"{
889 "id": "o",
890 "window": 42
891 }"#;
892
893 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
894
895 insta::assert_ron_snapshot!(quota, @r###"
896 Quota(
897 id: Some("o"),
898 categories: [],
899 scope: organization,
900 limit: None,
901 window: Some(42),
902 namespace: None,
903 )
904 "###);
905 }
906
907 #[test]
908 fn test_quota_valid_reject_all() {
909 let quota = Quota {
910 id: None,
911 categories: Default::default(),
912 scope: QuotaScope::Organization,
913 scope_id: None,
914 limit: Some(0),
915 window: None,
916 reason_code: None,
917 namespace: None,
918 group_by: None,
919 };
920
921 assert!(quota.is_valid());
922 }
923
924 #[test]
925 fn test_quota_invalid_only_unknown() {
926 let quota = Quota {
927 id: None,
928 categories: [DataCategory::Unknown, DataCategory::Unknown].into(),
929 scope: QuotaScope::Organization,
930 scope_id: None,
931 limit: Some(0),
932 window: None,
933 reason_code: None,
934 namespace: None,
935 group_by: None,
936 };
937
938 assert!(!quota.is_valid());
939 }
940
941 #[test]
942 fn test_quota_valid_reject_all_mixed() {
943 let quota = Quota {
944 id: None,
945 categories: [DataCategory::Error, DataCategory::Attachment].into(),
946 scope: QuotaScope::Organization,
947 scope_id: None,
948 limit: Some(0),
949 window: None,
950 reason_code: None,
951 namespace: None,
952 group_by: None,
953 };
954
955 assert!(quota.is_valid());
956 }
957
958 #[test]
959 fn test_quota_invalid_limited_mixed() {
960 let quota = Quota {
961 id: None,
962 categories: [DataCategory::Error, DataCategory::Attachment].into(),
963 scope: QuotaScope::Organization,
964 scope_id: None,
965 limit: Some(1000),
966 window: None,
967 reason_code: None,
968 namespace: None,
969 group_by: None,
970 };
971
972 assert!(!quota.is_valid());
974 }
975
976 #[test]
977 fn test_quota_invalid_unlimited_mixed() {
978 let quota = Quota {
979 id: None,
980 categories: [DataCategory::Error, DataCategory::Attachment].into(),
981 scope: QuotaScope::Organization,
982 scope_id: None,
983 limit: None,
984 window: None,
985 reason_code: None,
986 namespace: None,
987 group_by: None,
988 };
989
990 assert!(!quota.is_valid());
992 }
993
994 #[test]
995 fn test_quota_matches_no_categories() {
996 let quota = Quota {
997 id: None,
998 categories: Default::default(),
999 scope: QuotaScope::Organization,
1000 scope_id: None,
1001 limit: None,
1002 window: None,
1003 reason_code: None,
1004 namespace: None,
1005 group_by: None,
1006 };
1007
1008 assert!(quota.matches(&ItemScoping {
1009 category: DataCategory::Error,
1010 scoping: Scoping {
1011 organization_id: OrganizationId::new(42),
1012 project_id: ProjectId::new(21),
1013 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1014 key_id: Some(17),
1015 },
1016 namespace: MetricNamespaceScoping::None,
1017 dimensions: DimensionMap::default(),
1018 }));
1019 }
1020
1021 #[test]
1022 fn test_quota_matches_unknown_category() {
1023 let quota = Quota {
1024 id: None,
1025 categories: [DataCategory::Unknown].into(),
1026 scope: QuotaScope::Organization,
1027 scope_id: None,
1028 limit: None,
1029 window: None,
1030 reason_code: None,
1031 namespace: None,
1032 group_by: None,
1033 };
1034
1035 assert!(!quota.matches(&ItemScoping {
1036 category: DataCategory::Error,
1037 scoping: Scoping {
1038 organization_id: OrganizationId::new(42),
1039 project_id: ProjectId::new(21),
1040 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1041 key_id: Some(17),
1042 },
1043 namespace: MetricNamespaceScoping::None,
1044 dimensions: DimensionMap::default(),
1045 }));
1046 }
1047
1048 #[test]
1049 fn test_quota_matches_multiple_categores() {
1050 let quota = Quota {
1051 id: None,
1052 categories: [DataCategory::Unknown, DataCategory::Error].into(),
1053 scope: QuotaScope::Organization,
1054 scope_id: None,
1055 limit: None,
1056 window: None,
1057 reason_code: None,
1058 namespace: None,
1059 group_by: None,
1060 };
1061
1062 assert!(quota.matches(&ItemScoping {
1063 category: DataCategory::Error,
1064 scoping: Scoping {
1065 organization_id: OrganizationId::new(42),
1066 project_id: ProjectId::new(21),
1067 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1068 key_id: Some(17),
1069 },
1070 namespace: MetricNamespaceScoping::None,
1071 dimensions: DimensionMap::default(),
1072 }));
1073
1074 assert!(!quota.matches(&ItemScoping {
1075 category: DataCategory::Transaction,
1076 scoping: Scoping {
1077 organization_id: OrganizationId::new(42),
1078 project_id: ProjectId::new(21),
1079 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1080 key_id: Some(17),
1081 },
1082 namespace: MetricNamespaceScoping::None,
1083 dimensions: DimensionMap::default(),
1084 }));
1085 }
1086
1087 #[test]
1088 fn test_quota_matches_no_invalid_scope() {
1089 let quota = Quota {
1090 id: None,
1091 categories: Default::default(),
1092 scope: QuotaScope::Organization,
1093 scope_id: Some("not_a_number".into()),
1094 limit: None,
1095 window: None,
1096 reason_code: None,
1097 namespace: None,
1098 group_by: None,
1099 };
1100
1101 assert!(!quota.matches(&ItemScoping {
1102 category: DataCategory::Error,
1103 scoping: Scoping {
1104 organization_id: OrganizationId::new(42),
1105 project_id: ProjectId::new(21),
1106 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1107 key_id: Some(17),
1108 },
1109 namespace: MetricNamespaceScoping::None,
1110 dimensions: DimensionMap::default(),
1111 }));
1112 }
1113
1114 #[test]
1115 fn test_quota_matches_organization_scope() {
1116 let quota = Quota {
1117 id: None,
1118 categories: Default::default(),
1119 scope: QuotaScope::Organization,
1120 scope_id: Some("42".into()),
1121 limit: None,
1122 window: None,
1123 reason_code: None,
1124 namespace: None,
1125 group_by: None,
1126 };
1127
1128 assert!(quota.matches(&ItemScoping {
1129 category: DataCategory::Error,
1130 scoping: Scoping {
1131 organization_id: OrganizationId::new(42),
1132 project_id: ProjectId::new(21),
1133 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1134 key_id: Some(17),
1135 },
1136 namespace: MetricNamespaceScoping::None,
1137 dimensions: DimensionMap::default(),
1138 }));
1139
1140 assert!(!quota.matches(&ItemScoping {
1141 category: DataCategory::Error,
1142 scoping: Scoping {
1143 organization_id: OrganizationId::new(0),
1144 project_id: ProjectId::new(21),
1145 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1146 key_id: Some(17),
1147 },
1148 namespace: MetricNamespaceScoping::None,
1149 dimensions: DimensionMap::default(),
1150 }));
1151 }
1152
1153 #[test]
1154 fn test_quota_matches_project_scope() {
1155 let quota = Quota {
1156 id: None,
1157 categories: Default::default(),
1158 scope: QuotaScope::Project,
1159 scope_id: Some("21".into()),
1160 limit: None,
1161 window: None,
1162 reason_code: None,
1163 namespace: None,
1164 group_by: None,
1165 };
1166
1167 assert!(quota.matches(&ItemScoping {
1168 category: DataCategory::Error,
1169 scoping: Scoping {
1170 organization_id: OrganizationId::new(42),
1171 project_id: ProjectId::new(21),
1172 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1173 key_id: Some(17),
1174 },
1175 namespace: MetricNamespaceScoping::None,
1176 dimensions: DimensionMap::default(),
1177 }));
1178
1179 assert!(!quota.matches(&ItemScoping {
1180 category: DataCategory::Error,
1181 scoping: Scoping {
1182 organization_id: OrganizationId::new(42),
1183 project_id: ProjectId::new(0),
1184 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1185 key_id: Some(17),
1186 },
1187 namespace: MetricNamespaceScoping::None,
1188 dimensions: DimensionMap::default(),
1189 }));
1190 }
1191
1192 #[test]
1193 fn test_quota_matches_key_scope() {
1194 let quota = Quota {
1195 id: None,
1196 categories: Default::default(),
1197 scope: QuotaScope::Key,
1198 scope_id: Some("17".into()),
1199 limit: None,
1200 window: None,
1201 reason_code: None,
1202 namespace: None,
1203 group_by: None,
1204 };
1205
1206 assert!(quota.matches(&ItemScoping {
1207 category: DataCategory::Error,
1208 scoping: Scoping {
1209 organization_id: OrganizationId::new(42),
1210 project_id: ProjectId::new(21),
1211 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1212 key_id: Some(17),
1213 },
1214 namespace: MetricNamespaceScoping::None,
1215 dimensions: DimensionMap::default(),
1216 }));
1217
1218 assert!(!quota.matches(&ItemScoping {
1219 category: DataCategory::Error,
1220 scoping: Scoping {
1221 organization_id: OrganizationId::new(42),
1222 project_id: ProjectId::new(21),
1223 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1224 key_id: Some(0),
1225 },
1226 namespace: MetricNamespaceScoping::None,
1227 dimensions: DimensionMap::default(),
1228 }));
1229
1230 assert!(!quota.matches(&ItemScoping {
1231 category: DataCategory::Error,
1232 scoping: Scoping {
1233 organization_id: OrganizationId::new(42),
1234 project_id: ProjectId::new(21),
1235 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1236 key_id: None,
1237 },
1238 namespace: MetricNamespaceScoping::None,
1239 dimensions: DimensionMap::default(),
1240 }));
1241 }
1242
1243 fn dimensioned_quota(dimensions: Option<GroupBy>) -> Quota {
1245 Quota {
1246 id: Some("q".into()),
1247 categories: [DataCategory::Monitor].into(),
1248 scope: QuotaScope::Project,
1249 scope_id: None,
1250 limit: Some(10),
1251 window: Some(60),
1252 reason_code: None,
1253 namespace: None,
1254 group_by: dimensions,
1255 }
1256 }
1257
1258 fn dimensioned_scoping(dimensions: Option<&[(Dimension, &str)]>) -> ItemScoping {
1260 let scoping = Scoping {
1261 organization_id: OrganizationId::new(42),
1262 project_id: ProjectId::new(21),
1263 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1264 key_id: Some(17),
1265 };
1266
1267 let owned_dims: Option<Vec<_>> =
1268 dimensions.map(|dims| dims.iter().map(|(d, v)| (*d, (*v).to_owned())).collect());
1269
1270 match owned_dims {
1271 None => scoping.item(DataCategory::Monitor),
1272 Some(mut dims) => scoping.item_with_dimensions(
1273 DataCategory::Monitor,
1274 DimensionMap(BTreeMap::from_iter(dims.drain(..)).into()),
1275 ),
1276 }
1277 }
1278
1279 #[test]
1280 fn test_quota_valid_dimensions() {
1281 let quota = dimensioned_quota(
1282 GroupBy {
1283 max_cardinality: 100,
1284 dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::CheckInEnvironment])
1285 .into(),
1286 }
1287 .into(),
1288 );
1289
1290 assert!(quota.is_valid());
1291 }
1292
1293 #[test]
1294 fn test_quota_invalid_unknown_dimension() {
1295 let quota = dimensioned_quota(
1296 GroupBy {
1297 max_cardinality: 999,
1298 dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::Unknown]).into(),
1299 }
1300 .into(),
1301 );
1302
1303 assert!(!quota.is_valid());
1304 }
1305
1306 #[test]
1307 fn test_quota_empty_dimension() {
1308 let quota = dimensioned_quota(
1309 GroupBy {
1310 max_cardinality: 999,
1311 dimensions: BTreeSet::default().into(),
1312 }
1313 .into(),
1314 );
1315
1316 assert!(!quota.is_valid());
1317 }
1318
1319 #[test]
1320 fn test_quota_matches_dimensions() {
1321 let quota = dimensioned_quota(
1322 GroupBy {
1323 max_cardinality: 999,
1324 dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1325 .into(),
1326 }
1327 .into(),
1328 );
1329
1330 assert!(quota.matches(&dimensioned_scoping(Some(&[
1332 (Dimension::CheckInEnvironment, "prod"),
1333 (Dimension::CheckInSlug, "cron1"),
1334 ]))));
1335 assert!(quota.matches(&dimensioned_scoping(Some(&[
1336 (Dimension::CheckInSlug, "cron1"),
1337 (Dimension::CheckInEnvironment, "prod"),
1338 ]))));
1339
1340 assert!(!quota.matches(&dimensioned_scoping(Some(&[(
1342 Dimension::CheckInSlug,
1343 "cron1"
1344 )]))));
1345
1346 assert!(!quota.matches(&dimensioned_scoping(Some(&[]))));
1348 assert!(!quota.matches(&dimensioned_scoping(None)));
1349 }
1350
1351 #[test]
1352 fn test_quota_without_dimensions_matches_any_item() {
1353 let quota = dimensioned_quota(None);
1354
1355 assert!(quota.matches(&dimensioned_scoping(None)));
1357 assert!(quota.matches(&dimensioned_scoping(Some(&[
1358 (Dimension::CheckInEnvironment, "prod"),
1359 (Dimension::CheckInSlug, "cron1"),
1360 ]))));
1361 }
1362
1363 #[test]
1364 fn test_dimensions_as_string() {
1365 let quota = dimensioned_quota(
1366 GroupBy {
1367 max_cardinality: 999,
1368 dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1369 .into(),
1370 }
1371 .into(),
1372 );
1373
1374 let key = dimensioned_scoping(Some(&[
1375 (Dimension::CheckInEnvironment, "prod"),
1376 (Dimension::CheckInSlug, "cron1"),
1377 ]))
1378 .dimensions
1379 .to_string("a)
1380 .unwrap();
1381
1382 let parts = key.split(':').collect::<Vec<_>>();
1384 assert_eq!(parts.len(), 5);
1385 assert_eq!(parts[0], "");
1386 assert_eq!(parts[1], Dimension::CheckInEnvironment.to_string());
1387 assert_eq!(parts[3], Dimension::CheckInSlug.to_string());
1388 assert!(parts[2].parse::<u64>().is_ok());
1389 assert!(parts[4].parse::<u64>().is_ok());
1390 }
1391
1392 #[test]
1393 fn test_dimensions_as_string_ignores_extra_dimensions() {
1394 let quota = dimensioned_quota(
1395 GroupBy {
1396 max_cardinality: 999,
1397 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1398 }
1399 .into(),
1400 );
1401
1402 let expected = dimensioned_scoping(Some(&[(Dimension::CheckInSlug, "cron1")]))
1403 .dimensions
1404 .to_string("a);
1405
1406 assert_eq!(
1407 dimensioned_scoping(Some(&[
1408 (Dimension::CheckInSlug, "cron1"),
1409 (Dimension::CheckInEnvironment, "prod"),
1410 ]))
1411 .dimensions
1412 .to_string("a),
1413 expected
1414 );
1415 }
1416
1417 #[test]
1418 fn test_dimensions_as_string_without_dimensions() {
1419 let dimensions = GroupBy {
1420 max_cardinality: 999,
1421 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1422 };
1423
1424 let dimensioned_item =
1425 dimensioned_scoping(Some(&[(Dimension::CheckInEnvironment, "prod")]));
1426
1427 assert!(
1430 dimensioned_scoping(None)
1431 .dimensions
1432 .to_string(&dimensioned_quota(None))
1433 .is_none()
1434 );
1435 assert!(
1436 dimensioned_scoping(None)
1437 .dimensions
1438 .to_string(&dimensioned_quota(dimensions.clone().into()))
1439 .is_none(),
1440 );
1441 assert!(
1442 dimensioned_item
1443 .dimensions
1444 .to_string(&dimensioned_quota(None))
1445 .is_none()
1446 );
1447
1448 assert!(
1450 dimensioned_item
1451 .dimensions
1452 .to_string(&dimensioned_quota(dimensions.into()))
1453 .is_none()
1454 );
1455 }
1456
1457 #[test]
1458 fn test_parse_quota_dimensions() {
1459 let json = r#"{
1460 "id": "o",
1461 "categories": ["monitor"],
1462 "limit": 4711,
1463 "window": 42,
1464 "groupBy": {
1465 "maxCardinality": 100,
1466 "dimensions": ["checkInSlug", "checkInEnvironment"]
1467 }
1468 }"#;
1469
1470 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1471
1472 insta::assert_ron_snapshot!(quota, @r#"
1473 Quota(
1474 id: Some("o"),
1475 categories: [
1476 "monitor",
1477 ],
1478 scope: organization,
1479 limit: Some(4711),
1480 window: Some(42),
1481 namespace: None,
1482 groupBy: Some(GroupBy(
1483 maxCardinality: 100,
1484 dimensions: [
1485 checkInEnvironment,
1486 checkInSlug,
1487 ],
1488 )),
1489 )
1490 "#);
1491
1492 assert!(quota.is_valid());
1493 }
1494
1495 #[test]
1496 fn test_parse_quota_dimensions_unknown() {
1497 let json = r#"{
1498 "id": "o",
1499 "limit": 4711,
1500 "window": 42,
1501 "groupBy": {
1502 "maxCardinality": 999,
1503 "dimensions": ["checkInSlug", "somethingNew"]
1504 }
1505 }"#;
1506
1507 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1508
1509 insta::assert_ron_snapshot!(quota, @r#"
1512 Quota(
1513 id: Some("o"),
1514 categories: [],
1515 scope: organization,
1516 limit: Some(4711),
1517 window: Some(42),
1518 namespace: None,
1519 groupBy: Some(GroupBy(
1520 maxCardinality: 999,
1521 dimensions: [
1522 checkInSlug,
1523 unknown,
1524 ],
1525 )),
1526 )
1527 "#);
1528
1529 assert!(!quota.is_valid());
1530 }
1531
1532 #[test]
1533 fn test_data_categories_sorted_deduplicated() {
1534 let a = DataCategories::from([
1535 DataCategory::Transaction,
1536 DataCategory::Span,
1537 DataCategory::Transaction,
1538 ]);
1539 let b = DataCategories::from([
1540 DataCategory::Span,
1541 DataCategory::Transaction,
1542 DataCategory::Span,
1543 ]);
1544 let c = DataCategories::from([DataCategory::Span, DataCategory::Transaction]);
1545
1546 assert_eq!(a, b);
1547 assert_eq!(b, c);
1548 assert_eq!(a, c);
1549 }
1550
1551 #[test]
1552 fn test_data_categories_serde() {
1553 let s: DataCategories = serde_json::from_str(r#"["span", "transaction", "span"]"#).unwrap();
1554 insta::assert_json_snapshot!(s, @r#"
1555 [
1556 "transaction",
1557 "span"
1558 ]
1559 "#);
1560 }
1561
1562 #[test]
1563 fn test_data_categories_add() {
1564 let c = DataCategories::new();
1565 let c = c.add(DataCategory::Span).unwrap();
1566 assert!(c.add(DataCategory::Span).is_none());
1567 let c = c.add(DataCategory::Transaction).unwrap();
1568 assert_eq!(c, [DataCategory::Span, DataCategory::Transaction].into());
1569 }
1570
1571 #[test]
1572 fn test_reserve_last_bit_data_category() {
1573 let r: Result<DataCategory, UnknownDataCategory> = 63u32.try_into();
1575 assert!(r.is_err());
1576 }
1577
1578 #[test]
1579 fn test_bitmapping_identity() {
1580 assert_eq!(
1581 DataCategories::category_to_mask(DataCategory::Unknown),
1582 1u64 << 63
1583 );
1584 for i in 0..63u32 {
1585 let r: Result<DataCategory, UnknownDataCategory> = i.try_into();
1586 if let Ok(dc) = r {
1587 assert_eq!(DataCategories::bit_number_to_category(i as u8), dc);
1588 assert_eq!(DataCategories::category_to_mask(dc), 1 << i);
1589 } else {
1590 break;
1591 }
1592 }
1593 }
1594}