Skip to main content

unleash_types/
client_metrics.rs

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    /// Increments yes count
28    fn yes(&mut self) {
29        self.yes += 1
30    }
31    /// Increments no count
32    fn no(&mut self) {
33        self.no += 1
34    }
35
36    /// Use after evaluating a toggle passing in whether or not the toggle was enabled
37    pub fn count(&mut self, enabled: bool) {
38        if enabled {
39            self.yes()
40        } else {
41            self.no()
42        }
43    }
44
45    /// Counts occurrence for variant with name.
46    /// This method will also count yes for the toggle itself
47    /// Use count_disabled()
48    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    /// Incrementing count for var
59    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>, // needed due to nonstructural typing
423    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            // For gauge, last value wins
473            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    /// Will keep all set fields from self, overwriting None with Somes from other
542    /// Will merge strategies from self and other, deduplicating
543    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        // Cloning orig here, to avoid the destructive merge preventing us from testing
1101        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, // 10.0 + 15.0
1184                    labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1185                },
1186                NumericMetricSample {
1187                    value: 20.0, // Only in metric1
1188                    labels: Some(BTreeMap::from([("label2".into(), "value2".into())])),
1189                },
1190                NumericMetricSample {
1191                    value: 25.0, // Only in metric2
1192                    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, // Last value from metric2
1251                    labels: Some(BTreeMap::from([("label1".into(), "value1".into())])),
1252                },
1253                NumericMetricSample {
1254                    value: 20.0, // Only in metric1
1255                    labels: Some(BTreeMap::from([("label2".into(), "value2".into())])),
1256                },
1257                NumericMetricSample {
1258                    value: 25.0, // Only in metric2
1259                    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 }, // New bucket
1336                        Bucket { le: 2.0, count: 4 }, // Another new bucket
1337                        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, // 10 + 5
1357                    sum: 40.0, // 25.0 + 15.0
1358                    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                        }, // 10 + 5
1367                    ],
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}