1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use chrono::{DateTime, Utc};
4use derive_builder::Builder;
5use serde::{Deserialize, Serialize};
6
7#[cfg(feature = "openapi")]
8use utoipa::ToSchema;
9
10use crate::{Merge, MergeMut};
11
12type MetricLabels = BTreeMap<String, String>;
13
14#[derive(Debug, Clone, Deserialize, Serialize, Default, Builder)]
15#[cfg_attr(feature = "openapi", derive(ToSchema))]
16pub struct ToggleStats {
17 #[builder(default = "0")]
18 pub no: u32,
19 #[builder(default = "0")]
20 pub yes: u32,
21 #[builder(default = "HashMap::new()")]
22 #[serde(default)]
23 pub variants: HashMap<String, u32>,
24}
25
26impl ToggleStats {
27 fn yes(&mut self) {
29 self.yes += 1
30 }
31 fn no(&mut self) {
33 self.no += 1
34 }
35
36 pub fn count(&mut self, enabled: bool) {
38 if enabled {
39 self.yes()
40 } else {
41 self.no()
42 }
43 }
44
45 pub fn count_variant(&mut self, name: &str) {
49 self.increment_variant_count(name);
50 self.count(true);
51 }
52
53 pub fn variant_disabled(&mut self) {
54 self.increment_variant_count("disabled");
55 self.count(false);
56 }
57
58 fn increment_variant_count(&mut self, name: &str) {
60 self.variants
61 .entry(name.into())
62 .and_modify(|count| *count += 1)
63 .or_insert(1);
64 }
65}
66
67#[derive(Debug, Clone, Deserialize, Serialize, Builder)]
68#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
69pub struct MetricBucket {
70 pub start: DateTime<Utc>,
71 pub stop: DateTime<Utc>,
72 pub toggles: HashMap<String, ToggleStats>,
73}
74
75pub fn from_bucket_app_name_and_env(
76 bucket: MetricBucket,
77 app_name: String,
78 environment: String,
79 metadata: MetricsMetadata,
80) -> Vec<ClientMetricsEnv> {
81 let timestamp = bucket.start;
82 bucket
83 .toggles
84 .into_iter()
85 .map(|(name, stats)| ClientMetricsEnv {
86 feature_name: name,
87 app_name: app_name.clone(),
88 environment: environment.clone(),
89 timestamp,
90 yes: stats.yes,
91 no: stats.no,
92 variants: stats.variants,
93 metadata: metadata.clone(),
94 })
95 .collect()
96}
97
98#[derive(Debug, Clone, Deserialize, Serialize, Builder)]
99#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
100#[serde(rename_all = "camelCase")]
101pub struct ClientMetrics {
102 pub app_name: String,
103 pub bucket: MetricBucket,
104 pub environment: Option<String>,
105 pub instance_id: Option<String>,
106 pub connection_id: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub impact_metrics: Option<Vec<ImpactMetric>>,
109 #[serde(flatten)]
110 pub metadata: MetricsMetadata,
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115#[serde(rename_all = "camelCase")]
116pub struct ClientMetricsEnv {
117 pub feature_name: String,
118 pub app_name: String,
119 pub environment: String,
120 pub timestamp: DateTime<Utc>,
121 pub yes: u32,
122 pub no: u32,
123 pub variants: HashMap<String, u32>,
124 #[serde(flatten)]
125 pub metadata: MetricsMetadata,
126}
127
128#[derive(Debug, Clone, Deserialize, Serialize, Builder, PartialEq, Eq)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130#[serde(rename_all = "camelCase")]
131pub struct ConnectVia {
132 pub app_name: String,
133 pub instance_id: String,
134}
135
136#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Builder)]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138#[serde(rename_all = "camelCase")]
139#[derive(Default)]
140pub struct ClientApplication {
141 pub app_name: String,
142 pub connect_via: Option<Vec<ConnectVia>>,
143 pub environment: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub projects: Option<Vec<String>>,
146 pub instance_id: Option<String>,
147 pub connection_id: Option<String>,
148 pub interval: u32,
149 pub started: DateTime<Utc>,
150 pub strategies: Vec<String>,
151 #[serde(flatten)]
152 pub metadata: MetricsMetadata,
153}
154
155#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
156#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
157#[serde(rename_all = "lowercase")]
158pub enum SdkType {
159 Frontend,
160 Backend,
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Builder)]
164#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
165#[serde(rename_all = "camelCase")]
166#[derive(Default)]
167pub struct MetricsMetadata {
168 pub sdk_version: Option<String>,
169 pub sdk_type: Option<SdkType>,
170 pub yggdrasil_version: Option<String>,
171 pub platform_name: Option<String>,
172 pub platform_version: Option<String>,
173}
174
175#[derive(Debug, Clone, Deserialize, Serialize)]
176#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
177pub struct Bucket {
178 #[serde(
179 deserialize_with = "deserialize_bucket_le",
180 serialize_with = "serialize_bucket_le"
181 )]
182 pub le: f64,
183 pub count: u64,
184}
185
186impl PartialEq for Bucket {
187 fn eq(&self, other: &Self) -> bool {
188 self.le == other.le && self.count == other.count
189 }
190}
191
192impl Eq for Bucket {}
193
194impl PartialOrd for Bucket {
195 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
196 Some(self.cmp(other))
197 }
198}
199
200impl Ord for Bucket {
201 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
202 self.le
203 .total_cmp(&other.le)
204 .then_with(|| self.count.cmp(&other.count))
205 }
206}
207
208fn deserialize_bucket_le<'de, D>(deserializer: D) -> Result<f64, D::Error>
209where
210 D: serde::Deserializer<'de>,
211{
212 use serde::de::Error;
213
214 #[derive(Deserialize)]
215 #[serde(untagged)]
216 enum BucketLe {
217 Number(f64),
218 String(String),
219 }
220
221 match BucketLe::deserialize(deserializer)? {
222 BucketLe::Number(n) if n.is_nan() => {
223 Err(D::Error::custom("NaN is not a valid bucket boundary"))
224 }
225 BucketLe::Number(n) if n.is_infinite() && n.is_sign_negative() => {
226 Err(D::Error::custom("-Inf is not a valid bucket boundary"))
227 }
228 BucketLe::Number(n) => Ok(n),
229 BucketLe::String(s) if s == "+Inf" => Ok(f64::INFINITY),
230 BucketLe::String(s) => Err(D::Error::custom(format!("expected '+Inf', got '{}'", s))),
231 }
232}
233
234fn serialize_bucket_le<S>(le: &f64, serializer: S) -> Result<S::Ok, S::Error>
235where
236 S: serde::Serializer,
237{
238 if le.is_infinite() {
239 serializer.serialize_str("+Inf")
240 } else {
241 serializer.serialize_f64(*le)
242 }
243}
244
245#[derive(Debug, Clone, Deserialize, Serialize)]
246#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
247#[serde(rename_all = "camelCase")]
248pub struct BucketMetricSample {
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub labels: Option<MetricLabels>,
251 pub count: u64,
252 pub sum: f64,
253 pub buckets: Vec<Bucket>,
254}
255
256impl PartialEq for BucketMetricSample {
257 fn eq(&self, other: &Self) -> bool {
258 self.labels == other.labels
259 && self.count == other.count
260 && (self.sum - other.sum).abs() < f64::EPSILON
261 && self.buckets == other.buckets
262 }
263}
264
265impl Eq for BucketMetricSample {}
266
267impl MergeMut for BucketMetricSample {
268 fn merge(&mut self, other: BucketMetricSample) {
269 self.count += other.count;
270 self.sum += other.sum;
271
272 for bucket in other.buckets {
273 if let Some(existing) = self.buckets.iter_mut().find(|b| b.le == bucket.le) {
274 existing.count += bucket.count;
275 } else {
276 self.buckets.push(bucket);
277 }
278 }
279 self.buckets.sort();
280 }
281}
282
283#[derive(Debug, Clone, Deserialize, Serialize, Builder)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285#[serde(rename_all = "camelCase")]
286pub struct NumericMetricSample {
287 pub value: f64,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub labels: Option<MetricLabels>,
290}
291
292impl PartialEq for NumericMetricSample {
293 fn eq(&self, other: &Self) -> bool {
294 let values_equal = (self.value - other.value).abs() < f64::EPSILON;
295
296 let labels_equal = self.labels == other.labels;
297
298 values_equal && labels_equal
299 }
300}
301
302impl Eq for NumericMetricSample {}
303
304#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
305#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
306#[serde(tag = "type", rename_all = "lowercase")]
307pub enum ImpactMetric {
308 Counter {
309 name: String,
310 help: String,
311 samples: Vec<NumericMetricSample>,
312 },
313 Gauge {
314 name: String,
315 help: String,
316 samples: Vec<NumericMetricSample>,
317 },
318 Histogram {
319 name: String,
320 help: String,
321 samples: Vec<BucketMetricSample>,
322 },
323}
324
325impl ImpactMetric {
326 pub fn name(&self) -> &str {
327 match self {
328 ImpactMetric::Counter { name, .. } => name,
329 ImpactMetric::Gauge { name, .. } => name,
330 ImpactMetric::Histogram { name, .. } => name,
331 }
332 }
333
334 pub fn help(&self) -> &str {
335 match self {
336 ImpactMetric::Counter { help, .. } => help,
337 ImpactMetric::Gauge { help, .. } => help,
338 ImpactMetric::Histogram { help, .. } => help,
339 }
340 }
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
344#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
345#[serde(rename_all = "camelCase")]
346pub struct ImpactMetricEnv {
347 #[serde(flatten)]
348 pub impact_metric: ImpactMetric,
349 #[serde(skip)]
350 pub app_name: String,
351 #[serde(skip)]
352 pub environment: String,
353}
354
355impl ImpactMetricEnv {
356 pub fn new(impact_metric: ImpactMetric, app_name: String, environment: String) -> Self {
357 Self {
358 impact_metric,
359 app_name,
360 environment,
361 }
362 }
363}
364
365impl MergeMut for ImpactMetric {
366 fn merge(&mut self, other: ImpactMetric) {
367 match (self, other) {
368 (
369 ImpactMetric::Counter {
370 samples: ref mut self_samples,
371 ..
372 },
373 ImpactMetric::Counter {
374 samples: other_samples,
375 ..
376 },
377 ) => {
378 merge_counter_samples(self_samples, other_samples);
379 }
380 (
381 ImpactMetric::Gauge {
382 samples: ref mut self_samples,
383 ..
384 },
385 ImpactMetric::Gauge {
386 samples: other_samples,
387 ..
388 },
389 ) => {
390 merge_gauge_samples(self_samples, other_samples);
391 }
392 (
393 ImpactMetric::Histogram {
394 samples: ref mut self_samples,
395 ..
396 },
397 ImpactMetric::Histogram {
398 samples: other_samples,
399 ..
400 },
401 ) => {
402 merge_histogram_samples(self_samples, other_samples);
403 }
404 _ => {
405 println!(
406 "Warning: Mismatched ImpactMetric types during merge - this shouldn't happen in practice"
407 );
408 }
409 }
410 }
411}
412
413impl MergeMut for ImpactMetricEnv {
414 fn merge(&mut self, other: ImpactMetricEnv) {
415 self.impact_metric.merge(other.impact_metric);
416 }
417}
418
419fn merge_and_deduplicate_samples<T, F>(
420 self_samples: &mut Vec<T>,
421 other_samples: Vec<T>,
422 get_labels: fn(&T) -> &Option<MetricLabels>, merge_duplicates: F,
424) where
425 F: Fn(&mut T, T),
426{
427 self_samples.extend(other_samples);
428 self_samples.sort_by(|a, b| get_labels(a).cmp(get_labels(b)));
429
430 let old_samples = std::mem::take(self_samples);
431 let mut deduped = Vec::with_capacity(old_samples.len());
432 let mut iter = old_samples.into_iter();
433
434 if let Some(mut prev) = iter.next() {
435 for sample in iter {
436 if get_labels(&prev) == get_labels(&sample) {
437 merge_duplicates(&mut prev, sample);
438 } else {
439 deduped.push(prev);
440 prev = sample;
441 }
442 }
443 deduped.push(prev);
444 }
445
446 *self_samples = deduped;
447}
448
449fn merge_counter_samples(
450 self_samples: &mut Vec<NumericMetricSample>,
451 other_samples: Vec<NumericMetricSample>,
452) {
453 merge_and_deduplicate_samples(
454 self_samples,
455 other_samples,
456 |s| &s.labels,
457 |prev, sample| {
458 prev.value += sample.value;
459 },
460 );
461}
462
463fn merge_gauge_samples(
464 self_samples: &mut Vec<NumericMetricSample>,
465 other_samples: Vec<NumericMetricSample>,
466) {
467 merge_and_deduplicate_samples(
468 self_samples,
469 other_samples,
470 |s| &s.labels,
471 |prev, sample| {
472 prev.value = sample.value;
474 },
475 );
476}
477
478fn merge_histogram_samples(
479 self_samples: &mut Vec<BucketMetricSample>,
480 other_samples: Vec<BucketMetricSample>,
481) {
482 merge_and_deduplicate_samples(
483 self_samples,
484 other_samples,
485 |s| &s.labels,
486 |prev, sample| {
487 prev.merge(sample);
488 },
489 );
490}
491
492impl ClientApplication {
493 #[cfg(feature = "wall-clock")]
494 pub fn new(app_name: &str, interval: u32) -> Self {
495 Self {
496 app_name: app_name.into(),
497 connect_via: Some(vec![]),
498 environment: None,
499 projects: Some(vec![]),
500 instance_id: None,
501 connection_id: None,
502 interval,
503 started: Utc::now(),
504 strategies: vec![],
505 metadata: MetricsMetadata {
506 sdk_version: None,
507 sdk_type: None,
508 yggdrasil_version: None,
509 platform_name: None,
510 platform_version: None,
511 },
512 }
513 }
514
515 pub fn add_strategies(&mut self, strategies: Vec<String>) {
516 let unique_strats: Vec<String> = self
517 .strategies
518 .clone()
519 .into_iter()
520 .chain(strategies)
521 .collect::<HashSet<String>>()
522 .into_iter()
523 .collect();
524 self.strategies = unique_strats;
525 }
526
527 pub fn connect_via(&self, app_name: &str, instance_id: &str) -> ClientApplication {
528 let mut connect_via = self.connect_via.clone().unwrap_or_default();
529 connect_via.push(ConnectVia {
530 app_name: app_name.into(),
531 instance_id: instance_id.into(),
532 });
533 Self {
534 connect_via: Some(connect_via),
535 ..self.clone()
536 }
537 }
538}
539
540impl Merge for ClientApplication {
541 fn merge(self, other: ClientApplication) -> ClientApplication {
544 let mut merged_strategies: Vec<String> = self
545 .strategies
546 .into_iter()
547 .chain(other.strategies)
548 .collect::<HashSet<String>>()
549 .into_iter()
550 .collect();
551 merged_strategies.sort();
552 let merged_connected_via: Option<Vec<ConnectVia>> = self
553 .connect_via
554 .map(|c| {
555 let initial = c.into_iter();
556 let other_iter = other.connect_via.clone().unwrap_or_default().into_iter();
557 let connect_via: Vec<ConnectVia> = initial.chain(other_iter).collect();
558 connect_via
559 })
560 .or(other.connect_via.clone());
561
562 let merged_projects: Option<Vec<String>> = match (self.projects, other.projects) {
563 (Some(self_projects), Some(other_projects)) => {
564 let mut projects: Vec<String> = self_projects
565 .into_iter()
566 .chain(other_projects)
567 .collect::<HashSet<String>>()
568 .into_iter()
569 .collect();
570 projects.sort();
571 Some(projects)
572 }
573 (Some(projects), None) => Some(projects),
574 (None, Some(projects)) => Some(projects),
575 (None, None) => None,
576 };
577
578 ClientApplication {
579 app_name: self.app_name,
580 environment: self.environment.or(other.environment),
581 projects: merged_projects,
582 instance_id: self.instance_id.or(other.instance_id),
583 connection_id: self.connection_id.or(other.connection_id),
584 interval: self.interval,
585 started: self.started,
586 strategies: merged_strategies,
587 connect_via: merged_connected_via,
588 metadata: MetricsMetadata {
589 sdk_version: self.metadata.sdk_version.or(other.metadata.sdk_version),
590 sdk_type: self.metadata.sdk_type.or(other.metadata.sdk_type),
591 yggdrasil_version: self
592 .metadata
593 .yggdrasil_version
594 .or(other.metadata.yggdrasil_version),
595 platform_name: self.metadata.platform_name.or(other.metadata.platform_name),
596 platform_version: self
597 .metadata
598 .platform_version
599 .or(other.metadata.platform_version),
600 },
601 }
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use chrono::Utc;
608
609 use super::*;
610
611 #[test]
612 fn client_metrics_with_impact_metrics_serialization() {
613 let impact_metrics = vec![ImpactMetric::Counter {
614 name: "labeled_counter".into(),
615 help: "with labels".into(),
616 samples: vec![NumericMetricSample {
617 value: 10.0,
618 labels: Some(BTreeMap::from([("foo".into(), "bar".into())])),
619 }],
620 }];
621
622 let metrics = ClientMetrics {
623 app_name: "test-name".into(),
624 environment: Some("test-env".into()),
625 instance_id: Some("test-instance-id".into()),
626 connection_id: Some("test-connection-id".into()),
627 impact_metrics: Some(impact_metrics.clone()),
628 bucket: MetricBucket {
629 start: DateTime::<Utc>::from_timestamp(1000, 0).unwrap(),
630 stop: DateTime::<Utc>::from_timestamp(1000, 0).unwrap(),
631 toggles: HashMap::new(),
632 },
633 metadata: MetricsMetadata {
634 sdk_version: Some("rust-1.3.0".into()),
635 sdk_type: Some(SdkType::Backend),
636 yggdrasil_version: None,
637 platform_name: Some("rustc".into()),
638 platform_version: Some("1.7.9".into()),
639 },
640 };
641
642 let json_string = serde_json::to_string(&metrics).unwrap();
643 let deserialized: ClientMetrics = serde_json::from_str(&json_string).unwrap();
644
645 assert_eq!(deserialized.impact_metrics, Some(impact_metrics));
646 }
647
648 #[test]
649 pub fn can_increment_counts() {
650 let mut stats = ToggleStats::default();
651 assert_eq!(stats.yes, 0);
652 assert_eq!(stats.no, 0);
653 stats.yes();
654 stats.no();
655 assert_eq!(stats.yes, 1);
656 assert_eq!(stats.no, 1);
657 }
658
659 #[test]
660 pub fn can_increment_variant_count() {
661 let mut stats = ToggleStats::default();
662 assert!(stats.variants.is_empty());
663 stats.increment_variant_count("red");
664 stats.increment_variant_count("red");
665 let count = stats.variants.get("red").expect("No red key in map");
666 assert_eq!(count, &2);
667 }
668
669 #[test]
670 pub fn counts_correctly_based_on_enabled() {
671 let mut stats = ToggleStats::default();
672 stats.count(true);
673 stats.count(true);
674 stats.count(true);
675 stats.count(false);
676 stats.count(false);
677 assert_eq!(stats.yes, 3);
678 assert_eq!(stats.no, 2);
679 }
680 #[test]
681 pub fn counting_variant_should_also_increment_yes_no_counters() {
682 let mut stats = ToggleStats::default();
683 stats.count_variant("red");
684 stats.count_variant("green");
685 stats.count_variant("green");
686 stats.count_variant("green");
687 stats.variant_disabled();
688 assert_eq!(stats.yes, 4);
689 assert_eq!(stats.no, 1);
690 let red_count = stats.variants.get("red").unwrap();
691 let green_count = stats.variants.get("green").unwrap();
692 let disabled_count = stats.variants.get("disabled").unwrap();
693 assert_eq!(red_count, &1);
694 assert_eq!(green_count, &3);
695 assert_eq!(disabled_count, &1);
696 }
697
698 #[test]
699 fn toggle_states_can_be_deserialized_without_variants() {
700 let serialized_metrics = r#"
701 {
702 "appName": "some-app",
703 "instanceId": "some-instance",
704 "bucket": {
705 "start": "1867-11-07T12:00:00Z",
706 "stop": "1934-11-07T12:00:00Z",
707 "toggles": {
708 "some-feature": {
709 "yes": 1,
710 "no": 0
711 }
712 }
713 }
714 }
715 "#;
716 let metrics: ClientMetrics = serde_json::from_str(serialized_metrics).unwrap();
717 assert_eq!(metrics.bucket.toggles.get("some-feature").unwrap().yes, 1);
718 assert_eq!(metrics.bucket.toggles.get("some-feature").unwrap().no, 0);
719 }
720
721 #[test]
722 fn metrics_can_be_deserialized_from_legacy_data_structure() {
723 let serialized_metrics = r#"
724 {
725 "appName": "some-app",
726 "instanceId": "some-instance",
727 "bucket": {
728 "start": "1867-11-07T12:00:00Z",
729 "stop": "1934-11-07T12:00:00Z",
730 "toggles": {}
731 }
732 }
733 "#;
734 let metrics: ClientMetrics =
735 serde_json::from_str(serialized_metrics).expect("Should have deserialized correctly");
736 assert_eq!(metrics.metadata.yggdrasil_version, None);
737 }
738
739 #[test]
740 fn metrics_can_be_deserialized_when_containing_metadata_fields() {
741 let serialized_metrics = r#"
742 {
743 "appName": "some-app",
744 "instanceId": "some-instance",
745 "bucket": {
746 "start": "1867-11-07T12:00:00Z",
747 "stop": "1934-11-07T12:00:00Z",
748 "toggles": {}
749 },
750 "sdkVersion": "malbolge-1.0.0"
751 }
752 "#;
753 let metrics: ClientMetrics =
754 serde_json::from_str(serialized_metrics).expect("Should have deserialized correctly");
755 assert_eq!(metrics.metadata.sdk_version, Some("malbolge-1.0.0".into()));
756 }
757
758 #[test]
759 fn registration_can_be_deserialized_from_legacy_data_structure() {
760 let serialized_registration = r#"
761 {
762 "appName": "some-app",
763 "environment": "some-instance",
764 "projects": ["default"],
765 "instanceId": "something",
766 "interval": 15000,
767 "started": "1867-11-07T12:00:00Z",
768 "strategies": ["I-made-this-up"]
769 }
770 "#;
771 let registration: ClientApplication = serde_json::from_str(serialized_registration)
772 .expect("Should have deserialized correctly");
773 assert_eq!(registration.metadata.yggdrasil_version, None);
774 }
775
776 #[test]
777 fn registration_can_be_deserialized_when_containing_metadata_fields() {
778 let serialized_metrics = r#"
779 {
780 "appName": "some-app",
781 "instanceId": "some-instance",
782 "bucket": {
783 "start": "1867-11-07T12:00:00Z",
784 "stop": "1934-11-07T12:00:00Z",
785 "toggles": {}
786 },
787 "sdkVersion": "malbolge-1.0.0"
788 }
789 "#;
790 let metrics: ClientMetrics =
791 serde_json::from_str(serialized_metrics).expect("Should have deserialized correctly");
792
793 assert_eq!(metrics.metadata.sdk_version, Some("malbolge-1.0.0".into()));
794 }
795
796 #[test]
797 fn metrics_metadata_is_flattened_during_serialization() {
798 let expected_metrics = r#"
799 {
800 "appName": "test-name",
801 "bucket": {
802 "start": "1970-01-01T00:16:40Z",
803 "stop": "1970-01-01T00:16:40Z",
804 "toggles": {}
805 },
806 "environment": "test-env",
807 "instanceId": "test-instance-id",
808 "connectionId": "test-connection-id",
809 "sdkVersion": "rust-1.3.0",
810 "sdkType": "backend",
811 "yggdrasilVersion": null,
812 "platformName": "rustc",
813 "platformVersion": "1.7.9"
814 }
815 "#
816 .replace(" ", "")
817 .replace("\n", "");
818
819 let metrics = ClientMetrics {
820 app_name: "test-name".into(),
821 environment: Some("test-env".into()),
822 instance_id: Some("test-instance-id".into()),
823 connection_id: Some("test-connection-id".into()),
824 impact_metrics: None,
825 bucket: MetricBucket {
826 start: DateTime::<Utc>::from_timestamp(1000, 0).unwrap(),
827 stop: DateTime::<Utc>::from_timestamp(1000, 0).unwrap(),
828 toggles: HashMap::new(),
829 },
830 metadata: MetricsMetadata {
831 sdk_version: Some("rust-1.3.0".into()),
832 sdk_type: Some(SdkType::Backend),
833 yggdrasil_version: None,
834 platform_name: Some("rustc".into()),
835 platform_version: Some("1.7.9".into()),
836 },
837 };
838
839 let json_string = serde_json::to_string(&metrics).unwrap();
840 assert_eq!(json_string, expected_metrics);
841 }
842
843 #[test]
844 fn registration_metadata_is_flattened_during_serialization() {
845 let expected_registration = r#"
846 {
847 "appName": "test-name",
848 "connectVia": null,
849 "environment": "test-env",
850 "projects": ["default"],
851 "instanceId": "test-instance-id",
852 "connectionId": "test-connection-id",
853 "interval": 15000,
854 "started": "1970-01-01T00:16:40Z",
855 "strategies": [],
856 "sdkVersion": "rust-1.3.0",
857 "sdkType": "backend",
858 "yggdrasilVersion": null,
859 "platformName": "rustc",
860 "platformVersion": "1.7.9"
861 }
862 "#
863 .replace(" ", "")
864 .replace("\n", "");
865
866 let metrics = ClientApplication {
867 app_name: "test-name".into(),
868 environment: Some("test-env".into()),
869 projects: Some(vec!["default".into()]),
870 instance_id: Some("test-instance-id".into()),
871 connection_id: Some("test-connection-id".into()),
872 metadata: MetricsMetadata {
873 sdk_version: Some("rust-1.3.0".into()),
874 sdk_type: Some(SdkType::Backend),
875 yggdrasil_version: None,
876 platform_name: Some("rustc".into()),
877 platform_version: Some("1.7.9".into()),
878 },
879 connect_via: None,
880 interval: 15000,
881 started: DateTime::<Utc>::from_timestamp(1000, 0).unwrap(),
882 strategies: vec![],
883 };
884
885 let json_string = serde_json::to_string(&metrics).unwrap();
886 assert_eq!(json_string, expected_registration);
887 }
888}
889
890#[cfg(test)]
891#[cfg(feature = "wall-clock")]
892mod clock_tests {
893 use chrono::{Duration, Utc};
894
895 use super::*;
896
897 #[test]
898 pub fn can_have_client_metrics_env_from_metrics_bucket() {
899 let start = Utc::now();
900 let mut stats_feature_one = ToggleStats::default();
901 stats_feature_one.count_variant("red");
902 stats_feature_one.count_variant("green");
903 stats_feature_one.count_variant("green");
904 stats_feature_one.count_variant("green");
905 stats_feature_one.variant_disabled();
906 let mut stats_feature_two = ToggleStats::default();
907 stats_feature_two.count_variant("red");
908 stats_feature_two.count_variant("red");
909 stats_feature_two.count_variant("red");
910 stats_feature_two.count_variant("green");
911 stats_feature_two.yes();
912 stats_feature_two.yes();
913 stats_feature_two.yes();
914 stats_feature_two.variant_disabled();
915 let mut map = HashMap::new();
916 map.insert("feature_one".to_string(), stats_feature_one);
917 map.insert("feature_two".to_string(), stats_feature_two);
918 let bucket = MetricBucket {
919 start,
920 stop: start + Duration::minutes(50),
921 toggles: map,
922 };
923 let client_metrics_env = from_bucket_app_name_and_env(
924 bucket,
925 "unleash_edge_metrics".into(),
926 "development".into(),
927 MetricsMetadata {
928 ..Default::default()
929 },
930 );
931 assert_eq!(client_metrics_env.len(), 2);
932 let feature_one_metrics = client_metrics_env
933 .clone()
934 .into_iter()
935 .find(|e| e.feature_name == "feature_one")
936 .unwrap();
937
938 assert_eq!(feature_one_metrics.yes, 4);
939 assert_eq!(feature_one_metrics.no, 1);
940
941 let feature_two_metrics = client_metrics_env
942 .into_iter()
943 .find(|e| e.feature_name == "feature_two")
944 .unwrap();
945
946 assert_eq!(feature_two_metrics.yes, 7);
947 assert_eq!(feature_two_metrics.no, 1);
948 }
949
950 #[test]
951 pub fn can_connect_via_new_application() {
952 let demo_data = ClientApplication {
953 app_name: "demo".into(),
954 interval: 15500,
955 environment: Some("production".into()),
956 started: Utc::now(),
957 strategies: vec!["default".into(), "CustomStrategy".into()],
958 ..Default::default()
959 };
960 let connected_via = demo_data.connect_via("unleash-edge", "edge-id-1");
961 assert_eq!(
962 connected_via.connect_via,
963 Some(vec![ConnectVia {
964 app_name: "unleash-edge".into(),
965 instance_id: "edge-id-1".into(),
966 }]),
967 )
968 }
969
970 #[test]
971 pub fn can_merge_connected_via_where_one_side_is_none() {
972 let started = Utc::now();
973 let demo_data_1 = ClientApplication {
974 app_name: "demo".into(),
975 interval: 15500,
976 started,
977 strategies: vec!["default".into(), "gradualRollout".into()],
978 metadata: MetricsMetadata {
979 sdk_version: Some("unleash-client-java:7.1.0".into()),
980 ..Default::default()
981 },
982 ..Default::default()
983 };
984
985 let demo_data_2 = ClientApplication {
986 connect_via: Some(vec![ConnectVia {
987 app_name: "unleash-edge".into(),
988 instance_id: "2".into(),
989 }]),
990 app_name: "demo".into(),
991 interval: 15500,
992 environment: Some("production".into()),
993 started,
994 strategies: vec!["default".into(), "CustomStrategy".into()],
995 ..Default::default()
996 };
997 let merged = demo_data_1.clone().merge(demo_data_2.clone());
998 assert_eq!(demo_data_2.connect_via, merged.connect_via);
999 let reverse_merge = demo_data_2.clone().merge(demo_data_1);
1000 assert_eq!(demo_data_2.connect_via, reverse_merge.connect_via);
1001 }
1002
1003 #[test]
1004 pub fn can_merge_connected_via() {
1005 let started = Utc::now();
1006 let demo_data_1 = ClientApplication {
1007 connect_via: Some(vec![ConnectVia {
1008 app_name: "unleash-edge".into(),
1009 instance_id: "1".into(),
1010 }]),
1011 app_name: "demo".into(),
1012 interval: 15500,
1013 started,
1014 strategies: vec!["default".into(), "gradualRollout".into()],
1015 metadata: MetricsMetadata {
1016 sdk_version: Some("unleash-client-java:7.1.0".into()),
1017 ..Default::default()
1018 },
1019 ..Default::default()
1020 };
1021
1022 let demo_data_2 = ClientApplication {
1023 connect_via: Some(vec![ConnectVia {
1024 app_name: "unleash-edge".into(),
1025 instance_id: "2".into(),
1026 }]),
1027 app_name: "demo".into(),
1028 interval: 15500,
1029 environment: Some("production".into()),
1030 started,
1031 strategies: vec!["default".into(), "CustomStrategy".into()],
1032 ..Default::default()
1033 };
1034
1035 let merged = demo_data_1.merge(demo_data_2);
1036 let connections = merged.connect_via.unwrap();
1037 assert_eq!(connections.len(), 2);
1038 assert_eq!(
1039 connections,
1040 vec![
1041 ConnectVia {
1042 app_name: "unleash-edge".into(),
1043 instance_id: "1".into(),
1044 },
1045 ConnectVia {
1046 app_name: "unleash-edge".into(),
1047 instance_id: "2".into(),
1048 }
1049 ]
1050 )
1051 }
1052
1053 #[test]
1054 pub fn merging_two_client_applications_prioritizes_left_hand_side() {
1055 let started = Utc::now();
1056 let demo_data_1 = ClientApplication {
1057 app_name: "demo".into(),
1058 interval: 15500,
1059 started,
1060 strategies: vec!["default".into(), "gradualRollout".into()],
1061 metadata: MetricsMetadata {
1062 sdk_version: Some("unleash-client-java:7.1.0".into()),
1063 ..Default::default()
1064 },
1065 ..Default::default()
1066 };
1067
1068 let demo_data_2 = ClientApplication {
1069 app_name: "demo".into(),
1070 interval: 15500,
1071 environment: Some("production".into()),
1072 started,
1073 strategies: vec!["default".into(), "CustomStrategy".into()],
1074 ..Default::default()
1075 };
1076
1077 let left = demo_data_2.clone().merge(demo_data_1.clone());
1078 let right = demo_data_1.merge(demo_data_2);
1079
1080 assert_eq!(left, right);
1081 }
1082
1083 #[test]
1084 pub fn merging_two_client_applications_should_use_set_values() {
1085 let demo_data_orig = ClientApplication::new("demo", 15000);
1086 let demo_data_with_more_data = ClientApplication {
1087 app_name: "demo".into(),
1088 interval: 15500,
1089 environment: Some("development".into()),
1090 instance_id: Some("instance_id".into()),
1091 connection_id: Some("connection_id".into()),
1092 started: Utc::now(),
1093 strategies: vec!["default".into(), "gradualRollout".into()],
1094 metadata: MetricsMetadata {
1095 sdk_version: Some("unleash-client-java:7.1.0".into()),
1096 ..Default::default()
1097 },
1098 ..Default::default()
1099 };
1100 let merged = demo_data_orig.clone().merge(demo_data_with_more_data);
1102 assert_eq!(merged.interval, demo_data_orig.interval);
1103 assert_eq!(merged.environment, Some("development".into()));
1104 assert_eq!(
1105 merged.metadata.sdk_version,
1106 Some("unleash-client-java:7.1.0".into())
1107 );
1108 assert_eq!(merged.instance_id, Some("instance_id".into()));
1109 assert_eq!(merged.connection_id, Some("connection_id".into()));
1110 assert_eq!(merged.started, demo_data_orig.started);
1111 assert_eq!(merged.strategies.len(), 2);
1112 }
1113
1114 #[test]
1115 pub fn merging_two_client_applications_should_eliminate_duplicate_strategies() {
1116 let mut demo_data_1 = ClientApplication::new("demo", 15000);
1117 let mut demo_data_2 = ClientApplication::new("demo", 15000);
1118 demo_data_1.add_strategies(vec!["default".into(), "gradualRollout".into()]);
1119 demo_data_2.add_strategies(vec!["default".into(), "randomRollout".into()]);
1120 let demo_data_3 = demo_data_1.merge(demo_data_2);
1121 assert_eq!(demo_data_3.strategies.len(), 3);
1122 }
1123
1124 fn sort_samples_by_labels(mut impact_metric: ImpactMetric) -> ImpactMetric {
1125 match &mut impact_metric {
1126 ImpactMetric::Counter { samples, .. } | ImpactMetric::Gauge { samples, .. } => {
1127 samples.sort_by(|a, b| a.labels.cmp(&b.labels));
1128 }
1129 ImpactMetric::Histogram { samples, .. } => {
1130 samples.sort_by(|a, b| a.labels.cmp(&b.labels));
1131 }
1132 }
1133 impact_metric
1134 }
1135
1136 #[test]
1137 pub fn merging_impact_metric_env_counter_type_adds_values() {
1138 let mut metric1 = ImpactMetricEnv {
1139 impact_metric: ImpactMetric::Counter {
1140 name: "test_counter".into(),
1141 help: "Test counter metric".into(),
1142 samples: vec![
1143 NumericMetricSample {
1144 value: 10.0,
1145 labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1146 },
1147 NumericMetricSample {
1148 value: 20.0,
1149 labels: Some(BTreeMap::from([("label2".into(), "value2".into())])),
1150 },
1151 ],
1152 },
1153 app_name: "test_app".into(),
1154 environment: "test_env".into(),
1155 };
1156
1157 let metric2 = ImpactMetricEnv {
1158 impact_metric: ImpactMetric::Counter {
1159 name: "test_counter".into(),
1160 help: "Test counter metric".into(),
1161 samples: vec![
1162 NumericMetricSample {
1163 value: 15.0,
1164 labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1165 },
1166 NumericMetricSample {
1167 value: 25.0,
1168 labels: Some(BTreeMap::from([("label3".into(), "value3".into())])),
1169 },
1170 ],
1171 },
1172 app_name: "test_app".into(),
1173 environment: "test_env".into(),
1174 };
1175
1176 metric1.merge(metric2);
1177
1178 let expected_impact_metric = ImpactMetric::Counter {
1179 name: "test_counter".into(),
1180 help: "Test counter metric".into(),
1181 samples: vec![
1182 NumericMetricSample {
1183 value: 25.0, labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1185 },
1186 NumericMetricSample {
1187 value: 20.0, labels: Some(BTreeMap::from([("label2".into(), "value2".into())])),
1189 },
1190 NumericMetricSample {
1191 value: 25.0, labels: Some(BTreeMap::from([("label3".into(), "value3".into())])),
1193 },
1194 ],
1195 };
1196
1197 let sorted_merged = sort_samples_by_labels(metric1.impact_metric);
1198 let sorted_expected = sort_samples_by_labels(expected_impact_metric);
1199
1200 assert_eq!(sorted_merged, sorted_expected);
1201 }
1202
1203 #[test]
1204 pub fn merging_impact_metric_env_gauge_type_uses_last_value() {
1205 let mut metric1 = ImpactMetricEnv {
1206 impact_metric: ImpactMetric::Gauge {
1207 name: "test_gauge".into(),
1208 help: "Test gauge metric".into(),
1209 samples: vec![
1210 NumericMetricSample {
1211 value: 10.0,
1212 labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1213 },
1214 NumericMetricSample {
1215 value: 20.0,
1216 labels: Some(BTreeMap::from([("label2".into(), "value2".into())])),
1217 },
1218 ],
1219 },
1220 app_name: "test_app".into(),
1221 environment: "test_env".into(),
1222 };
1223
1224 let metric2 = ImpactMetricEnv {
1225 impact_metric: ImpactMetric::Gauge {
1226 name: "test_gauge".into(),
1227 help: "Test gauge metric".into(),
1228 samples: vec![
1229 NumericMetricSample {
1230 value: 15.0,
1231 labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1232 },
1233 NumericMetricSample {
1234 value: 25.0,
1235 labels: Some(BTreeMap::from([("label3".into(), "value3".into())])),
1236 },
1237 ],
1238 },
1239 app_name: "test_app".into(),
1240 environment: "test_env".into(),
1241 };
1242
1243 metric1.merge(metric2);
1244
1245 let expected_impact_metric = ImpactMetric::Gauge {
1246 name: "test_gauge".into(),
1247 help: "Test gauge metric".into(),
1248 samples: vec![
1249 NumericMetricSample {
1250 value: 15.0, labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1252 },
1253 NumericMetricSample {
1254 value: 20.0, labels: Some(BTreeMap::from([("label2".into(), "value2".into())])),
1256 },
1257 NumericMetricSample {
1258 value: 25.0, labels: Some(BTreeMap::from([("label3".into(), "value3".into())])),
1260 },
1261 ],
1262 };
1263
1264 let sorted_merged = sort_samples_by_labels(metric1.impact_metric);
1265 let sorted_expected = sort_samples_by_labels(expected_impact_metric);
1266
1267 assert_eq!(sorted_merged, sorted_expected);
1268 }
1269
1270 #[test]
1271 pub fn histogram_metric_serialization() {
1272 let histogram_metric = ImpactMetric::Histogram {
1273 name: "test_histogram".into(),
1274 help: "Test histogram metric".into(),
1275 samples: vec![BucketMetricSample {
1276 labels: Some(BTreeMap::from([("endpoint".into(), "/api/test".into())])),
1277 count: 50,
1278 sum: 125.5,
1279 buckets: vec![
1280 Bucket { le: 0.1, count: 10 },
1281 Bucket { le: 1.0, count: 30 },
1282 Bucket {
1283 le: f64::INFINITY,
1284 count: 50,
1285 },
1286 ],
1287 }],
1288 };
1289
1290 let json_string = serde_json::to_string(&histogram_metric).unwrap();
1291 let deserialized: ImpactMetric = serde_json::from_str(&json_string).unwrap();
1292
1293 assert_eq!(deserialized, histogram_metric);
1294 assert!(
1295 json_string.contains("\"+Inf\""),
1296 "JSON should contain +Inf for infinity bucket. Got: {}",
1297 json_string
1298 );
1299 }
1300
1301 #[test]
1302 pub fn merging_histogram_metrics() {
1303 let mut metric1 = ImpactMetricEnv {
1304 impact_metric: ImpactMetric::Histogram {
1305 name: "test_histogram".into(),
1306 help: "Test histogram metric".into(),
1307 samples: vec![BucketMetricSample {
1308 labels: Some(BTreeMap::from([("service".into(), "api".into())])),
1309 count: 10,
1310 sum: 25.0,
1311 buckets: vec![
1312 Bucket { le: 0.1, count: 5 },
1313 Bucket { le: 1.0, count: 8 },
1314 Bucket {
1315 le: f64::INFINITY,
1316 count: 10,
1317 },
1318 ],
1319 }],
1320 },
1321 app_name: "test_app".into(),
1322 environment: "test_env".into(),
1323 };
1324
1325 let metric2 = ImpactMetricEnv {
1326 impact_metric: ImpactMetric::Histogram {
1327 name: "test_histogram".into(),
1328 help: "Test histogram metric".into(),
1329 samples: vec![BucketMetricSample {
1330 labels: Some(BTreeMap::from([("service".into(), "api".into())])),
1331 count: 5,
1332 sum: 15.0,
1333 buckets: vec![
1334 Bucket { le: 0.1, count: 2 },
1335 Bucket { le: 0.5, count: 4 }, Bucket { le: 2.0, count: 4 }, Bucket {
1338 le: f64::INFINITY,
1339 count: 5,
1340 },
1341 ],
1342 }],
1343 },
1344 app_name: "test_app".into(),
1345 environment: "test_env".into(),
1346 };
1347
1348 metric1.merge(metric2);
1349
1350 let expected = ImpactMetricEnv {
1351 impact_metric: ImpactMetric::Histogram {
1352 name: "test_histogram".into(),
1353 help: "Test histogram metric".into(),
1354 samples: vec![BucketMetricSample {
1355 labels: Some(BTreeMap::from([("service".into(), "api".into())])),
1356 count: 15, sum: 40.0, buckets: vec![
1359 Bucket { le: 0.1, count: 7 },
1360 Bucket { le: 0.5, count: 4 },
1361 Bucket { le: 1.0, count: 8 },
1362 Bucket { le: 2.0, count: 4 },
1363 Bucket {
1364 le: f64::INFINITY,
1365 count: 15,
1366 }, ],
1368 }],
1369 },
1370 app_name: "test_app".into(),
1371 environment: "test_env".into(),
1372 };
1373
1374 assert_eq!(metric1, expected);
1375 }
1376
1377 #[test]
1378 fn bucket_ordering() {
1379 let bucket_1 = Bucket { le: 0.1, count: 10 };
1380 let bucket_2 = Bucket { le: 1.0, count: 20 };
1381 let bucket_3 = Bucket {
1382 le: 10.0,
1383 count: 30,
1384 };
1385 let bucket_inf = Bucket {
1386 le: f64::INFINITY,
1387 count: 40,
1388 };
1389
1390 assert!(bucket_1 < bucket_2);
1391 assert!(bucket_2 < bucket_3);
1392 assert!(bucket_3 < bucket_inf);
1393 assert!(bucket_1 < bucket_inf);
1394 }
1395}