oai-statsig-rust 0.27.0

Statsig Rust SDK for usage in multi-user server environments.
Documentation
use std::collections::HashMap;

use super::dynamic_returnable::DynamicReturnable;
use crate::{
    evaluation::secondary_exposure_key::SecondaryExposureKey,
    gcir::gcir_formatter::GCIRHashable,
    hashing::{self, opt_bool_to_hashable},
    interned_string::InternedString,
    specs_response::explicit_params::ExplicitParameters,
};

use serde::{Deserialize, Serialize};

pub fn is_false(v: &bool) -> bool {
    !(*v)
}

#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SecondaryExposure {
    pub gate: InternedString,
    pub gate_value: InternedString,
    #[serde(rename = "ruleID")]
    pub rule_id: InternedString,
}

impl GCIRHashable for SecondaryExposure {
    fn create_hash(&self, name: &InternedString) -> u64 {
        hashing::hash_u64_slice(&[name.hash, self.gate_value.hash, self.rule_id.hash])
    }
}

impl SecondaryExposure {
    pub fn get_dedupe_key(&self) -> String {
        let mut key = String::new();
        key += &self.gate;
        key += "|";
        key += &self.gate_value;
        key += "|";
        key += self.rule_id.as_str();
        key
    }
}

impl From<&SecondaryExposure> for SecondaryExposureKey {
    fn from(val: &SecondaryExposure) -> Self {
        SecondaryExposureKey {
            gate_name_hash: val.gate.hash,
            rule_id_hash: val.rule_id.hash,
            gate_value_hash: val.gate_value.hash,
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ExtraExposureInfo {
    pub sampling_rate: Option<u64>,
    pub forward_all_exposures: Option<bool>,
    pub has_seen_analytical_gates: Option<bool>,
    pub override_config_name: Option<InternedString>,
    pub version: Option<u32>,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct BaseEvaluation {
    pub name: InternedString,
    pub rule_id: InternedString,
    pub secondary_exposures: Vec<SecondaryExposure>,
    pub version: Option<u32>,

    #[serde(skip_serializing)]
    pub(crate) exposure_info: Option<ExtraExposureInfo>,
}

pub enum AnyEvaluation<'a> {
    FeatureGate(&'a GateEvaluation),
    DynamicConfig(&'a DynamicConfigEvaluation),
    Experiment(&'a ExperimentEvaluation),
    Layer(&'a LayerEvaluation),
}

impl AnyEvaluation<'_> {
    pub fn get_base_result(&self) -> &BaseEvaluation {
        match self {
            AnyEvaluation::FeatureGate(gate) => &gate.base,
            AnyEvaluation::DynamicConfig(config) => &config.base,
            AnyEvaluation::Experiment(experiment) => &experiment.base,
            AnyEvaluation::Layer(layer) => &layer.base,
        }
    }

    pub fn get_gate_bool_value(&self) -> bool {
        match self {
            AnyEvaluation::FeatureGate(eval) => eval.value,
            _ => false, // return false for all other types
        }
    }
}

impl<'a> From<&'a LayerEvaluation> for AnyEvaluation<'a> {
    fn from(layer_eval: &'a LayerEvaluation) -> Self {
        AnyEvaluation::Layer(layer_eval)
    }
}

impl<'a> From<&'a GateEvaluation> for AnyEvaluation<'a> {
    fn from(gate_eval: &'a GateEvaluation) -> Self {
        AnyEvaluation::FeatureGate(gate_eval)
    }
}

impl<'a> From<&'a ExperimentEvaluation> for AnyEvaluation<'a> {
    fn from(experiment_eval: &'a ExperimentEvaluation) -> Self {
        AnyEvaluation::Experiment(experiment_eval)
    }
}

impl<'a> From<&'a DynamicConfigEvaluation> for AnyEvaluation<'a> {
    fn from(dynamic_config_evalation: &'a DynamicConfigEvaluation) -> Self {
        AnyEvaluation::DynamicConfig(dynamic_config_evalation)
    }
}

#[derive(Serialize, Deserialize, Clone)]
pub struct GateEvaluation {
    #[serde(flatten)]
    pub base: BaseEvaluation,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_type: Option<InternedString>,
    pub value: bool,
}

impl GCIRHashable for GateEvaluation {
    fn create_hash(&self, name: &InternedString) -> u64 {
        let version_hashes = optional_version_hash_values(&self.base.version);
        let hash_array = [
            name.hash,
            self.value as u64,
            self.base.rule_id.hash,
            hash_secondary_exposures(&self.base.secondary_exposures),
            version_hashes[0],
            version_hashes[1],
        ];
        hashing::hash_u64_slice(&hash_array)
    }
}

#[derive(Serialize, Deserialize, Clone)]
pub struct DynamicConfigEvaluation {
    #[serde(flatten)]
    pub base: BaseEvaluation,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_type: Option<InternedString>,
    pub value: DynamicReturnable,

    pub is_device_based: bool,

    pub passed: bool,
}

impl GCIRHashable for DynamicConfigEvaluation {
    fn create_hash(&self, name: &InternedString) -> u64 {
        let version_hashes = optional_version_hash_values(&self.base.version);
        let hash_array = [
            name.hash,
            self.value.get_stable_hash(),
            self.base.rule_id.hash,
            hash_secondary_exposures(&self.base.secondary_exposures),
            self.passed as u64,
            version_hashes[0],
            version_hashes[1],
        ];
        hashing::hash_u64_slice(&hash_array)
    }
}

#[derive(Serialize, Deserialize, Clone)]
pub struct ExperimentEvaluation {
    #[serde(flatten)]
    pub base: BaseEvaluation,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_type: Option<InternedString>,
    pub value: DynamicReturnable,

    pub is_device_based: bool,

    #[serde(skip_serializing_if = "is_false")]
    pub is_in_layer: bool,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub explicit_parameters: Option<ExplicitParameters>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub group_name: Option<InternedString>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_experiment_active: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_user_in_experiment: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub undelegated_secondary_exposures: Option<Vec<SecondaryExposure>>,
}

impl GCIRHashable for ExperimentEvaluation {
    fn create_hash(&self, name: &InternedString) -> u64 {
        let version_hashes = optional_version_hash_values(&self.base.version);
        let hash_array = [
            name.hash,
            self.value.get_stable_hash(),
            self.base.rule_id.hash,
            hash_secondary_exposures(&self.base.secondary_exposures),
            self.is_in_layer as u64,
            version_hashes[0],
            version_hashes[1],
            hash_explicit_parameters(self.explicit_parameters.as_ref()),
            self.group_name.as_ref().map_or(0, |g| g.hash),
            opt_bool_to_hashable(&self.is_experiment_active),
            opt_bool_to_hashable(&self.is_user_in_experiment),
        ];

        hashing::hash_u64_slice(&hash_array)
    }
}

#[derive(Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum AnyConfigEvaluation {
    DynamicConfig(DynamicConfigEvaluation),
    Experiment(ExperimentEvaluation),
}

impl GCIRHashable for AnyConfigEvaluation {
    fn create_hash(&self, name: &InternedString) -> u64 {
        match self {
            AnyConfigEvaluation::DynamicConfig(eval) => eval.create_hash(name),
            AnyConfigEvaluation::Experiment(eval) => eval.create_hash(name),
        }
    }
}

#[derive(Serialize, Deserialize, Clone)]
pub struct LayerEvaluation {
    #[serde(flatten)]
    pub base: BaseEvaluation,

    pub value: DynamicReturnable,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub id_type: Option<InternedString>,

    pub is_device_based: bool,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub group_name: Option<InternedString>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_experiment_active: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_user_in_experiment: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub allocated_experiment_name: Option<InternedString>,
    pub explicit_parameters: ExplicitParameters,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub undelegated_secondary_exposures: Option<Vec<SecondaryExposure>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub parameter_rule_ids: Option<HashMap<InternedString, InternedString>>,
}

impl GCIRHashable for LayerEvaluation {
    fn create_hash(&self, name: &InternedString) -> u64 {
        let version_hashes = optional_version_hash_values(&self.base.version);
        let mut hash_array = hashing::U64HashBuilder::new();
        hash_array.push(name.hash);
        hash_array.push(self.value.get_stable_hash());
        hash_array.push(self.base.rule_id.hash);
        hash_array.push(hash_secondary_exposures(&self.base.secondary_exposures));
        hash_array.push(self.group_name.as_ref().map_or(0, |g| g.hash));
        hash_array.push(opt_bool_to_hashable(&self.is_experiment_active));
        hash_array.push(opt_bool_to_hashable(&self.is_user_in_experiment));
        hash_array.push(
            self.allocated_experiment_name
                .as_ref()
                .map_or(0, |n| n.hash),
        );
        hash_array.push(version_hashes[0]);
        hash_array.push(version_hashes[1]);
        hash_array.push(hash_explicit_parameters(Some(&self.explicit_parameters)));
        hash_array.push(hash_optional_secondary_exposures(
            self.undelegated_secondary_exposures.as_ref(),
        ));
        if let Some(parameter_rule_ids) = &self.parameter_rule_ids {
            hash_array.push(hash_parameter_rule_ids(parameter_rule_ids));
        }

        hash_array.finish()
    }
}

fn hash_secondary_exposures(exposures: &Vec<SecondaryExposure>) -> u64 {
    let mut secondary_exposure_hashes = hashing::U64HashBuilder::with_capacity(exposures.len());
    for exposure in exposures {
        secondary_exposure_hashes.push(exposure.create_hash(&exposure.gate));
    }
    secondary_exposure_hashes.finish()
}

fn hash_optional_secondary_exposures(exposures: Option<&Vec<SecondaryExposure>>) -> u64 {
    let Some(exposures) = exposures else {
        return hashing::hash_u64_slice(&[]);
    };

    hash_secondary_exposures(exposures)
}

fn hash_explicit_parameters(explicit_parameters: Option<&ExplicitParameters>) -> u64 {
    let Some(explicit_parameters) = explicit_parameters else {
        return hashing::hash_u64_slice(&[]);
    };

    let mut explicit_params_hashes =
        hashing::U64HashBuilder::with_capacity(explicit_parameters.as_slice().len());
    for value in explicit_parameters.as_slice() {
        explicit_params_hashes.push(value.hash);
    }
    explicit_params_hashes.finish()
}

fn hash_parameter_rule_ids(parameter_rule_ids: &HashMap<InternedString, InternedString>) -> u64 {
    hashing::hash_unordered(
        parameter_rule_ids
            .iter()
            .map(|(param_name, rule_id)| hashing::hash_u64_slice(&[param_name.hash, rule_id.hash]))
            .collect(),
    )
}

fn optional_version_hash_values(version: &Option<u32>) -> [u64; 2] {
    [version.is_some() as u64, version.map_or(0, u64::from)]
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::{hash_parameter_rule_ids, optional_version_hash_values};
    use crate::interned_string::InternedString;

    #[test]
    fn optional_version_hash_values_distinguish_missing_from_zero() {
        assert_eq!(optional_version_hash_values(&None), [0, 0]);
        assert_eq!(optional_version_hash_values(&Some(0)), [1, 0]);
        assert_eq!(optional_version_hash_values(&Some(1)), [1, 1]);
    }

    #[test]
    fn parameter_rule_id_hash_ignores_map_order() {
        let first = HashMap::from([
            (
                InternedString::from_str_ref("alpha"),
                InternedString::from_str_ref("rule-a"),
            ),
            (
                InternedString::from_str_ref("beta"),
                InternedString::from_str_ref("rule-b"),
            ),
        ]);
        let second = HashMap::from([
            (
                InternedString::from_str_ref("beta"),
                InternedString::from_str_ref("rule-b"),
            ),
            (
                InternedString::from_str_ref("alpha"),
                InternedString::from_str_ref("rule-a"),
            ),
        ]);

        assert_eq!(
            hash_parameter_rule_ids(&first),
            hash_parameter_rule_ids(&second)
        );
    }
}