1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{self, Write};
3use std::hash::{Hash, Hasher};
4use std::ops::Deref;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use fnv::FnvHasher;
9use relay_base_schema::metrics::MetricNamespace;
10use relay_base_schema::organization::OrganizationId;
11use relay_base_schema::project::{ProjectId, ProjectKey};
12use serde::ser::SerializeSeq;
13use serde::{Deserialize, Serialize};
14use smallvec::SmallVec;
15
16#[doc(inline)]
17pub use relay_base_schema::data_category::{CategoryUnit, DataCategory};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub struct Scoping {
26 pub organization_id: OrganizationId,
28
29 pub project_id: ProjectId,
31
32 pub project_key: ProjectKey,
34
35 pub key_id: Option<u64>,
37}
38
39impl Scoping {
40 pub fn item(&self, category: DataCategory) -> ItemScoping {
46 ItemScoping {
47 category,
48 scoping: *self,
49 namespace: MetricNamespaceScoping::None,
50 dimensions: DimensionMap::default(),
51 }
52 }
53
54 pub fn item_with_dimensions(
56 &self,
57 category: DataCategory,
58 dimensions: DimensionMap,
59 ) -> ItemScoping {
60 ItemScoping {
61 category,
62 scoping: *self,
63 namespace: MetricNamespaceScoping::None,
64 dimensions,
65 }
66 }
67
68 pub fn metric_bucket(&self, namespace: MetricNamespace) -> ItemScoping {
74 ItemScoping {
75 category: DataCategory::MetricBucket,
76 scoping: *self,
77 namespace: MetricNamespaceScoping::Some(namespace),
78 dimensions: DimensionMap::default(),
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, PartialOrd)]
88pub enum MetricNamespaceScoping {
89 #[default]
93 None,
94
95 Some(MetricNamespace),
97
98 Any,
103}
104
105impl MetricNamespaceScoping {
106 pub fn matches(&self, namespace: MetricNamespace) -> bool {
112 match self {
113 Self::None => false,
114 Self::Some(ns) => *ns == namespace,
115 Self::Any => true,
116 }
117 }
118}
119
120impl From<MetricNamespace> for MetricNamespaceScoping {
121 fn from(namespace: MetricNamespace) -> Self {
122 Self::Some(namespace)
123 }
124}
125
126#[derive(Debug, Clone, Eq, PartialEq)]
131pub struct ItemScoping {
132 pub category: DataCategory,
134
135 pub scoping: Scoping,
137
138 pub namespace: MetricNamespaceScoping,
140
141 pub dimensions: DimensionMap,
143}
144
145#[derive(Default, Debug, Clone, Eq, PartialEq, PartialOrd, Ord)]
147#[cfg_attr(test, derive(serde::Serialize))]
148pub struct DimensionMap {
149 dimensions: Arc<BTreeMap<Dimension, DimensionMapValue>>,
150 hash: u64,
151}
152
153#[derive(Default, Debug, Eq, PartialEq, PartialOrd, Ord)]
155#[cfg_attr(test, derive(serde::Serialize))]
156pub struct DimensionMapValue {
157 pub value: String,
159
160 pub key_value_hash: u64,
162}
163
164impl DimensionMapValue {
165 fn new(value: String, key_value_hash: u64) -> Self {
166 Self {
167 value,
168 key_value_hash,
169 }
170 }
171}
172
173impl<const N: usize> From<[(Dimension, String); N]> for DimensionMap {
174 fn from(arr: [(Dimension, String); N]) -> Self {
175 let dimensions = BTreeMap::from(arr);
176 Self::new(dimensions)
177 }
178}
179
180impl<const N: usize> From<[(Dimension, &str); N]> for DimensionMap {
181 fn from(arr: [(Dimension, &str); N]) -> Self {
182 let owned = arr.map(|(k, v)| (k, v.to_owned()));
183 let dimensions = BTreeMap::from(owned);
184 Self::new(dimensions)
185 }
186}
187
188impl FromIterator<(Dimension, String)> for DimensionMap {
189 fn from_iter<T: IntoIterator<Item = (Dimension, String)>>(iter: T) -> DimensionMap {
190 let dimensions = BTreeMap::from_iter(iter);
191
192 Self::new(dimensions)
193 }
194}
195
196impl Deref for DimensionMap {
197 type Target = BTreeMap<Dimension, DimensionMapValue>;
198
199 fn deref(&self) -> &Self::Target {
200 self.dimensions.as_ref()
201 }
202}
203
204impl DimensionMap {
205 pub const EMPTY_MAP_HASH: u64 = 0;
207
208 pub fn new(dimensions: BTreeMap<Dimension, String>) -> DimensionMap {
210 let mut hash = Self::EMPTY_MAP_HASH;
211
212 let dimensions = dimensions
213 .into_iter()
214 .map(|(k, v)| {
215 let mut h = FnvHasher::with_key(0);
216 k.hash(&mut h);
217 v.hash(&mut h);
218
219 hash ^= h.finish();
228
229 (k, DimensionMapValue::new(v, h.finish()))
230 })
231 .collect::<BTreeMap<_, _>>()
232 .into();
233
234 Self { dimensions, hash }
235 }
236
237 pub fn dimension_hashes(&self) -> u64 {
239 self.hash
240 }
241
242 pub fn has_dimension(&self, dim: &Dimension) -> bool {
244 self.dimensions.contains_key(dim)
245 }
246
247 pub fn project(&self, set: &BTreeSet<Dimension>) -> Option<u64> {
251 let mut hash = Self::EMPTY_MAP_HASH;
252
253 for dim in set {
254 let val = self.dimensions.get(dim)?;
255 hash ^= val.key_value_hash;
258 }
259
260 Some(hash)
261 }
262
263 pub fn to_string(&self, quota: &Quota) -> Option<String> {
268 let mut result = String::new();
269
270 let Some(group_by) = "a.group_by else {
271 return None;
272 };
273
274 for dim in group_by.dimensions.iter() {
275 if let Some((key, val)) = self.dimensions.get_key_value(dim) {
276 let mut hasher = fnv::FnvHasher::with_key(1);
277 val.value.hash(&mut hasher);
278 let h = &hasher.finish();
279
280 write!(&mut result, ":{key}:{h}").expect("should be infallible");
281 }
282 }
283
284 if result.is_empty() {
285 return None;
286 }
287
288 result.into()
289 }
290}
291
292impl std::ops::Deref for ItemScoping {
293 type Target = Scoping;
294
295 fn deref(&self) -> &Self::Target {
296 &self.scoping
297 }
298}
299
300impl ItemScoping {
301 pub fn scope_id(&self, scope: QuotaScope) -> Option<u64> {
306 match scope {
307 QuotaScope::Organization => Some(self.organization_id.value()),
308 QuotaScope::Project => Some(self.project_id.value()),
309 QuotaScope::Key => self.key_id,
310 QuotaScope::Unknown => None,
311 }
312 }
313
314 pub(crate) fn matches_categories(&self, categories: DataCategories) -> bool {
316 categories.is_empty() || categories.contains(&self.category)
321 }
322
323 pub(crate) fn matches_dimensions(&self, group_by: &Option<GroupBy>) -> bool {
325 let Some(gb) = group_by else { return true };
326
327 gb.dimensions
328 .iter()
329 .all(|dim| self.dimensions.has_dimension(dim))
330 }
331
332 pub(crate) fn satisfies_quota_dimensions(&self, quota_dimensions: &DimensionMap) -> bool {
335 quota_dimensions
336 .iter()
337 .all(|(dim, value)| self.dimensions.get(dim) == Some(value))
338 }
339
340 pub(crate) fn matches_namespaces<'a, I>(&self, namespaces: I) -> bool
354 where
355 I: IntoIterator<Item = &'a MetricNamespace>,
356 {
357 let mut iter = namespaces.into_iter().peekable();
358 iter.peek().is_none() || iter.any(|ns| self.namespace.matches(*ns))
359 }
360}
361
362#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
366pub struct DataCategories(u64);
367
368impl Serialize for DataCategories {
369 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
370 where
371 S: serde::Serializer,
372 {
373 let mut ser = serializer.serialize_seq(Some(self.len()))?;
374 for category in self.iter() {
375 ser.serialize_element(&category)?;
376 }
377 ser.end()
378 }
379}
380
381impl DataCategories {
382 fn category_to_mask(category: DataCategory) -> u64 {
383 if matches!(category, DataCategory::Unknown) {
384 1u64 << 63
386 } else {
387 1u64 << (category as u8)
388 }
389 }
390
391 fn bit_number_to_category(bit_number: u8) -> DataCategory {
392 bit_number.try_into().unwrap_or(DataCategory::Unknown)
393 }
394
395 pub fn new() -> Self {
397 Default::default()
398 }
399
400 pub fn from_slice(slice: &[DataCategory]) -> Self {
402 let mut categories = 0;
403 for category in slice {
404 categories |= DataCategories::category_to_mask(*category);
405 }
406
407 Self(categories)
408 }
409
410 pub fn add(&self, category: DataCategory) -> Option<Self> {
415 let category_mask = DataCategories::category_to_mask(category);
416
417 if (self.0 & category_mask) != 0 {
418 return None;
419 }
420
421 Some(Self(self.0 | category_mask))
422 }
423
424 pub fn contains(&self, category: &DataCategory) -> bool {
426 let category_mask = DataCategories::category_to_mask(*category);
427 (self.0 & category_mask) != 0
428 }
429
430 pub fn iter(&self) -> DataCategoryIterator {
432 let bit_start = self.0.trailing_zeros();
435
436 let (categories, _) = self.0.overflowing_shr(bit_start);
438 DataCategoryIterator {
439 categories,
440 current_bit: bit_start as u8,
441 }
442 }
443
444 pub fn len(&self) -> usize {
446 self.0.count_ones() as usize
447 }
448
449 pub fn is_empty(&self) -> bool {
451 self.0 == 0
452 }
453}
454
455pub struct DataCategoryIterator {
457 categories: u64,
458 current_bit: u8,
459}
460
461impl Iterator for DataCategoryIterator {
462 type Item = DataCategory;
463
464 fn next(&mut self) -> Option<Self::Item> {
465 if self.categories == 0 {
466 return None;
467 }
468 let category = DataCategories::bit_number_to_category(self.current_bit);
469
470 self.categories &= u64::MAX - 1;
472
473 let trailing_zeroes = self.categories.trailing_zeros() as u8;
475
476 (self.categories, _) = self.categories.overflowing_shr(trailing_zeroes as u32);
478
479 self.current_bit += trailing_zeroes;
481
482 Some(category)
483 }
484}
485
486impl<'de> Deserialize<'de> for DataCategories {
487 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
488 where
489 D: serde::Deserializer<'de>,
490 {
491 SmallVec::<[DataCategory; 12]>::deserialize(deserializer).map(Self::from)
492 }
493}
494
495impl<const N: usize> From<SmallVec<[DataCategory; N]>> for DataCategories {
496 fn from(categories: SmallVec<[DataCategory; N]>) -> Self {
497 Self::from_slice(&categories)
498 }
499}
500
501impl<const N: usize> From<[DataCategory; N]> for DataCategories {
502 fn from(categories: [DataCategory; N]) -> Self {
503 Self::from_slice(&categories)
504 }
505}
506
507impl FromIterator<DataCategory> for DataCategories {
508 fn from_iter<T: IntoIterator<Item = DataCategory>>(iter: T) -> Self {
509 let v: SmallVec<[DataCategory; 12]> = iter.into_iter().collect();
510 Self::from_slice(&v)
511 }
512}
513
514#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
523#[serde(rename_all = "lowercase")]
524pub enum QuotaScope {
525 Organization,
529
530 Project,
534
535 Key,
539
540 #[serde(other)]
542 Unknown,
543}
544
545impl QuotaScope {
546 pub fn from_name(string: &str) -> Self {
550 match string {
551 "organization" => Self::Organization,
552 "project" => Self::Project,
553 "key" => Self::Key,
554 _ => Self::Unknown,
555 }
556 }
557
558 pub fn name(self) -> &'static str {
562 match self {
563 Self::Key => "key",
564 Self::Project => "project",
565 Self::Organization => "organization",
566 Self::Unknown => "unknown",
567 }
568 }
569}
570
571impl fmt::Display for QuotaScope {
572 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573 write!(f, "{}", self.name())
574 }
575}
576
577impl FromStr for QuotaScope {
578 type Err = ();
579
580 fn from_str(string: &str) -> Result<Self, Self::Err> {
581 Ok(Self::from_name(string))
582 }
583}
584
585fn default_scope() -> QuotaScope {
586 QuotaScope::Organization
587}
588
589#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
594pub struct ReasonCode(Arc<str>);
595
596impl ReasonCode {
597 pub fn new<S: Into<Arc<str>>>(code: S) -> Self {
603 Self(code.into())
604 }
605
606 pub fn as_str(&self) -> &str {
608 &self.0
609 }
610}
611
612impl fmt::Display for ReasonCode {
613 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614 self.0.fmt(f)
615 }
616}
617
618#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
631#[serde(rename_all = "camelCase")]
632pub struct Quota {
633 #[serde(default)]
637 pub id: Option<Arc<str>>,
638
639 #[serde(default)]
643 pub categories: DataCategories,
644
645 #[serde(default = "default_scope")]
650 pub scope: QuotaScope,
651
652 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub scope_id: Option<Arc<str>>,
658
659 #[serde(default)]
668 pub limit: Option<u64>,
669
670 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub window: Option<u64>,
676
677 pub namespace: Option<MetricNamespace>,
681
682 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub reason_code: Option<ReasonCode>,
688
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub group_by: Option<GroupBy>,
692}
693
694#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
697#[serde(rename_all = "camelCase")]
698pub struct GroupBy {
699 pub max_cardinality: u32,
701
702 pub dimensions: Arc<BTreeSet<Dimension>>,
704}
705
706#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord, Hash)]
708#[serde(rename_all = "camelCase")]
709pub enum Dimension {
710 CheckInEnvironment,
712
713 CheckInSlug,
715
716 #[serde(other)]
720 Unknown,
721}
722
723impl fmt::Display for Dimension {
724 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
725 match self {
726 Dimension::CheckInEnvironment => f.write_str("check_in.environment"),
727 Dimension::CheckInSlug => f.write_str("check_in.slug"),
728 Dimension::Unknown => f.write_str("unknown"),
729 }
730 }
731}
732
733impl Quota {
734 pub fn is_valid(&self) -> bool {
742 if self.namespace == Some(MetricNamespace::Unsupported) {
743 return false;
744 }
745
746 if let Some(group_by) = &self.group_by {
747 if group_by.dimensions.is_empty() {
748 return false;
749 }
750
751 for dim in group_by.dimensions.iter() {
752 if *dim == Dimension::Unknown {
753 return false;
754 }
755 }
756 }
757
758 let mut units = self
759 .categories
760 .iter()
761 .filter_map(CategoryUnit::from_category);
762
763 match units.next() {
764 None if !self.categories.is_empty() => false,
766 _ if self.limit == Some(0) => true,
768 None => false,
770 Some(unit) => units.all(|u| u == unit),
772 }
773 }
774
775 fn matches_scope(&self, scoping: &ItemScoping) -> bool {
782 let Some(scope_id) = self.scope_id.as_ref() else {
786 return true;
787 };
788
789 let Ok(parsed) = scope_id.parse::<u64>() else {
792 return false;
793 };
794
795 scoping.scope_id(self.scope) == Some(parsed)
797 }
798
799 pub fn matches(&self, scoping: &ItemScoping) -> bool {
804 self.matches_scope(scoping)
805 && scoping.matches_categories(self.categories)
806 && scoping.matches_namespaces(&self.namespace)
807 && scoping.matches_dimensions(&self.group_by)
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use relay_base_schema::data_category::UnknownDataCategory;
814
815 use super::*;
816
817 #[test]
818 fn test_parse_quota_reject_all() {
819 let json = r#"{
820 "limit": 0,
821 "reasonCode": "not_yet"
822 }"#;
823
824 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
825
826 insta::assert_ron_snapshot!(quota, @r###"
827 Quota(
828 id: None,
829 categories: [],
830 scope: organization,
831 limit: Some(0),
832 namespace: None,
833 reasonCode: Some(ReasonCode("not_yet")),
834 )
835 "###);
836 }
837
838 #[test]
839 fn test_parse_quota_reject_transactions() {
840 let json = r#"{
841 "limit": 0,
842 "categories": ["transaction"],
843 "reasonCode": "not_yet"
844 }"#;
845
846 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
847
848 insta::assert_ron_snapshot!(quota, @r#"
849 Quota(
850 id: None,
851 categories: [
852 "transaction",
853 ],
854 scope: organization,
855 limit: Some(0),
856 namespace: None,
857 reasonCode: Some(ReasonCode("not_yet")),
858 )
859 "#);
860 }
861
862 #[test]
863 fn test_parse_quota_limited() {
864 let json = r#"{
865 "id": "o",
866 "limit": 4711,
867 "window": 42,
868 "reasonCode": "not_so_fast"
869 }"#;
870
871 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
872
873 insta::assert_ron_snapshot!(quota, @r###"
874 Quota(
875 id: Some("o"),
876 categories: [],
877 scope: organization,
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_project() {
888 let json = r#"{
889 "id": "p",
890 "scope": "project",
891 "scopeId": "1",
892 "limit": 4711,
893 "window": 42,
894 "reasonCode": "not_so_fast"
895 }"#;
896
897 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
898
899 insta::assert_ron_snapshot!(quota, @r###"
900 Quota(
901 id: Some("p"),
902 categories: [],
903 scope: project,
904 scopeId: Some("1"),
905 limit: Some(4711),
906 window: Some(42),
907 namespace: None,
908 reasonCode: Some(ReasonCode("not_so_fast")),
909 )
910 "###);
911 }
912
913 #[test]
914 fn test_parse_quota_project_large() {
915 let json = r#"{
916 "id": "p",
917 "scope": "project",
918 "scopeId": "1",
919 "limit": 4294967296,
920 "window": 42,
921 "reasonCode": "not_so_fast"
922 }"#;
923
924 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
925
926 insta::assert_ron_snapshot!(quota, @r###"
927 Quota(
928 id: Some("p"),
929 categories: [],
930 scope: project,
931 scopeId: Some("1"),
932 limit: Some(4294967296),
933 window: Some(42),
934 namespace: None,
935 reasonCode: Some(ReasonCode("not_so_fast")),
936 )
937 "###);
938 }
939
940 #[test]
941 fn test_parse_quota_key() {
942 let json = r#"{
943 "id": "k",
944 "scope": "key",
945 "scopeId": "1",
946 "limit": 4711,
947 "window": 42,
948 "reasonCode": "not_so_fast"
949 }"#;
950
951 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
952
953 insta::assert_ron_snapshot!(quota, @r###"
954 Quota(
955 id: Some("k"),
956 categories: [],
957 scope: key,
958 scopeId: Some("1"),
959 limit: Some(4711),
960 window: Some(42),
961 namespace: None,
962 reasonCode: Some(ReasonCode("not_so_fast")),
963 )
964 "###);
965 }
966
967 #[test]
968 fn test_parse_quota_unknown_variants() {
969 let json = r#"{
970 "id": "f",
971 "categories": ["future"],
972 "scope": "future",
973 "scopeId": "1",
974 "limit": 4711,
975 "window": 42,
976 "reasonCode": "not_so_fast"
977 }"#;
978
979 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
980
981 insta::assert_ron_snapshot!(quota, @r#"
982 Quota(
983 id: Some("f"),
984 categories: [
985 "unknown",
986 ],
987 scope: unknown,
988 scopeId: Some("1"),
989 limit: Some(4711),
990 window: Some(42),
991 namespace: None,
992 reasonCode: Some(ReasonCode("not_so_fast")),
993 )
994 "#);
995 }
996
997 #[test]
998 fn test_parse_quota_unlimited() {
999 let json = r#"{
1000 "id": "o",
1001 "window": 42
1002 }"#;
1003
1004 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1005
1006 insta::assert_ron_snapshot!(quota, @r###"
1007 Quota(
1008 id: Some("o"),
1009 categories: [],
1010 scope: organization,
1011 limit: None,
1012 window: Some(42),
1013 namespace: None,
1014 )
1015 "###);
1016 }
1017
1018 #[test]
1019 fn test_quota_valid_reject_all() {
1020 let quota = Quota {
1021 id: None,
1022 categories: Default::default(),
1023 scope: QuotaScope::Organization,
1024 scope_id: None,
1025 limit: Some(0),
1026 window: None,
1027 reason_code: None,
1028 namespace: None,
1029 group_by: None,
1030 };
1031
1032 assert!(quota.is_valid());
1033 }
1034
1035 #[test]
1036 fn test_quota_invalid_only_unknown() {
1037 let quota = Quota {
1038 id: None,
1039 categories: [DataCategory::Unknown, DataCategory::Unknown].into(),
1040 scope: QuotaScope::Organization,
1041 scope_id: None,
1042 limit: Some(0),
1043 window: None,
1044 reason_code: None,
1045 namespace: None,
1046 group_by: None,
1047 };
1048
1049 assert!(!quota.is_valid());
1050 }
1051
1052 #[test]
1053 fn test_quota_valid_reject_all_mixed() {
1054 let quota = Quota {
1055 id: None,
1056 categories: [DataCategory::Error, DataCategory::Attachment].into(),
1057 scope: QuotaScope::Organization,
1058 scope_id: None,
1059 limit: Some(0),
1060 window: None,
1061 reason_code: None,
1062 namespace: None,
1063 group_by: None,
1064 };
1065
1066 assert!(quota.is_valid());
1067 }
1068
1069 #[test]
1070 fn test_quota_invalid_limited_mixed() {
1071 let quota = Quota {
1072 id: None,
1073 categories: [DataCategory::Error, DataCategory::Attachment].into(),
1074 scope: QuotaScope::Organization,
1075 scope_id: None,
1076 limit: Some(1000),
1077 window: None,
1078 reason_code: None,
1079 namespace: None,
1080 group_by: None,
1081 };
1082
1083 assert!(!quota.is_valid());
1085 }
1086
1087 #[test]
1088 fn test_quota_invalid_unlimited_mixed() {
1089 let quota = Quota {
1090 id: None,
1091 categories: [DataCategory::Error, DataCategory::Attachment].into(),
1092 scope: QuotaScope::Organization,
1093 scope_id: None,
1094 limit: None,
1095 window: None,
1096 reason_code: None,
1097 namespace: None,
1098 group_by: None,
1099 };
1100
1101 assert!(!quota.is_valid());
1103 }
1104
1105 #[test]
1106 fn test_quota_matches_no_categories() {
1107 let quota = Quota {
1108 id: None,
1109 categories: Default::default(),
1110 scope: QuotaScope::Organization,
1111 scope_id: None,
1112 limit: None,
1113 window: None,
1114 reason_code: None,
1115 namespace: None,
1116 group_by: None,
1117 };
1118
1119 assert!(quota.matches(&ItemScoping {
1120 category: DataCategory::Error,
1121 scoping: Scoping {
1122 organization_id: OrganizationId::new(42),
1123 project_id: ProjectId::new(21),
1124 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1125 key_id: Some(17),
1126 },
1127 namespace: MetricNamespaceScoping::None,
1128 dimensions: DimensionMap::default(),
1129 }));
1130 }
1131
1132 #[test]
1133 fn test_quota_matches_unknown_category() {
1134 let quota = Quota {
1135 id: None,
1136 categories: [DataCategory::Unknown].into(),
1137 scope: QuotaScope::Organization,
1138 scope_id: None,
1139 limit: None,
1140 window: None,
1141 reason_code: None,
1142 namespace: None,
1143 group_by: None,
1144 };
1145
1146 assert!(!quota.matches(&ItemScoping {
1147 category: DataCategory::Error,
1148 scoping: Scoping {
1149 organization_id: OrganizationId::new(42),
1150 project_id: ProjectId::new(21),
1151 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1152 key_id: Some(17),
1153 },
1154 namespace: MetricNamespaceScoping::None,
1155 dimensions: DimensionMap::default(),
1156 }));
1157 }
1158
1159 #[test]
1160 fn test_quota_matches_multiple_categores() {
1161 let quota = Quota {
1162 id: None,
1163 categories: [DataCategory::Unknown, DataCategory::Error].into(),
1164 scope: QuotaScope::Organization,
1165 scope_id: None,
1166 limit: None,
1167 window: None,
1168 reason_code: None,
1169 namespace: None,
1170 group_by: None,
1171 };
1172
1173 assert!(quota.matches(&ItemScoping {
1174 category: DataCategory::Error,
1175 scoping: Scoping {
1176 organization_id: OrganizationId::new(42),
1177 project_id: ProjectId::new(21),
1178 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1179 key_id: Some(17),
1180 },
1181 namespace: MetricNamespaceScoping::None,
1182 dimensions: DimensionMap::default(),
1183 }));
1184
1185 assert!(!quota.matches(&ItemScoping {
1186 category: DataCategory::Transaction,
1187 scoping: Scoping {
1188 organization_id: OrganizationId::new(42),
1189 project_id: ProjectId::new(21),
1190 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1191 key_id: Some(17),
1192 },
1193 namespace: MetricNamespaceScoping::None,
1194 dimensions: DimensionMap::default(),
1195 }));
1196 }
1197
1198 #[test]
1199 fn test_quota_matches_no_invalid_scope() {
1200 let quota = Quota {
1201 id: None,
1202 categories: Default::default(),
1203 scope: QuotaScope::Organization,
1204 scope_id: Some("not_a_number".into()),
1205 limit: None,
1206 window: None,
1207 reason_code: None,
1208 namespace: None,
1209 group_by: None,
1210 };
1211
1212 assert!(!quota.matches(&ItemScoping {
1213 category: DataCategory::Error,
1214 scoping: Scoping {
1215 organization_id: OrganizationId::new(42),
1216 project_id: ProjectId::new(21),
1217 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1218 key_id: Some(17),
1219 },
1220 namespace: MetricNamespaceScoping::None,
1221 dimensions: DimensionMap::default(),
1222 }));
1223 }
1224
1225 #[test]
1226 fn test_quota_matches_organization_scope() {
1227 let quota = Quota {
1228 id: None,
1229 categories: Default::default(),
1230 scope: QuotaScope::Organization,
1231 scope_id: Some("42".into()),
1232 limit: None,
1233 window: None,
1234 reason_code: None,
1235 namespace: None,
1236 group_by: None,
1237 };
1238
1239 assert!(quota.matches(&ItemScoping {
1240 category: DataCategory::Error,
1241 scoping: Scoping {
1242 organization_id: OrganizationId::new(42),
1243 project_id: ProjectId::new(21),
1244 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1245 key_id: Some(17),
1246 },
1247 namespace: MetricNamespaceScoping::None,
1248 dimensions: DimensionMap::default(),
1249 }));
1250
1251 assert!(!quota.matches(&ItemScoping {
1252 category: DataCategory::Error,
1253 scoping: Scoping {
1254 organization_id: OrganizationId::new(0),
1255 project_id: ProjectId::new(21),
1256 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1257 key_id: Some(17),
1258 },
1259 namespace: MetricNamespaceScoping::None,
1260 dimensions: DimensionMap::default(),
1261 }));
1262 }
1263
1264 #[test]
1265 fn test_quota_matches_project_scope() {
1266 let quota = Quota {
1267 id: None,
1268 categories: Default::default(),
1269 scope: QuotaScope::Project,
1270 scope_id: Some("21".into()),
1271 limit: None,
1272 window: None,
1273 reason_code: None,
1274 namespace: None,
1275 group_by: None,
1276 };
1277
1278 assert!(quota.matches(&ItemScoping {
1279 category: DataCategory::Error,
1280 scoping: Scoping {
1281 organization_id: OrganizationId::new(42),
1282 project_id: ProjectId::new(21),
1283 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1284 key_id: Some(17),
1285 },
1286 namespace: MetricNamespaceScoping::None,
1287 dimensions: DimensionMap::default(),
1288 }));
1289
1290 assert!(!quota.matches(&ItemScoping {
1291 category: DataCategory::Error,
1292 scoping: Scoping {
1293 organization_id: OrganizationId::new(42),
1294 project_id: ProjectId::new(0),
1295 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1296 key_id: Some(17),
1297 },
1298 namespace: MetricNamespaceScoping::None,
1299 dimensions: DimensionMap::default(),
1300 }));
1301 }
1302
1303 #[test]
1304 fn test_quota_matches_key_scope() {
1305 let quota = Quota {
1306 id: None,
1307 categories: Default::default(),
1308 scope: QuotaScope::Key,
1309 scope_id: Some("17".into()),
1310 limit: None,
1311 window: None,
1312 reason_code: None,
1313 namespace: None,
1314 group_by: None,
1315 };
1316
1317 assert!(quota.matches(&ItemScoping {
1318 category: DataCategory::Error,
1319 scoping: Scoping {
1320 organization_id: OrganizationId::new(42),
1321 project_id: ProjectId::new(21),
1322 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1323 key_id: Some(17),
1324 },
1325 namespace: MetricNamespaceScoping::None,
1326 dimensions: DimensionMap::default(),
1327 }));
1328
1329 assert!(!quota.matches(&ItemScoping {
1330 category: DataCategory::Error,
1331 scoping: Scoping {
1332 organization_id: OrganizationId::new(42),
1333 project_id: ProjectId::new(21),
1334 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1335 key_id: Some(0),
1336 },
1337 namespace: MetricNamespaceScoping::None,
1338 dimensions: DimensionMap::default(),
1339 }));
1340
1341 assert!(!quota.matches(&ItemScoping {
1342 category: DataCategory::Error,
1343 scoping: Scoping {
1344 organization_id: OrganizationId::new(42),
1345 project_id: ProjectId::new(21),
1346 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1347 key_id: None,
1348 },
1349 namespace: MetricNamespaceScoping::None,
1350 dimensions: DimensionMap::default(),
1351 }));
1352 }
1353
1354 fn dimensioned_quota(dimensions: Option<GroupBy>) -> Quota {
1356 Quota {
1357 id: Some("q".into()),
1358 categories: [DataCategory::Monitor].into(),
1359 scope: QuotaScope::Project,
1360 scope_id: None,
1361 limit: Some(10),
1362 window: Some(60),
1363 reason_code: None,
1364 namespace: None,
1365 group_by: dimensions,
1366 }
1367 }
1368
1369 fn dimensioned_scoping(dimensions: Option<&[(Dimension, &str)]>) -> ItemScoping {
1371 let scoping = Scoping {
1372 organization_id: OrganizationId::new(42),
1373 project_id: ProjectId::new(21),
1374 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1375 key_id: Some(17),
1376 };
1377
1378 let owned_dims: Option<Vec<_>> =
1379 dimensions.map(|dims| dims.iter().map(|(d, v)| (*d, (*v).to_owned())).collect());
1380
1381 match owned_dims {
1382 None => scoping.item(DataCategory::Monitor),
1383 Some(mut dims) => scoping.item_with_dimensions(
1384 DataCategory::Monitor,
1385 DimensionMap::new(BTreeMap::from_iter(dims.drain(..))),
1386 ),
1387 }
1388 }
1389
1390 #[test]
1391 fn test_quota_valid_dimensions() {
1392 let quota = dimensioned_quota(
1393 GroupBy {
1394 max_cardinality: 100,
1395 dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::CheckInEnvironment])
1396 .into(),
1397 }
1398 .into(),
1399 );
1400
1401 assert!(quota.is_valid());
1402 }
1403
1404 #[test]
1405 fn test_quota_invalid_unknown_dimension() {
1406 let quota = dimensioned_quota(
1407 GroupBy {
1408 max_cardinality: 999,
1409 dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::Unknown]).into(),
1410 }
1411 .into(),
1412 );
1413
1414 assert!(!quota.is_valid());
1415 }
1416
1417 #[test]
1418 fn test_quota_empty_dimension() {
1419 let quota = dimensioned_quota(
1420 GroupBy {
1421 max_cardinality: 999,
1422 dimensions: BTreeSet::default().into(),
1423 }
1424 .into(),
1425 );
1426
1427 assert!(!quota.is_valid());
1428 }
1429
1430 #[test]
1431 fn test_quota_matches_dimensions() {
1432 let quota = dimensioned_quota(
1433 GroupBy {
1434 max_cardinality: 999,
1435 dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1436 .into(),
1437 }
1438 .into(),
1439 );
1440
1441 assert!(quota.matches(&dimensioned_scoping(Some(&[
1443 (Dimension::CheckInEnvironment, "prod"),
1444 (Dimension::CheckInSlug, "cron1"),
1445 ]))));
1446 assert!(quota.matches(&dimensioned_scoping(Some(&[
1447 (Dimension::CheckInSlug, "cron1"),
1448 (Dimension::CheckInEnvironment, "prod"),
1449 ]))));
1450
1451 assert!(!quota.matches(&dimensioned_scoping(Some(&[(
1453 Dimension::CheckInSlug,
1454 "cron1"
1455 )]))));
1456
1457 assert!(!quota.matches(&dimensioned_scoping(Some(&[]))));
1459 assert!(!quota.matches(&dimensioned_scoping(None)));
1460 }
1461
1462 #[test]
1463 fn test_quota_without_dimensions_matches_any_item() {
1464 let quota = dimensioned_quota(None);
1465
1466 assert!(quota.matches(&dimensioned_scoping(None)));
1468 assert!(quota.matches(&dimensioned_scoping(Some(&[
1469 (Dimension::CheckInEnvironment, "prod"),
1470 (Dimension::CheckInSlug, "cron1"),
1471 ]))));
1472 }
1473
1474 #[test]
1475 fn test_dimensions_as_string() {
1476 let quota = dimensioned_quota(
1477 GroupBy {
1478 max_cardinality: 999,
1479 dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1480 .into(),
1481 }
1482 .into(),
1483 );
1484
1485 let key = dimensioned_scoping(Some(&[
1486 (Dimension::CheckInEnvironment, "prod"),
1487 (Dimension::CheckInSlug, "cron1"),
1488 ]))
1489 .dimensions
1490 .to_string("a)
1491 .unwrap();
1492
1493 let parts = key.split(':').collect::<Vec<_>>();
1495 assert_eq!(parts.len(), 5);
1496 assert_eq!(parts[0], "");
1497 assert_eq!(parts[1], Dimension::CheckInEnvironment.to_string());
1498 assert_eq!(parts[3], Dimension::CheckInSlug.to_string());
1499 assert!(parts[2].parse::<u64>().is_ok());
1500 assert!(parts[4].parse::<u64>().is_ok());
1501 }
1502
1503 #[test]
1504 fn test_dimensions_as_string_ignores_extra_dimensions() {
1505 let quota = dimensioned_quota(
1506 GroupBy {
1507 max_cardinality: 999,
1508 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1509 }
1510 .into(),
1511 );
1512
1513 let expected = dimensioned_scoping(Some(&[(Dimension::CheckInSlug, "cron1")]))
1514 .dimensions
1515 .to_string("a);
1516
1517 assert_eq!(
1518 dimensioned_scoping(Some(&[
1519 (Dimension::CheckInSlug, "cron1"),
1520 (Dimension::CheckInEnvironment, "prod"),
1521 ]))
1522 .dimensions
1523 .to_string("a),
1524 expected
1525 );
1526 }
1527
1528 #[test]
1529 fn test_dimensions_as_string_without_dimensions() {
1530 let dimensions = GroupBy {
1531 max_cardinality: 999,
1532 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1533 };
1534
1535 let dimensioned_item =
1536 dimensioned_scoping(Some(&[(Dimension::CheckInEnvironment, "prod")]));
1537
1538 assert!(
1541 dimensioned_scoping(None)
1542 .dimensions
1543 .to_string(&dimensioned_quota(None))
1544 .is_none()
1545 );
1546 assert!(
1547 dimensioned_scoping(None)
1548 .dimensions
1549 .to_string(&dimensioned_quota(dimensions.clone().into()))
1550 .is_none(),
1551 );
1552 assert!(
1553 dimensioned_item
1554 .dimensions
1555 .to_string(&dimensioned_quota(None))
1556 .is_none()
1557 );
1558
1559 assert!(
1561 dimensioned_item
1562 .dimensions
1563 .to_string(&dimensioned_quota(dimensions.into()))
1564 .is_none()
1565 );
1566 }
1567
1568 #[test]
1569 fn test_parse_quota_dimensions() {
1570 let json = r#"{
1571 "id": "o",
1572 "categories": ["monitor"],
1573 "limit": 4711,
1574 "window": 42,
1575 "groupBy": {
1576 "maxCardinality": 100,
1577 "dimensions": ["checkInSlug", "checkInEnvironment"]
1578 }
1579 }"#;
1580
1581 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1582
1583 insta::assert_ron_snapshot!(quota, @r#"
1584 Quota(
1585 id: Some("o"),
1586 categories: [
1587 "monitor",
1588 ],
1589 scope: organization,
1590 limit: Some(4711),
1591 window: Some(42),
1592 namespace: None,
1593 groupBy: Some(GroupBy(
1594 maxCardinality: 100,
1595 dimensions: [
1596 checkInEnvironment,
1597 checkInSlug,
1598 ],
1599 )),
1600 )
1601 "#);
1602
1603 assert!(quota.is_valid());
1604 }
1605
1606 #[test]
1607 fn test_parse_quota_dimensions_unknown() {
1608 let json = r#"{
1609 "id": "o",
1610 "limit": 4711,
1611 "window": 42,
1612 "groupBy": {
1613 "maxCardinality": 999,
1614 "dimensions": ["checkInSlug", "somethingNew"]
1615 }
1616 }"#;
1617
1618 let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1619
1620 insta::assert_ron_snapshot!(quota, @r#"
1623 Quota(
1624 id: Some("o"),
1625 categories: [],
1626 scope: organization,
1627 limit: Some(4711),
1628 window: Some(42),
1629 namespace: None,
1630 groupBy: Some(GroupBy(
1631 maxCardinality: 999,
1632 dimensions: [
1633 checkInSlug,
1634 unknown,
1635 ],
1636 )),
1637 )
1638 "#);
1639
1640 assert!(!quota.is_valid());
1641 }
1642
1643 #[test]
1644 fn test_data_categories_sorted_deduplicated() {
1645 let a = DataCategories::from([
1646 DataCategory::Transaction,
1647 DataCategory::Span,
1648 DataCategory::Transaction,
1649 ]);
1650 let b = DataCategories::from([
1651 DataCategory::Span,
1652 DataCategory::Transaction,
1653 DataCategory::Span,
1654 ]);
1655 let c = DataCategories::from([DataCategory::Span, DataCategory::Transaction]);
1656
1657 assert_eq!(a, b);
1658 assert_eq!(b, c);
1659 assert_eq!(a, c);
1660 }
1661
1662 #[test]
1663 fn test_data_categories_serde() {
1664 let s: DataCategories = serde_json::from_str(r#"["span", "transaction", "span"]"#).unwrap();
1665 insta::assert_json_snapshot!(s, @r#"
1666 [
1667 "transaction",
1668 "span"
1669 ]
1670 "#);
1671 }
1672
1673 #[test]
1674 fn test_data_categories_add() {
1675 let c = DataCategories::new();
1676 let c = c.add(DataCategory::Span).unwrap();
1677 assert!(c.add(DataCategory::Span).is_none());
1678 let c = c.add(DataCategory::Transaction).unwrap();
1679 assert_eq!(c, [DataCategory::Span, DataCategory::Transaction].into());
1680 }
1681
1682 #[test]
1683 fn test_reserve_last_bit_data_category() {
1684 let r: Result<DataCategory, UnknownDataCategory> = 63u32.try_into();
1686 assert!(r.is_err());
1687 }
1688
1689 #[test]
1690 fn test_bitmapping_identity() {
1691 assert_eq!(
1692 DataCategories::category_to_mask(DataCategory::Unknown),
1693 1u64 << 63
1694 );
1695 for i in 0..63u32 {
1696 let r: Result<DataCategory, UnknownDataCategory> = i.try_into();
1697 if let Ok(dc) = r {
1698 assert_eq!(DataCategories::bit_number_to_category(i as u8), dc);
1699 assert_eq!(DataCategories::category_to_mask(dc), 1 << i);
1700 } else {
1701 break;
1702 }
1703 }
1704 }
1705}