unleash_api_client/
client.rs

1// Copyright 2020 Cognite AS
2//! The primary interface for users of the library.
3use std::collections::hash_map::HashMap;
4use std::default::Default;
5use std::fmt::{self, Debug, Display};
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use arc_swap::ArcSwapOption;
11use chrono::Utc;
12use enum_map::{EnumArray, EnumMap};
13use futures_timer::Delay;
14use log::{debug, trace, warn};
15use rand::Rng;
16use serde::de::DeserializeOwned;
17use serde::Serialize;
18use uuid::Uuid;
19
20use crate::api::{
21    self, ConstraintExpression, Feature, Features, Metrics, MetricsBucket, Registration,
22    ToggleMetrics,
23};
24use crate::context::Context;
25use crate::http::{HttpClient, HTTP};
26use crate::strategy;
27
28// ----------------- Variant
29
30/// Variant is returned from `Client.get_variant` and is a cut down and
31/// ergonomic version of `api.get_variant`
32#[derive(Clone, Debug, Default, Eq, PartialEq)]
33pub struct Variant {
34    pub name: String,
35    pub payload: HashMap<String, String>,
36    pub enabled: bool,
37}
38
39impl From<&CachedVariant> for Variant {
40    fn from(variant: &CachedVariant) -> Self {
41        Self {
42            name: variant.value.name.clone(),
43            payload: variant.value.payload.as_ref().cloned().unwrap_or_default(),
44            enabled: true,
45        }
46    }
47}
48
49impl Variant {
50    fn disabled() -> Self {
51        Self {
52            name: "disabled".into(),
53            ..Default::default()
54        }
55    }
56}
57
58// ----------------- ClientBuilder
59
60pub struct ClientBuilder {
61    disable_metric_submission: bool,
62    enable_str_features: bool,
63    interval: u64,
64    strategies: HashMap<String, strategy::Strategy>,
65}
66
67impl ClientBuilder {
68    pub fn into_client<F, C>(
69        self,
70        api_url: &str,
71        app_name: &str,
72        instance_id: &str,
73        authorization: Option<String>,
74    ) -> Result<Client<F, C>, C::Error>
75    where
76        F: EnumArray<CachedFeature> + Debug + DeserializeOwned + Serialize,
77        C: HttpClient + Default,
78    {
79        let connection_id = Uuid::new_v4().to_string();
80        Ok(Client {
81            api_url: api_url.into(),
82            app_name: app_name.into(),
83            disable_metric_submission: self.disable_metric_submission,
84            enable_str_features: self.enable_str_features,
85            instance_id: instance_id.into(),
86            connection_id: connection_id.clone(),
87            interval: self.interval,
88            polling: AtomicBool::new(false),
89            http: HTTP::new(
90                app_name.into(),
91                instance_id.into(),
92                connection_id,
93                authorization,
94            )?,
95            cached_state: ArcSwapOption::from(None),
96            strategies: Mutex::new(self.strategies),
97        })
98    }
99
100    pub fn disable_metric_submission(mut self) -> Self {
101        self.disable_metric_submission = true;
102        self
103    }
104
105    pub fn enable_string_features(mut self) -> Self {
106        self.enable_str_features = true;
107        self
108    }
109
110    pub fn interval(mut self, interval: u64) -> Self {
111        self.interval = interval;
112        self
113    }
114
115    pub fn strategy(mut self, name: &str, strategy: strategy::Strategy) -> Self {
116        self.strategies.insert(name.into(), strategy);
117        self
118    }
119}
120
121impl Default for ClientBuilder {
122    fn default() -> ClientBuilder {
123        let result = ClientBuilder {
124            disable_metric_submission: false,
125            enable_str_features: false,
126            interval: 15000,
127            strategies: Default::default(),
128        };
129        result
130            .strategy("default", Box::new(&strategy::default))
131            .strategy("applicationHostname", Box::new(&strategy::hostname))
132            .strategy("default", Box::new(&strategy::default))
133            .strategy("gradualRolloutRandom", Box::new(&strategy::random))
134            .strategy("gradualRolloutSessionId", Box::new(&strategy::session_id))
135            .strategy("gradualRolloutUserId", Box::new(&strategy::user_id))
136            .strategy("remoteAddress", Box::new(&strategy::remote_address))
137            .strategy("userWithId", Box::new(&strategy::user_with_id))
138            .strategy("flexibleRollout", Box::new(&strategy::flexible_rollout))
139    }
140}
141
142#[derive(Default)]
143pub struct CachedFeature {
144    pub strategies: Vec<strategy::Evaluate>,
145    // unknown features are tracked for metrics (so the server can see that they
146    // are being used). They require specific logic (see is_enabled).
147    known: bool,
148    // disabled features behaviour differently to empty strategies, so we carry
149    // this field across.
150    feature_disabled: bool,
151    // Tracks metrics during a refresh interval. If the AtomicBool updates show
152    // to be a contention point then thread-sharded counters with a gather phase
153    // on submission will be the next logical progression.
154    enabled: AtomicU64,
155    disabled: AtomicU64,
156    disabled_variant_count: AtomicU64,
157    // Variants for use with get_variant
158    variants: Vec<CachedVariant>,
159}
160
161impl From<&CachedFeature> for ToggleMetrics {
162    fn from(feature: &CachedFeature) -> Self {
163        ToggleMetrics {
164            yes: feature.enabled.load(Ordering::Relaxed),
165            no: feature.disabled.load(Ordering::Relaxed),
166            variants: feature.variant_metrics(),
167        }
168    }
169}
170
171impl CachedFeature {
172    fn variant_metrics(&self) -> HashMap<String, u64> {
173        self.variants
174            .iter()
175            .map(|variant| {
176                (
177                    variant.value.name.clone(),
178                    variant.count.load(Ordering::Relaxed),
179                )
180            })
181            .chain([(
182                "disabled".into(),
183                self.disabled_variant_count.load(Ordering::Relaxed),
184            )])
185            .collect()
186    }
187}
188
189pub struct CachedVariant {
190    count: AtomicU64,
191    value: api::Variant,
192}
193
194impl Clone for CachedVariant {
195    fn clone(&self) -> Self {
196        Self {
197            count: AtomicU64::new(self.count.load(Ordering::Relaxed)),
198            value: self.value.clone(),
199        }
200    }
201}
202
203impl From<api::Variant> for CachedVariant {
204    fn from(variant: api::Variant) -> Self {
205        CachedVariant {
206            value: variant,
207            count: AtomicU64::new(0),
208        }
209    }
210}
211
212pub struct CachedState<F>
213where
214    F: EnumArray<CachedFeature>,
215{
216    start: chrono::DateTime<chrono::Utc>,
217    // user supplies F defining the features they need
218    // The default value of F is defined as 'fallback to string lookups'.
219    features: EnumMap<F, CachedFeature>,
220    str_features: HashMap<String, CachedFeature>,
221}
222
223impl<F> CachedState<F>
224where
225    F: EnumArray<CachedFeature>,
226{
227    /// Access the cached string features.
228    pub fn str_features(&self) -> &HashMap<String, CachedFeature> {
229        &self.str_features
230    }
231}
232
233pub struct Client<F, C>
234where
235    F: EnumArray<CachedFeature> + Debug + DeserializeOwned + Serialize,
236    C: HttpClient,
237{
238    api_url: String,
239    app_name: String,
240    disable_metric_submission: bool,
241    enable_str_features: bool,
242    instance_id: String,
243    connection_id: String,
244    interval: u64,
245    polling: AtomicBool,
246    // Permits making extension calls to the Unleash API not yet modelled in the Rust SDK.
247    pub http: HTTP<C>,
248    // known strategies: strategy_name : memoiser
249    strategies: Mutex<HashMap<String, strategy::Strategy>>,
250    // memoised state: feature_name: [callback, callback, ...]
251    cached_state: ArcSwapOption<CachedState<F>>,
252}
253
254trait Enabled<F>
255where
256    F: EnumArray<CachedFeature>,
257{
258    fn is_enabled(&self, feature_enum: F, context: Option<&Context>, default: bool) -> bool;
259    fn is_enabled_str(
260        &self,
261        feature_name: &str,
262        context: Option<&Context>,
263        default: bool,
264        cached_features: &ArcSwapOption<CachedState<F>>,
265    ) -> bool;
266}
267
268impl<F> Enabled<F> for &Arc<CachedState<F>>
269where
270    F: EnumArray<CachedFeature> + Clone + Debug + DeserializeOwned + Serialize,
271{
272    fn is_enabled(&self, feature_enum: F, context: Option<&Context>, default: bool) -> bool {
273        fn raw_enabled<F: Debug>(
274            feature: &CachedFeature,
275            feature_enum: F,
276            context: &Context,
277            default: bool,
278        ) -> bool {
279            if feature.strategies.is_empty() && feature.known && !feature.feature_disabled {
280                trace!("is_enabled: feature {feature_enum:?} has no strategies: enabling");
281                return true;
282            }
283            for memo in feature.strategies.iter() {
284                if memo(context) {
285                    debug!(
286                        "is_enabled: feature {feature_enum:?} enabled by memo {memo:p}, context {context:?}"
287                    );
288                    return true;
289                } else {
290                    // Traces once per strategy (memo)
291                    trace!(
292                        "is_enabled: feature {feature_enum:?} not enabled by memo {memo:p}, context {context:?}"
293                    );
294                }
295            }
296            if !feature.known {
297                debug!("is_enabled: Unknown feature {feature_enum:?}, using default {default}");
298                default
299            } else {
300                // known, non-empty, missed all strategies: disabled
301                debug!("is_enabled: feature {feature_enum:?} failed all strategies, disabling");
302                false
303            }
304        }
305
306        trace!("is_enabled: feature {feature_enum:?} default {default}, context {context:?}");
307        let feature = &self.features[feature_enum.clone()];
308        let default_context = &Default::default();
309        let context = context.unwrap_or(default_context);
310
311        let feature_enabled = raw_enabled(feature, feature_enum, context, default);
312
313        if feature_enabled {
314            feature.enabled.fetch_add(1, Ordering::Relaxed);
315            true
316        } else {
317            feature.disabled.fetch_add(1, Ordering::Relaxed);
318            false
319        }
320    }
321
322    fn is_enabled_str(
323        &self,
324        feature_name: &str,
325        context: Option<&Context>,
326        default: bool,
327        cached_features: &ArcSwapOption<CachedState<F>>,
328    ) -> bool {
329        if let Some(feature) = &self.str_features.get(feature_name) {
330            let default_context: Context = Default::default();
331            let context = context.unwrap_or(&default_context);
332            if feature.strategies.is_empty() && feature.known && !feature.feature_disabled {
333                trace!("is_enabled: feature {feature_name} has no strategies: enabling");
334                feature.enabled.fetch_add(1, Ordering::Relaxed);
335                return true;
336            }
337            for memo in feature.strategies.iter() {
338                if memo(context) {
339                    debug!(
340                        "is_enabled: feature {feature_name} enabled by memo {memo:p}, context {context:?}"
341                    );
342                    feature.enabled.fetch_add(1, Ordering::Relaxed);
343                    return true;
344                } else {
345                    // Traces once per strategy (memo)
346                    trace!(
347                        "is_enabled: feature {feature_name} not enabled by memo {memo:p}, context {context:?}"
348                    );
349                }
350            }
351            if !feature.known {
352                trace!("is_enabled: Unknown feature {feature_name}, using default {default}");
353                if default {
354                    feature.enabled.fetch_add(1, Ordering::Relaxed);
355                } else {
356                    feature.disabled.fetch_add(1, Ordering::Relaxed);
357                }
358                default
359            } else {
360                false
361            }
362        } else {
363            debug!("is_enabled: Unknown feature {feature_name}, using default {default}");
364            // Insert a compiled feature to track metrics.
365            cached_features.rcu(|cached_state: &Option<Arc<CachedState<F>>>| {
366                // Did someone swap None in ?
367                if let Some(cached_state) = cached_state {
368                    let cached_state = cached_state.clone();
369                    if let Some(feature) = cached_state.str_features.get(feature_name) {
370                        // raced with *either* a poll_for_updates() that
371                        // added the feature in the API server or another
372                        // thread adding this same metric memoisation;
373                        // record against metrics here, but still return
374                        // default as consistent enough.
375                        if default {
376                            feature.enabled.fetch_add(1, Ordering::Relaxed);
377                        } else {
378                            feature.disabled.fetch_add(1, Ordering::Relaxed);
379                        }
380                        Some(cached_state)
381                    } else {
382                        // still not present; add it
383                        // Build up a new cached state
384                        let mut new_state = CachedState {
385                            start: cached_state.start,
386                            features: EnumMap::default(),
387                            str_features: HashMap::new(),
388                        };
389                        fn cloned_feature(feature: &CachedFeature) -> CachedFeature {
390                            CachedFeature {
391                                disabled: AtomicU64::new(feature.disabled.load(Ordering::Relaxed)),
392                                enabled: AtomicU64::new(feature.enabled.load(Ordering::Relaxed)),
393                                disabled_variant_count: AtomicU64::new(
394                                    feature.disabled_variant_count.load(Ordering::Relaxed),
395                                ),
396                                known: feature.known,
397                                feature_disabled: feature.feature_disabled,
398                                strategies: feature.strategies.clone(),
399                                variants: feature.variants.clone(),
400                            }
401                        }
402                        for (key, feature) in &cached_state.features {
403                            new_state.features[key] = cloned_feature(feature);
404                        }
405                        for (name, feature) in &cached_state.str_features {
406                            new_state
407                                .str_features
408                                .insert(name.clone(), cloned_feature(feature));
409                        }
410                        let stub_feature = CachedFeature {
411                            disabled: AtomicU64::new(if default { 0 } else { 1 }),
412                            enabled: AtomicU64::new(if default { 1 } else { 0 }),
413                            disabled_variant_count: AtomicU64::new(0),
414                            known: false,
415                            feature_disabled: false,
416                            strategies: vec![],
417                            variants: vec![],
418                        };
419                        new_state
420                            .str_features
421                            .insert(feature_name.into(), stub_feature);
422                        Some(Arc::new(new_state))
423                    }
424                } else {
425                    None
426                }
427            });
428            default
429        }
430    }
431}
432
433impl<F, C> Client<F, C>
434where
435    F: EnumArray<CachedFeature> + Clone + Debug + DeserializeOwned + Serialize,
436    C: HttpClient + Default,
437{
438    /// The cached state can be accessed. It may be uninitialised, and
439    /// represents a point in time snapshot: subsequent calls may have wound the
440    /// metrics back, entirely lost string features etc.
441    pub fn cached_state(&self) -> arc_swap::Guard<Option<Arc<CachedState<F>>>> {
442        let cache = self.cached_state.load();
443        if cache.is_none() {
444            // No API state loaded
445            trace!("is_enabled: No API state");
446        }
447        cache
448    }
449
450    /// Determine what variant (if any) of the feature the given context is
451    /// selected for. This is a consistent selection within a feature only
452    /// - across different features with identical variant definitions,
453    ///   different variant selection will take place.
454    ///
455    /// The key used to hash is the first of the username, sessionid, the host
456    /// address, or a random string per call to get_variant.
457    pub fn get_variant(&self, feature_enum: F, context: &Context) -> Variant {
458        trace!("get_variant: feature {feature_enum:?} context {context:?}");
459        let cache = self.cached_state();
460        let cache = match cache.as_ref() {
461            None => {
462                trace!("get_variant: feature {feature_enum:?} no cached state");
463                return Variant::disabled();
464            }
465            Some(cache) => cache,
466        };
467        let enabled = cache.is_enabled(feature_enum.clone(), Some(context), false);
468        let feature = &cache.features[feature_enum.clone()];
469        if !enabled {
470            feature
471                .disabled_variant_count
472                .fetch_add(1, Ordering::Relaxed);
473            return Variant::disabled();
474        }
475        let str_f = EnumToString(&feature_enum);
476        self._get_variant(feature, str_f, context)
477    }
478
479    /// Determine what variant (if any) of the feature the given context is
480    /// selected for. This is a consistent selection within a feature only
481    /// - across different features with identical variant definitions,
482    ///   different variant selection will take place.
483    ///
484    /// The key used to hash is the first of the username, sessionid, the host
485    /// address, or a random string per call to get_variant.
486    pub fn get_variant_str(&self, feature_name: &str, context: &Context) -> Variant {
487        trace!("get_variant_Str: feature {feature_name} context {context:?}");
488        assert!(
489            self.enable_str_features,
490            "String feature lookup not enabled"
491        );
492        let cache = self.cached_state();
493        let cache = match cache.as_ref() {
494            None => {
495                trace!("get_variant_str: feature {feature_name} no cached state");
496                return Variant::disabled();
497            }
498            Some(cache) => cache,
499        };
500        let enabled = cache.is_enabled_str(feature_name, Some(context), false, &self.cached_state);
501        let feature = &cache.str_features.get(feature_name);
502        if !enabled {
503            // Count the disabled variant on the newly created, previously missing feature.
504            match feature {
505                Some(f) => {
506                    f.disabled_variant_count.fetch_add(1, Ordering::Relaxed);
507                }
508                None => {
509                    if let Some(fresh_cache) = self.cached_state().as_ref() {
510                        let _ = &fresh_cache
511                            .str_features
512                            .get(feature_name)
513                            .map(|f| f.disabled_variant_count.fetch_add(1, Ordering::Relaxed));
514                    }
515                }
516            }
517            return Variant::disabled();
518        }
519        match feature {
520            None => {
521                trace!("get_variant_str: feature {feature_name} enabled but not in cache");
522                Variant::disabled()
523            }
524            Some(feature) => self._get_variant(feature, feature_name, context),
525        }
526    }
527
528    fn _get_variant<N: Debug + Display>(
529        &self,
530        feature: &CachedFeature,
531        feature_name: N,
532        context: &Context,
533    ) -> Variant {
534        if feature.variants.is_empty() {
535            trace!("get_variant: feature {feature_name:?} no variants");
536            feature
537                .disabled_variant_count
538                .fetch_add(1, Ordering::Relaxed);
539            return Variant::disabled();
540        }
541        let group = format!("{feature_name}");
542        let mut remote_address: Option<String> = None;
543        let identifier = context
544            .user_id
545            .as_ref()
546            .or(context.session_id.as_ref())
547            .or_else(|| {
548                context.remote_address.as_ref().and_then({
549                    |addr| {
550                        remote_address = Some(format!("{addr:?}"));
551                        remote_address.as_ref()
552                    }
553                })
554            });
555        if identifier.is_none() {
556            trace!(
557                "get_variant: feature {feature_name:?} context has no identifiers, selecting randomly"
558            );
559            let mut rng = rand::rng();
560            let picked = rng.random_range(0..feature.variants.len());
561            feature.variants[picked]
562                .count
563                .fetch_add(1, Ordering::Relaxed);
564            return (&feature.variants[picked]).into();
565        }
566        let identifier = identifier.unwrap();
567        let total_weight = feature.variants.iter().map(|v| v.value.weight as u32).sum();
568        strategy::normalised_variant_hash(&group, identifier, total_weight)
569            .map(|selected_weight| {
570                let mut counter: u32 = 0;
571                for variant in feature.variants.iter().as_ref() {
572                    counter += variant.value.weight as u32;
573                    if counter >= selected_weight {
574                        variant.count.fetch_add(1, Ordering::Relaxed);
575                        return variant.into();
576                    }
577                }
578
579                feature
580                    .disabled_variant_count
581                    .fetch_add(1, Ordering::Relaxed);
582                Variant::disabled()
583            })
584            .unwrap_or_else(|_| {
585                feature
586                    .disabled_variant_count
587                    .fetch_add(1, Ordering::Relaxed);
588
589                Variant::disabled()
590            })
591    }
592
593    pub fn is_enabled(&self, feature_enum: F, context: Option<&Context>, default: bool) -> bool {
594        trace!("is_enabled: feature {feature_enum:?} default {default}, context {context:?}");
595        let cache = self.cached_state();
596        let cache = match cache.as_ref() {
597            None => {
598                trace!("is_enabled: feature {feature_enum:?} no cached state");
599                return false;
600            }
601            Some(cache) => cache,
602        };
603        cache.is_enabled(feature_enum, context, default)
604    }
605
606    pub fn is_enabled_str(
607        &self,
608        feature_name: &str,
609        context: Option<&Context>,
610        default: bool,
611    ) -> bool {
612        trace!("is_enabled: feature_str {feature_name:?} default {default}, context {context:?}");
613        assert!(
614            self.enable_str_features,
615            "String feature lookup not enabled"
616        );
617        let cache = self.cached_state();
618        let cache = match cache.as_ref() {
619            None => return false,
620            Some(cache) => cache,
621        };
622        cache.is_enabled_str(feature_name, context, default, &self.cached_state)
623    }
624
625    /// Memoize new features into the cached state
626    ///
627    /// Interior mutability is used, via the arc-swap crate.
628    ///
629    /// Note that this is primarily public to facilitate benchmarking;
630    /// poll_for_updates is the usual way in which memoize will be called.
631    pub fn memoize(
632        &self,
633        features: Vec<Feature>,
634    ) -> Result<Option<Metrics>, Box<dyn std::error::Error + Send + Sync>> {
635        let now = Utc::now();
636        trace!("memoize: start with {} features", features.len());
637        let source_strategies = self.strategies.lock().unwrap();
638        let mut unenumerated_features: HashMap<String, CachedFeature> = HashMap::new();
639        let mut cached_features: EnumMap<F, CachedFeature> = EnumMap::default();
640        // HashMap<String, Vec<Box<strategy::Evaluate>>> = HashMap::new();
641        for feature in features {
642            let cached_feature = {
643                if !feature.enabled {
644                    // no strategies == return false per the unleash example code;
645                    let strategies = vec![];
646                    CachedFeature {
647                        strategies,
648                        disabled: AtomicU64::new(0),
649                        enabled: AtomicU64::new(0),
650                        disabled_variant_count: AtomicU64::new(0),
651                        known: true,
652                        feature_disabled: true,
653                        variants: vec![],
654                    }
655                } else {
656                    // TODO add variant support
657                    let mut strategies = vec![];
658                    for api_strategy in feature.strategies {
659                        if let Some(code_strategy) = source_strategies.get(&api_strategy.name) {
660                            strategies.push(strategy::constrain(
661                                api_strategy.constraints,
662                                code_strategy,
663                                api_strategy.parameters,
664                            ));
665                        }
666                        // Graceful degradation: ignore this unknown strategy.
667                        // TODO: add a logging layer and log it.
668                    }
669                    // Only include variants where the weight is greater than zero to save filtering at query time
670                    let variants = feature
671                        .variants
672                        .unwrap_or_default()
673                        .into_iter()
674                        .filter(|v| v.weight > 0)
675                        .map(Into::into)
676                        .collect();
677                    CachedFeature {
678                        strategies,
679                        disabled: AtomicU64::new(0),
680                        enabled: AtomicU64::new(0),
681                        disabled_variant_count: AtomicU64::new(0),
682                        known: true,
683                        feature_disabled: false,
684                        variants,
685                    }
686                }
687            };
688            if let Ok(feature_enum) = serde_plain::from_str::<F>(feature.name.as_str()) {
689                cached_features[feature_enum] = cached_feature;
690            } else {
691                unenumerated_features.insert(feature.name.clone(), cached_feature);
692            }
693        }
694        let new_cache = CachedState {
695            start: now,
696            features: cached_features,
697            str_features: unenumerated_features,
698        };
699        // Now we have the new cache compiled, swap it in.
700        let old = self.cached_state.swap(Some(Arc::new(new_cache)));
701        trace!("memoize: swapped memoized state in");
702        if let Some(old) = old {
703            // send metrics here
704            let mut bucket = MetricsBucket {
705                start: old.start,
706                stop: now,
707                toggles: HashMap::new(),
708            };
709            for (key, feature) in &old.features {
710                bucket.toggles.insert(
711                    // Is this unwrap safe? Not sure.
712                    serde_plain::to_string(&key).unwrap(),
713                    feature.into(),
714                );
715            }
716            // Only create metrics for used str_features.
717            if self.enable_str_features {
718                for (name, feature) in &old.str_features {
719                    if feature.enabled.load(Ordering::Relaxed) != 0
720                        || feature.disabled.load(Ordering::Relaxed) != 0
721                    {
722                        bucket.toggles.insert(name.clone(), feature.into());
723                    }
724                }
725            }
726            let metrics = Metrics {
727                app_name: self.app_name.clone(),
728                instance_id: self.instance_id.clone(),
729                connection_id: self.connection_id.clone(),
730                bucket,
731            };
732            Ok(Some(metrics))
733        } else {
734            Ok(None)
735        }
736    }
737
738    /// Query the API endpoint for features and push metrics
739    ///
740    /// Immediately and then every self.interval milliseconds the API server is
741    /// queryed for features and the previous cycles metrics are uploaded.
742    ///
743    /// May be dropped, or will terminate at the next polling cycle after
744    /// stop_poll is called().
745    pub async fn poll_for_updates(&self) {
746        // TODO: add an event / pipe to permit immediate exit.
747        let endpoint = Features::endpoint(&self.api_url);
748        let metrics_endpoint = Metrics::endpoint(&self.api_url);
749        self.polling.store(true, Ordering::Relaxed);
750        loop {
751            debug!("poll: retrieving features");
752            match self
753                .http
754                .get_json::<Features>(&endpoint, Some(self.interval))
755                .await
756            {
757                Ok(features) => {
758                    for feature in &features.features {
759                        for strategy in &feature.strategies {
760                            if let Some(constraints) = &strategy.constraints {
761                                for constraint in constraints {
762                                    if matches!(
763                                        &constraint.expression,
764                                        ConstraintExpression::Unknown(..)
765                                    ) {
766                                        warn!("Unknown or invalid constraint expression {:?} detected in strategy '{}' in feature toggle '{}'",  
767                                            serde_json::to_string(&constraint.expression),
768                                            strategy.name,
769                                            feature.name);
770                                    }
771                                }
772                            }
773                        }
774                    }
775
776                    match self.memoize(features.features) {
777                        Ok(None) => {}
778                        Ok(Some(metrics)) => {
779                            if !self.disable_metric_submission {
780                                let mut metrics_uploaded = false;
781                                let res = self
782                                    .http
783                                    .post_json(&metrics_endpoint, metrics, Some(self.interval))
784                                    .await;
785                                if let Ok(successful) = res {
786                                    if successful {
787                                        metrics_uploaded = true;
788                                        debug!("poll: uploaded feature metrics")
789                                    }
790                                }
791                                if !metrics_uploaded {
792                                    warn!("poll: error uploading feature metrics");
793                                }
794                            }
795                        }
796                        Err(err) => {
797                            warn!("poll: failed to memoize features: {err:?}");
798                        }
799                    }
800                }
801                Err(err) => {
802                    warn!("poll: failed to retrieve features: {err:?}");
803                }
804            }
805
806            let duration = Duration::from_millis(self.interval);
807            debug!("poll: waiting {duration:?}");
808            Delay::new(duration).await;
809
810            if !self.polling.load(Ordering::Relaxed) {
811                return;
812            }
813        }
814    }
815
816    /// Register this client with the API endpoint.
817    pub async fn register(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
818        let registration = Registration {
819            app_name: self.app_name.clone(),
820            instance_id: self.instance_id.clone(),
821            connection_id: self.connection_id.clone(),
822            interval: self.interval,
823            strategies: self
824                .strategies
825                .lock()
826                .unwrap()
827                .keys()
828                .map(|s| s.to_owned())
829                .collect(),
830            ..Default::default()
831        };
832        let success = self
833            .http
834            .post_json(&Registration::endpoint(&self.api_url), &registration, None)
835            .await
836            .map_err(|err| anyhow::anyhow!(err))?;
837        if !success {
838            return Err(anyhow::anyhow!("Failed to register with unleash API server").into());
839        }
840        Ok(())
841    }
842
843    /// stop the poll_for_updates() function.
844    ///
845    /// If poll is not running, will wait-loop until poll_for_updates is
846    /// running, then signal it to stop, then return. Will wait for ever if
847    /// poll_for_updates never starts running.
848    pub async fn stop_poll(&self) {
849        loop {
850            match self
851                .polling
852                .compare_exchange(true, false, Ordering::Relaxed, Ordering::Relaxed)
853            {
854                Ok(_) => {
855                    return;
856                }
857                Err(_) => {
858                    Delay::new(Duration::from_millis(50)).await;
859                }
860            }
861        }
862    }
863}
864
865// DisplayForEnum
866
867/// Adapts an Enum to have Display for _get_variant so we can give consistent
868/// results between get_variant and get_variant_str on the same feature.
869struct EnumToString<T>(T)
870where
871    T: Debug;
872
873impl<T> Debug for EnumToString<T>
874where
875    T: Debug,
876{
877    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
878        self.0.fmt(formatter)
879    }
880}
881
882impl<T> Display for EnumToString<T>
883where
884    T: Debug,
885{
886    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
887        self.0.fmt(formatter)
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use std::collections::hash_map::HashMap;
894    use std::collections::hash_set::HashSet;
895    use std::default::Default;
896    use std::hash::BuildHasher;
897    use std::sync::atomic::AtomicU64;
898
899    use enum_map::Enum;
900    use maplit::hashmap;
901    use serde::{Deserialize, Serialize};
902
903    use super::{ClientBuilder, Variant};
904    use crate::api::{self, Feature, Features, Strategy, ToggleMetrics};
905    use crate::client::{CachedFeature, CachedVariant};
906    use crate::context::{Context, IPAddress};
907    use crate::strategy;
908
909    cfg_if::cfg_if! {
910        if #[cfg(feature = "reqwest")] {
911            use reqwest::Client as HttpClient;
912        } else if #[cfg(feature = "reqwest-11")] {
913            use reqwest_11::Client as HttpClient;
914        } else {
915            compile_error!("Cannot run test suite without a client enabled");
916        }
917    }
918
919    fn features() -> Features {
920        Features {
921            version: 1,
922            features: vec![
923                Feature {
924                    description: Some("default".to_string()),
925                    enabled: true,
926                    created_at: None,
927                    variants: None,
928                    name: "default".into(),
929                    strategies: vec![Strategy {
930                        name: "default".into(),
931                        ..Default::default()
932                    }],
933                },
934                Feature {
935                    description: Some("userWithId".to_string()),
936                    enabled: true,
937                    created_at: None,
938                    variants: None,
939                    name: "userWithId".into(),
940                    strategies: vec![Strategy {
941                        name: "userWithId".into(),
942                        parameters: Some(hashmap!["userIds".into()=>"present".into()]),
943                        ..Default::default()
944                    }],
945                },
946                Feature {
947                    description: Some("userWithId+default".to_string()),
948                    enabled: true,
949                    created_at: None,
950                    variants: None,
951                    name: "userWithId+default".into(),
952                    strategies: vec![
953                        Strategy {
954                            name: "userWithId".into(),
955                            parameters: Some(hashmap!["userIds".into()=>"present".into()]),
956                            ..Default::default()
957                        },
958                        Strategy {
959                            name: "default".into(),
960                            ..Default::default()
961                        },
962                    ],
963                },
964                Feature {
965                    description: Some("disabled".to_string()),
966                    enabled: false,
967                    created_at: None,
968                    variants: None,
969                    name: "disabled".into(),
970                    strategies: vec![Strategy {
971                        name: "default".into(),
972                        ..Default::default()
973                    }],
974                },
975                Feature {
976                    description: Some("nostrategies".to_string()),
977                    enabled: true,
978                    created_at: None,
979                    variants: None,
980                    name: "nostrategies".into(),
981                    strategies: vec![],
982                },
983            ],
984        }
985    }
986
987    #[test]
988    fn test_memoization_enum() {
989        let _ = simple_logger::SimpleLogger::new()
990            .with_utc_timestamps()
991            .with_module_level("isahc::agent", log::LevelFilter::Off)
992            .with_module_level("tracing::span", log::LevelFilter::Off)
993            .with_module_level("tracing::span::active", log::LevelFilter::Off)
994            .init();
995        let f = features();
996        // with an enum
997        #[allow(non_camel_case_types)]
998        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
999        enum UserFeatures {
1000            unknown,
1001            default,
1002            userWithId,
1003            #[serde(rename = "userWithId+default")]
1004            userWithId_Default,
1005            disabled,
1006            nostrategies,
1007        }
1008        let c = ClientBuilder::default()
1009            .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1010            .unwrap();
1011
1012        c.memoize(f.features).unwrap();
1013        let present: Context = Context {
1014            user_id: Some("present".into()),
1015            ..Default::default()
1016        };
1017        let missing: Context = Context {
1018            user_id: Some("missing".into()),
1019            ..Default::default()
1020        };
1021        // features unknown on the server should honour the default
1022        assert!(!c.is_enabled(UserFeatures::unknown, None, false));
1023        assert!(c.is_enabled(UserFeatures::unknown, None, true));
1024        // default should be enabled, no context needed
1025        assert!(c.is_enabled(UserFeatures::default, None, false));
1026        // user present should be present on userWithId
1027        assert!(c.is_enabled(UserFeatures::userWithId, Some(&present), false));
1028        // user missing should not
1029        assert!(!c.is_enabled(UserFeatures::userWithId, Some(&missing), false));
1030        // user missing should be present on userWithId+default
1031        assert!(c.is_enabled(UserFeatures::userWithId_Default, Some(&missing), false));
1032        // disabled should be disabled
1033        assert!(!c.is_enabled(UserFeatures::disabled, None, true));
1034        // no strategies should result in enabled features.
1035        assert!(c.is_enabled(UserFeatures::nostrategies, None, false));
1036    }
1037
1038    #[test]
1039    fn test_memoization_strs() {
1040        let _ = simple_logger::SimpleLogger::new()
1041            .with_utc_timestamps()
1042            .with_module_level("isahc::agent", log::LevelFilter::Off)
1043            .with_module_level("tracing::span", log::LevelFilter::Off)
1044            .with_module_level("tracing::span::active", log::LevelFilter::Off)
1045            .init();
1046        let f = features();
1047        // And with plain old strings
1048        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1049        enum NoFeatures {}
1050        let c = ClientBuilder::default()
1051            .enable_string_features()
1052            .into_client::<NoFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1053            .unwrap();
1054
1055        c.memoize(f.features).unwrap();
1056        let present: Context = Context {
1057            user_id: Some("present".into()),
1058            ..Default::default()
1059        };
1060        let missing: Context = Context {
1061            user_id: Some("missing".into()),
1062            ..Default::default()
1063        };
1064        // features unknown on the server should honour the default
1065        assert!(!c.is_enabled_str("unknown", None, false));
1066        assert!(c.is_enabled_str("unknown", None, true));
1067        // default should be enabled, no context needed
1068        assert!(c.is_enabled_str("default", None, false));
1069        // user present should be present on userWithId
1070        assert!(c.is_enabled_str("userWithId", Some(&present), false));
1071        // user missing should not
1072        assert!(!c.is_enabled_str("userWithId", Some(&missing), false));
1073        // user missing should be present on userWithId+default
1074        assert!(c.is_enabled_str("userWithId+default", Some(&missing), false));
1075        // disabled should be disabled
1076        assert!(!c.is_enabled_str("disabled", None, true));
1077        // no strategies should result in enabled features.
1078        assert!(c.is_enabled_str("nostrategies", None, false));
1079    }
1080
1081    fn _reversed_uids<S: BuildHasher>(
1082        parameters: Option<HashMap<String, String, S>>,
1083    ) -> strategy::Evaluate {
1084        let mut uids: HashSet<String> = HashSet::new();
1085        if let Some(parameters) = parameters {
1086            if let Some(uids_list) = parameters.get("userIds") {
1087                for uid in uids_list.split(',') {
1088                    uids.insert(uid.chars().rev().collect());
1089                }
1090            }
1091        }
1092        Box::new(move |context: &Context| -> bool {
1093            context
1094                .user_id
1095                .as_ref()
1096                .map(|uid| uids.contains(uid))
1097                .unwrap_or(false)
1098        })
1099    }
1100
1101    #[test]
1102    fn test_custom_strategy() {
1103        let _ = simple_logger::SimpleLogger::new()
1104            .with_utc_timestamps()
1105            .with_module_level("isahc::agent", log::LevelFilter::Off)
1106            .with_module_level("tracing::span", log::LevelFilter::Off)
1107            .with_module_level("tracing::span::active", log::LevelFilter::Off)
1108            .init();
1109        #[allow(non_camel_case_types)]
1110        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1111        enum UserFeatures {
1112            default,
1113            reversed,
1114        }
1115        let client = ClientBuilder::default()
1116            .strategy("reversed", Box::new(&_reversed_uids))
1117            .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1118            .unwrap();
1119
1120        let f = Features {
1121            version: 1,
1122            features: vec![
1123                Feature {
1124                    description: Some("default".to_string()),
1125                    enabled: true,
1126                    created_at: None,
1127                    variants: None,
1128                    name: "default".into(),
1129                    strategies: vec![Strategy {
1130                        name: "default".into(),
1131                        ..Default::default()
1132                    }],
1133                },
1134                Feature {
1135                    description: Some("reversed".to_string()),
1136                    enabled: true,
1137                    created_at: None,
1138                    variants: None,
1139                    name: "reversed".into(),
1140                    strategies: vec![Strategy {
1141                        name: "reversed".into(),
1142                        parameters: Some(hashmap!["userIds".into()=>"abc".into()]),
1143                        ..Default::default()
1144                    }],
1145                },
1146            ],
1147        };
1148        client.memoize(f.features).unwrap();
1149        let present: Context = Context {
1150            user_id: Some("cba".into()),
1151            ..Default::default()
1152        };
1153        let missing: Context = Context {
1154            user_id: Some("abc".into()),
1155            ..Default::default()
1156        };
1157        // user cba should be present on reversed
1158        assert!(client.is_enabled(UserFeatures::reversed, Some(&present), false));
1159        // user abc should not
1160        assert!(!client.is_enabled(UserFeatures::reversed, Some(&missing), false));
1161        // adding custom strategies shouldn't disable built-in ones
1162        // default should be enabled, no context needed
1163        assert!(client.is_enabled(UserFeatures::default, None, false));
1164    }
1165
1166    fn variant_features() -> Features {
1167        Features {
1168            version: 1,
1169            features: vec![
1170                Feature {
1171                    description: Some("disabled".to_string()),
1172                    enabled: false,
1173                    created_at: None,
1174                    variants: None,
1175                    name: "disabled".into(),
1176                    strategies: vec![],
1177                },
1178                Feature {
1179                    description: Some("novariants".to_string()),
1180                    enabled: true,
1181                    created_at: None,
1182                    variants: None,
1183                    name: "novariants".into(),
1184                    strategies: vec![Strategy {
1185                        name: "default".into(),
1186                        ..Default::default()
1187                    }],
1188                },
1189                Feature {
1190                    description: Some("one".to_string()),
1191                    enabled: true,
1192                    created_at: None,
1193                    variants: Some(vec![api::Variant {
1194                        name: "variantone".into(),
1195                        weight: 100,
1196                        payload: Some(hashmap![
1197                            "type".into() => "string".into(),
1198                            "value".into() => "val1".into()]),
1199                        overrides: None,
1200                    }]),
1201                    name: "one".into(),
1202                    strategies: vec![],
1203                },
1204                Feature {
1205                    description: Some("two".to_string()),
1206                    enabled: true,
1207                    created_at: None,
1208                    variants: Some(vec![
1209                        api::Variant {
1210                            name: "variantone".into(),
1211                            weight: 50,
1212                            payload: Some(hashmap![
1213                            "type".into() => "string".into(),
1214                            "value".into() => "val1".into()]),
1215                            overrides: None,
1216                        },
1217                        api::Variant {
1218                            name: "varianttwo".into(),
1219                            weight: 50,
1220                            payload: Some(hashmap![
1221                            "type".into() => "string".into(),
1222                            "value".into() => "val2".into()]),
1223                            overrides: None,
1224                        },
1225                    ]),
1226                    name: "two".into(),
1227                    strategies: vec![],
1228                },
1229                Feature {
1230                    description: Some("nostrategies".to_string()),
1231                    enabled: true,
1232                    created_at: None,
1233                    variants: None,
1234                    name: "nostrategies".into(),
1235                    strategies: vec![],
1236                },
1237            ],
1238        }
1239    }
1240
1241    #[test]
1242    fn variants_enum() {
1243        let _ = simple_logger::SimpleLogger::new()
1244            .with_utc_timestamps()
1245            .with_module_level("isahc::agent", log::LevelFilter::Off)
1246            .with_module_level("tracing::span", log::LevelFilter::Off)
1247            .with_module_level("tracing::span::active", log::LevelFilter::Off)
1248            .init();
1249        let f = variant_features();
1250        // with an enum
1251        #[allow(non_camel_case_types)]
1252        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1253        enum UserFeatures {
1254            disabled,
1255            novariants,
1256            one,
1257            two,
1258        }
1259        let c = ClientBuilder::default()
1260            .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1261            .unwrap();
1262
1263        c.memoize(f.features).unwrap();
1264
1265        // disabled should be disabled
1266        let variant = Variant::disabled();
1267        assert_eq!(
1268            variant,
1269            c.get_variant(UserFeatures::disabled, &Context::default())
1270        );
1271
1272        // enabled no variants should get the disabled variant
1273        let variant = Variant::disabled();
1274        assert_eq!(
1275            variant,
1276            c.get_variant(UserFeatures::novariants, &Context::default())
1277        );
1278
1279        // One variant
1280        let variant = Variant {
1281            name: "variantone".to_string(),
1282            payload: hashmap![
1283                "type".into()=>"string".into(),
1284                "value".into()=>"val1".into()
1285            ],
1286            enabled: true,
1287        };
1288        assert_eq!(
1289            variant,
1290            c.get_variant(UserFeatures::one, &Context::default())
1291        );
1292
1293        // Two variants
1294        let uid1: Context = Context {
1295            user_id: Some("user1".into()),
1296            ..Default::default()
1297        };
1298        let session1: Context = Context {
1299            session_id: Some("session1".into()),
1300            ..Default::default()
1301        };
1302        let host1: Context = Context {
1303            remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1304            ..Default::default()
1305        };
1306        let variant1 = Variant {
1307            name: "variantone".to_string(),
1308            payload: hashmap![
1309                "type".into()=>"string".into(),
1310                "value".into()=>"val1".into()
1311            ],
1312            enabled: true,
1313        };
1314        let variant2 = Variant {
1315            name: "varianttwo".to_string(),
1316            payload: hashmap![
1317                "type".into()=>"string".into(),
1318                "value".into()=>"val2".into()
1319            ],
1320            enabled: true,
1321        };
1322        assert_eq!(variant1, c.get_variant(UserFeatures::two, &uid1));
1323        assert_eq!(variant2, c.get_variant(UserFeatures::two, &session1));
1324        assert_eq!(variant1, c.get_variant(UserFeatures::two, &host1));
1325    }
1326
1327    #[test]
1328    fn variants_str() {
1329        let _ = simple_logger::SimpleLogger::new()
1330            .with_utc_timestamps()
1331            .with_module_level("isahc::agent", log::LevelFilter::Off)
1332            .with_module_level("tracing::span", log::LevelFilter::Off)
1333            .with_module_level("tracing::span::active", log::LevelFilter::Off)
1334            .init();
1335        let f = variant_features();
1336        // without the enum API
1337        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1338        enum NoFeatures {}
1339        let c = ClientBuilder::default()
1340            .enable_string_features()
1341            .into_client::<NoFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1342            .unwrap();
1343
1344        c.memoize(f.features).unwrap();
1345
1346        // disabled should be disabled
1347        let variant = Variant::disabled();
1348        assert_eq!(variant, c.get_variant_str("disabled", &Context::default()));
1349
1350        // enabled no variants should get the disabled variant
1351        let variant = Variant::disabled();
1352        assert_eq!(
1353            variant,
1354            c.get_variant_str("novariants", &Context::default())
1355        );
1356
1357        // One variant
1358        let variant = Variant {
1359            name: "variantone".to_string(),
1360            payload: hashmap![
1361                "type".into()=>"string".into(),
1362                "value".into()=>"val1".into()
1363            ],
1364            enabled: true,
1365        };
1366        assert_eq!(variant, c.get_variant_str("one", &Context::default()));
1367
1368        // Two variants
1369        let uid1: Context = Context {
1370            user_id: Some("user1".into()),
1371            ..Default::default()
1372        };
1373        let session1: Context = Context {
1374            session_id: Some("session1".into()),
1375            ..Default::default()
1376        };
1377        let host1: Context = Context {
1378            remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1379            ..Default::default()
1380        };
1381        let variant1 = Variant {
1382            name: "variantone".to_string(),
1383            payload: hashmap![
1384                "type".into()=>"string".into(),
1385                "value".into()=>"val1".into()
1386            ],
1387            enabled: true,
1388        };
1389        let variant2 = Variant {
1390            name: "varianttwo".to_string(),
1391            payload: hashmap![
1392                "type".into()=>"string".into(),
1393                "value".into()=>"val2".into()
1394            ],
1395            enabled: true,
1396        };
1397        assert_eq!(variant1, c.get_variant_str("two", &uid1));
1398        assert_eq!(variant2, c.get_variant_str("two", &session1));
1399        assert_eq!(variant1, c.get_variant_str("two", &host1));
1400    }
1401
1402    #[test]
1403    fn variant_metrics() {
1404        let _ = simple_logger::SimpleLogger::new()
1405            .with_utc_timestamps()
1406            .with_module_level("isahc::agent", log::LevelFilter::Off)
1407            .with_module_level("tracing::span", log::LevelFilter::Off)
1408            .with_module_level("tracing::span::active", log::LevelFilter::Off)
1409            .init();
1410        let f = variant_features();
1411        // with an enum
1412        #[allow(non_camel_case_types)]
1413        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1414        enum UserFeatures {
1415            disabled,
1416            novariants,
1417            one,
1418            two,
1419        }
1420        let c = ClientBuilder::default()
1421            .into_client::<UserFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1422            .unwrap();
1423
1424        c.memoize(f.features).unwrap();
1425
1426        let disabled_variant_count = |feature_name| -> u64 {
1427            *c.cached_state().clone().expect("No cached state").features[feature_name]
1428                .variant_metrics()
1429                .get("disabled")
1430                .unwrap()
1431        };
1432
1433        c.get_variant(UserFeatures::disabled, &Context::default());
1434        assert_eq!(disabled_variant_count(UserFeatures::disabled), 1);
1435
1436        c.get_variant(UserFeatures::novariants, &Context::default());
1437        assert_eq!(disabled_variant_count(UserFeatures::novariants), 1);
1438
1439        let session1: Context = Context {
1440            session_id: Some("session1".into()),
1441            ..Default::default()
1442        };
1443
1444        let host1: Context = Context {
1445            remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1446            ..Default::default()
1447        };
1448        c.get_variant(UserFeatures::two, &session1);
1449        c.get_variant(UserFeatures::two, &host1);
1450
1451        let variant_count = |feature_name, variant_name| -> u64 {
1452            *c.cached_state().clone().expect("No cached state").features[feature_name]
1453                .variant_metrics()
1454                .get(variant_name)
1455                .unwrap()
1456        };
1457
1458        assert_eq!(variant_count(UserFeatures::two, "variantone"), 1);
1459        assert_eq!(variant_count(UserFeatures::two, "varianttwo"), 1);
1460    }
1461
1462    #[test]
1463    fn variant_metrics_str() {
1464        let _ = simple_logger::SimpleLogger::new()
1465            .with_utc_timestamps()
1466            .with_module_level("isahc::agent", log::LevelFilter::Off)
1467            .with_module_level("tracing::span", log::LevelFilter::Off)
1468            .with_module_level("tracing::span::active", log::LevelFilter::Off)
1469            .init();
1470        let f = variant_features();
1471        // with an enum
1472        #[allow(non_camel_case_types)]
1473        #[derive(Debug, Deserialize, Serialize, Enum, Clone)]
1474        enum NoFeatures {}
1475        let c = ClientBuilder::default()
1476            .enable_string_features()
1477            .into_client::<NoFeatures, HttpClient>("http://127.0.0.1:1234/", "foo", "test", None)
1478            .unwrap();
1479
1480        c.memoize(f.features).unwrap();
1481
1482        let disabled_variant_count = |feature_name| -> u64 {
1483            *c.cached_state()
1484                .clone()
1485                .expect("No cached state")
1486                .str_features
1487                .get(feature_name)
1488                .expect("No feature named {feature_name}")
1489                .variant_metrics()
1490                .get("disabled")
1491                .unwrap()
1492        };
1493
1494        c.get_variant_str("disabled", &Context::default());
1495        assert_eq!(disabled_variant_count("disabled"), 1);
1496
1497        c.get_variant_str("novariants", &Context::default());
1498        assert_eq!(disabled_variant_count("novariants"), 1);
1499
1500        let session1: Context = Context {
1501            session_id: Some("session1".into()),
1502            ..Default::default()
1503        };
1504
1505        let host1: Context = Context {
1506            remote_address: Some(IPAddress("10.10.10.10".parse().unwrap())),
1507            ..Default::default()
1508        };
1509        c.get_variant_str("two", &session1);
1510        c.get_variant_str("two", &host1);
1511
1512        let variant_count = |feature_name, variant_name| -> u64 {
1513            *c.cached_state()
1514                .clone()
1515                .expect("No cached state")
1516                .str_features
1517                .get(feature_name)
1518                .expect("No feature named {feature_name}")
1519                .variant_metrics()
1520                .get(variant_name)
1521                .unwrap()
1522        };
1523
1524        assert_eq!(variant_count("two", "variantone"), 1);
1525        assert_eq!(variant_count("two", "varianttwo"), 1);
1526
1527        // Metrics should also be tracked for features that don't exist
1528        c.get_variant_str("nonexistent-feature", &Context::default());
1529        assert_eq!(variant_count("nonexistent-feature", "disabled"), 1);
1530
1531        c.get_variant_str("nonexistent-feature", &Context::default());
1532        assert_eq!(variant_count("nonexistent-feature", "disabled"), 2);
1533
1534        // Calling is_enabled_str shouldn't increment disabled variant counts
1535        c.is_enabled_str("bogus-feature", None, false);
1536        assert_eq!(variant_count("bogus-feature", "disabled"), 0);
1537    }
1538
1539    #[test]
1540    fn cached_feature_into_toggle_metrics() {
1541        let variant_counts = [("a", 36), ("b", 16), ("c", 42)];
1542
1543        let variants = variant_counts
1544            .iter()
1545            .map(|(name, count)| CachedVariant {
1546                count: AtomicU64::from(*count),
1547                value: api::Variant {
1548                    name: (*name).into(),
1549                    weight: 0,
1550                    payload: None,
1551                    overrides: None,
1552                },
1553            })
1554            .collect();
1555
1556        let yes_count = 85;
1557        let no_count = 364;
1558        let disabled_variant_count = 56;
1559
1560        let feature = CachedFeature {
1561            strategies: vec![],
1562            disabled: AtomicU64::new(no_count),
1563            enabled: AtomicU64::new(yes_count),
1564            known: true,
1565            feature_disabled: true,
1566            variants,
1567            disabled_variant_count: AtomicU64::new(disabled_variant_count),
1568        };
1569
1570        let metrics: ToggleMetrics = (&feature).into();
1571
1572        assert_eq!(metrics.yes, yes_count);
1573        assert_eq!(metrics.no, no_count);
1574
1575        let converted_metrics = metrics.variants;
1576        for (variant, count) in variant_counts {
1577            assert_eq!(*converted_metrics.get(variant).unwrap(), count);
1578        }
1579
1580        assert_eq!(
1581            *converted_metrics.get("disabled").unwrap(),
1582            disabled_variant_count
1583        )
1584    }
1585}