statsig_rust/evaluation/
evaluator.rs

1use chrono::Utc;
2use lazy_static::lazy_static;
3
4use crate::evaluation::cmab_evaluator::evaluate_cmab;
5use crate::evaluation::comparisons::{
6    compare_arrays, compare_numbers, compare_str_with_regex, compare_strings_in_array,
7    compare_time, compare_versions,
8};
9use crate::evaluation::dynamic_value::DynamicValue;
10use crate::evaluation::evaluation_details::EvaluationDetails;
11use crate::evaluation::evaluation_types::SecondaryExposure;
12use crate::evaluation::evaluator_context::EvaluatorContext;
13use crate::evaluation::evaluator_value::{EvaluatorValue, EvaluatorValueType};
14use crate::evaluation::get_unit_id::get_unit_id;
15use crate::evaluation::ua_parser::UserAgentParser;
16use crate::specs_response::spec_types::{Condition, Rule, Spec};
17use crate::{dyn_value, log_e, unwrap_or_return, StatsigErr};
18
19use super::country_lookup::CountryLookup;
20
21const TAG: &str = stringify!(Evaluator);
22
23pub struct Evaluator;
24
25lazy_static! {
26    static ref EMPTY_STR: String = String::new();
27    static ref DEFAULT_RULE: String = "default".to_string();
28    static ref DISABLED_RULE: String = "disabled".to_string();
29    static ref EMPTY_EVALUATOR_VALUE: EvaluatorValue =
30        EvaluatorValue::new(EvaluatorValueType::Null);
31    static ref EMPTY_DYNAMIC_VALUE: DynamicValue = DynamicValue::new();
32}
33
34#[derive(Clone)]
35pub enum SpecType {
36    Gate,
37    DynamicConfig,
38    Experiment,
39    Layer,
40}
41
42#[derive(PartialEq, Eq)]
43pub enum Recognition {
44    Unrecognized,
45    Recognized,
46}
47
48impl Evaluator {
49    pub fn evaluate_with_details(
50        ctx: &mut EvaluatorContext,
51        spec_name: &str,
52        spec_type: &SpecType,
53    ) -> Result<EvaluationDetails, StatsigErr> {
54        let recognition = Self::evaluate(ctx, spec_name, spec_type)?;
55
56        if recognition == Recognition::Unrecognized {
57            return Ok(EvaluationDetails::unrecognized(ctx.spec_store_data));
58        }
59
60        if let Some(reason) = ctx.result.override_reason {
61            return Ok(EvaluationDetails::recognized_but_overridden(
62                ctx.spec_store_data,
63                reason,
64            ));
65        }
66
67        Ok(EvaluationDetails::recognized(
68            ctx.spec_store_data,
69            &ctx.result,
70        ))
71    }
72
73    pub fn evaluate(
74        ctx: &mut EvaluatorContext,
75        spec_name: &str,
76        spec_type: &SpecType,
77    ) -> Result<Recognition, StatsigErr> {
78        let opt_spec = match spec_type {
79            SpecType::Gate => ctx.spec_store_data.values.feature_gates.get(spec_name),
80            SpecType::DynamicConfig => ctx.spec_store_data.values.dynamic_configs.get(spec_name),
81            SpecType::Experiment => ctx.spec_store_data.values.dynamic_configs.get(spec_name),
82            SpecType::Layer => ctx.spec_store_data.values.layer_configs.get(spec_name),
83        };
84
85        if try_apply_override(ctx, spec_name, spec_type, opt_spec) {
86            return Ok(Recognition::Recognized);
87        }
88
89        if try_apply_config_mapping(ctx, spec_name, spec_type, opt_spec) {
90            return Ok(Recognition::Recognized);
91        }
92
93        if evaluate_cmab(ctx, spec_name, spec_type) {
94            return Ok(Recognition::Recognized);
95        }
96
97        let spec = unwrap_or_return!(opt_spec, Ok(Recognition::Unrecognized));
98
99        if ctx.result.id_type.is_none() {
100            ctx.result.id_type = Some(&spec.id_type);
101        }
102
103        if ctx.result.version.is_none() {
104            if let Some(version) = spec.version {
105                ctx.result.version = Some(version);
106            }
107        }
108
109        if let Some(is_active) = spec.is_active {
110            ctx.result.is_experiment_active = is_active;
111        }
112
113        if let Some(has_shared_params) = spec.has_shared_params {
114            ctx.result.is_in_layer = has_shared_params;
115        }
116
117        if let Some(explicit_params) = &spec.explicit_parameters {
118            ctx.result.explicit_parameters = Some(explicit_params);
119        }
120
121        for rule in &spec.rules {
122            evaluate_rule(ctx, rule)?;
123
124            if ctx.result.unsupported {
125                return Ok(Recognition::Recognized);
126            }
127
128            if !ctx.result.bool_value {
129                continue;
130            }
131
132            if evaluate_config_delegate(ctx, rule)? {
133                ctx.finalize_evaluation(spec, Some(rule));
134                return Ok(Recognition::Recognized);
135            }
136
137            let did_pass = evaluate_pass_percentage(ctx, rule, &spec.salt);
138
139            if did_pass {
140                ctx.result.bool_value = rule.return_value.get_bool() != Some(false);
141                ctx.result.json_value = rule.return_value.get_json();
142            } else {
143                ctx.result.bool_value = spec.default_value.get_bool() == Some(true);
144                ctx.result.json_value = spec.default_value.get_json();
145            }
146
147            ctx.result.rule_id = Some(&rule.id);
148            ctx.result.group_name = rule.group_name.as_ref();
149            ctx.result.is_experiment_group = rule.is_experiment_group.unwrap_or(false);
150            ctx.result.is_experiment_active = spec.is_active.unwrap_or(false);
151            ctx.finalize_evaluation(spec, Some(rule));
152            return Ok(Recognition::Recognized);
153        }
154
155        ctx.result.bool_value = spec.default_value.get_bool() == Some(true);
156        ctx.result.json_value = spec.default_value.get_json();
157        ctx.result.rule_id = match spec.enabled {
158            true => Some(&DEFAULT_RULE),
159            false => Some(&DISABLED_RULE),
160        };
161        ctx.finalize_evaluation(spec, None);
162
163        Ok(Recognition::Recognized)
164    }
165}
166
167fn try_apply_config_mapping(
168    ctx: &mut EvaluatorContext,
169    spec_name: &str,
170    spec_type: &SpecType,
171    opt_spec: Option<&Spec>,
172) -> bool {
173    let overrides = match &ctx.spec_store_data.values.overrides {
174        Some(overrides) => overrides,
175        None => return false,
176    };
177
178    let override_rules = match &ctx.spec_store_data.values.override_rules {
179        Some(override_rules) => override_rules,
180        None => return false,
181    };
182
183    let mapping_list = match overrides.get(spec_name) {
184        Some(mapping_list) => mapping_list,
185        None => return false,
186    };
187
188    let spec_salt = match opt_spec {
189        Some(spec) => &spec.salt,
190        None => &EMPTY_STR,
191    };
192
193    for mapping in mapping_list {
194        for override_rule in &mapping.rules {
195            let start_time = override_rule.start_time.unwrap_or_default();
196
197            if start_time > Utc::now().timestamp_millis() {
198                continue;
199            }
200
201            let rule = match override_rules.get(&override_rule.rule_name) {
202                Some(rule) => rule,
203                None => continue,
204            };
205            match evaluate_rule(ctx, rule) {
206                Ok(_) => {}
207                Err(_) => {
208                    ctx.reset_result();
209                    continue;
210                }
211            }
212
213            if !ctx.result.bool_value || ctx.result.unsupported {
214                ctx.reset_result();
215                continue;
216            }
217            ctx.reset_result();
218            let pass = evaluate_pass_percentage(ctx, rule, spec_salt);
219            if pass {
220                ctx.result.override_config_name = Some(mapping.new_config_name.clone());
221                match Evaluator::evaluate(ctx, mapping.new_config_name.as_str(), spec_type) {
222                    Ok(Recognition::Recognized) => {
223                        return true;
224                    }
225                    _ => {
226                        ctx.reset_result();
227                        break;
228                    }
229                }
230            }
231        }
232    }
233
234    false
235}
236
237fn try_apply_override(
238    ctx: &mut EvaluatorContext,
239    spec_name: &str,
240    spec_type: &SpecType,
241    opt_spec: Option<&Spec>,
242) -> bool {
243    let adapter = match &ctx.override_adapter {
244        Some(adapter) => adapter,
245        None => return false,
246    };
247
248    match spec_type {
249        SpecType::Gate => adapter.get_gate_override(ctx.user.user_data, spec_name, &mut ctx.result),
250
251        SpecType::DynamicConfig => {
252            adapter.get_dynamic_config_override(ctx.user.user_data, spec_name, &mut ctx.result)
253        }
254
255        SpecType::Experiment => adapter.get_experiment_override(
256            ctx.user.user_data,
257            spec_name,
258            &mut ctx.result,
259            opt_spec,
260        ),
261
262        SpecType::Layer => {
263            adapter.get_layer_override(ctx.user.user_data, spec_name, &mut ctx.result)
264        }
265    }
266}
267
268fn evaluate_rule<'a>(ctx: &mut EvaluatorContext<'a>, rule: &'a Rule) -> Result<(), StatsigErr> {
269    let mut all_conditions_pass = true;
270    // println!("--- Eval Rule {} ---", rule.id);
271    for condition_hash in &rule.conditions {
272        // println!("Condition Hash {}", condition_hash);
273        let opt_condition = ctx.spec_store_data.values.condition_map.get(condition_hash);
274        let condition = if let Some(c) = opt_condition {
275            c
276        } else {
277            // todo: log condition not found error
278            ctx.result.unsupported = true;
279            return Ok(());
280        };
281
282        evaluate_condition(ctx, condition)?;
283
284        if !ctx.result.bool_value {
285            all_conditions_pass = false;
286        }
287    }
288
289    ctx.result.bool_value = all_conditions_pass;
290
291    Ok(())
292}
293
294fn evaluate_condition<'a>(
295    ctx: &mut EvaluatorContext<'a>,
296    condition: &'a Condition,
297) -> Result<(), StatsigErr> {
298    let temp_value: Option<DynamicValue>;
299    let target_value = condition
300        .target_value
301        .as_ref()
302        .unwrap_or(&EMPTY_EVALUATOR_VALUE);
303    let condition_type = &condition.condition_type;
304
305    let value: &DynamicValue = match condition_type as &str {
306        "public" => {
307            ctx.result.bool_value = true;
308            return Ok(());
309        }
310        "fail_gate" | "pass_gate" => {
311            evaluate_nested_gate(ctx, target_value, condition_type)?;
312            return Ok(());
313        }
314        "ua_based" => match ctx.user.get_user_value(&condition.field) {
315            Some(value) => Some(value),
316            None => {
317                temp_value =
318                    UserAgentParser::get_value_from_user_agent(ctx.user, &condition.field, ctx);
319                temp_value.as_ref()
320            }
321        },
322        "ip_based" => match ctx.user.get_user_value(&condition.field) {
323            Some(value) => Some(value),
324            None => {
325                temp_value = CountryLookup::get_value_from_ip(ctx.user, &condition.field, ctx);
326                temp_value.as_ref()
327            }
328        },
329        "user_field" => ctx.user.get_user_value(&condition.field),
330        "environment_field" => {
331            temp_value = ctx.user.get_value_from_environment(&condition.field);
332            temp_value.as_ref()
333        }
334        "current_time" => {
335            temp_value = Some(DynamicValue::for_timestamp_evaluation(
336                Utc::now().timestamp_millis(),
337            ));
338            temp_value.as_ref()
339        }
340        "user_bucket" => {
341            temp_value = Some(get_hash_for_user_bucket(ctx, condition));
342            temp_value.as_ref()
343        }
344        "target_app" => ctx.app_id,
345        "unit_id" => ctx.user.get_unit_id(&condition.id_type),
346        _ => {
347            ctx.result.unsupported = true;
348            return Ok(());
349        }
350    }
351    .unwrap_or(&EMPTY_DYNAMIC_VALUE);
352
353    // println!("Eval Condition {}, {:?}", condition_type, value);
354
355    let operator = match &condition.operator {
356        Some(operator) => operator,
357        None => {
358            ctx.result.unsupported = true;
359            return Ok(());
360        }
361    };
362
363    ctx.result.bool_value = match operator as &str {
364        // numerical comparisons
365        "gt" | "gte" | "lt" | "lte" => compare_numbers(value, target_value, operator),
366
367        // version comparisons
368        "version_gt" | "version_gte" | "version_lt" | "version_lte" | "version_eq"
369        | "version_neq" => compare_versions(value, target_value, operator),
370
371        // string/array comparisons
372        "any"
373        | "none"
374        | "str_starts_with_any"
375        | "str_ends_with_any"
376        | "str_contains_any"
377        | "str_contains_none" => compare_strings_in_array(value, target_value, operator, true),
378        "any_case_sensitive" | "none_case_sensitive" => {
379            compare_strings_in_array(value, target_value, operator, false)
380        }
381        "str_matches" => compare_str_with_regex(value, target_value),
382
383        // time comparisons
384        "before" | "after" | "on" => compare_time(value, target_value, operator),
385
386        // strict equals
387        "eq" => target_value.is_equal_to_dynamic_value(value),
388        "neq" => !target_value.is_equal_to_dynamic_value(value),
389
390        // id_lists
391        "in_segment_list" | "not_in_segment_list" => {
392            evaluate_id_list(ctx, operator, target_value, value)
393        }
394
395        "array_contains_any"
396        | "array_contains_none"
397        | "array_contains_all"
398        | "not_array_contains_all" => compare_arrays(value, target_value, operator),
399
400        _ => {
401            ctx.result.unsupported = true;
402            return Ok(());
403        }
404    };
405
406    Ok(())
407}
408
409fn evaluate_id_list(
410    ctx: &mut EvaluatorContext<'_>,
411    op: &str,
412    target_value: &EvaluatorValue,
413    value: &DynamicValue,
414) -> bool {
415    let list_name = unwrap_or_return!(&target_value.string_value, false);
416    let id_lists = &ctx.spec_store_data.id_lists;
417
418    let list = unwrap_or_return!(id_lists.get(&list_name.value), false);
419
420    let dyn_str = unwrap_or_return!(&value.string_value, false);
421    let hashed = ctx.hashing.sha256(&dyn_str.value);
422    let lookup_id: String = hashed.chars().take(8).collect();
423
424    let is_in_list = list.ids.contains(&lookup_id);
425
426    if op == "not_in_segment_list" {
427        return !is_in_list;
428    }
429
430    is_in_list
431}
432
433fn evaluate_nested_gate<'a>(
434    ctx: &mut EvaluatorContext<'a>,
435    target_value: &'a EvaluatorValue,
436    condition_type: &'a String,
437) -> Result<(), StatsigErr> {
438    let gate_name = if let Some(name) = target_value.string_value.as_ref() {
439        &name.value
440    } else {
441        log_e!(
442            TAG,
443            "Invalid target_value for condition {}, {:?}",
444            condition_type,
445            target_value
446        );
447        ctx.result.unsupported = true;
448        return Ok(());
449    };
450
451    match ctx.nested_gate_memo.get(gate_name.as_str()) {
452        Some((previous_bool, previous_rule_id)) => {
453            ctx.result.bool_value = *previous_bool;
454            ctx.result.rule_id = *previous_rule_id;
455        }
456        None => {
457            ctx.prep_for_nested_evaluation()?;
458            let _ = Evaluator::evaluate(ctx, gate_name, &SpecType::Gate)?;
459
460            if ctx.result.unsupported {
461                return Ok(());
462            }
463
464            ctx.nested_gate_memo
465                .insert(gate_name, (ctx.result.bool_value, ctx.result.rule_id));
466        }
467    }
468
469    if !&gate_name.starts_with("segment:") {
470        let res = &ctx.result;
471        let expo = SecondaryExposure {
472            gate: gate_name.clone(),
473            gate_value: res.bool_value.to_string(),
474            rule_id: res.rule_id.unwrap_or(&EMPTY_STR).clone(),
475        };
476
477        if res.sampling_rate.is_none() {
478            ctx.result.has_seen_analytical_gates = Option::from(true);
479        }
480
481        ctx.result.secondary_exposures.push(expo);
482    }
483
484    if condition_type == "fail_gate" {
485        ctx.result.bool_value = !ctx.result.bool_value;
486    }
487    Ok(())
488}
489
490fn evaluate_config_delegate<'a>(
491    ctx: &mut EvaluatorContext<'a>,
492    rule: &'a Rule,
493) -> Result<bool, StatsigErr> {
494    let delegate = unwrap_or_return!(&rule.config_delegate, Ok(false));
495    let delegate_spec = unwrap_or_return!(
496        ctx.spec_store_data.values.dynamic_configs.get(delegate),
497        Ok(false)
498    );
499
500    ctx.result.undelegated_secondary_exposures = Some(ctx.result.secondary_exposures.clone());
501
502    ctx.prep_for_nested_evaluation()?;
503    let recognition = Evaluator::evaluate(ctx, delegate, &SpecType::Experiment)?;
504    if recognition == Recognition::Unrecognized {
505        ctx.result.undelegated_secondary_exposures = None;
506        return Ok(false);
507    }
508
509    ctx.result.explicit_parameters = delegate_spec.explicit_parameters.as_ref();
510    ctx.result.config_delegate = rule.config_delegate.as_ref();
511
512    Ok(true)
513}
514
515fn evaluate_pass_percentage(ctx: &mut EvaluatorContext, rule: &Rule, spec_salt: &String) -> bool {
516    if rule.pass_percentage == 100f64 {
517        return true;
518    }
519
520    if rule.pass_percentage == 0f64 {
521        return false;
522    }
523
524    let rule_salt = rule.salt.as_ref().unwrap_or(&rule.id);
525    let unit_id = get_unit_id(ctx, &rule.id_type);
526    let input = format!("{spec_salt}.{rule_salt}.{unit_id}");
527    match ctx.hashing.evaluation_hash(&input) {
528        Some(hash) => ((hash % 10000) as f64) < rule.pass_percentage * 100.0,
529        None => false,
530    }
531}
532
533fn get_hash_for_user_bucket(ctx: &mut EvaluatorContext, condition: &Condition) -> DynamicValue {
534    let unit_id = get_unit_id(ctx, &condition.id_type);
535
536    let mut salt: &String = &EMPTY_STR;
537
538    if let Some(add_values) = &condition.additional_values {
539        if let Some(v) = add_values.get("salt") {
540            salt = v;
541        }
542    }
543
544    let input = format!("{salt}.{unit_id}");
545    let hash = ctx.hashing.evaluation_hash(&input).unwrap_or(1);
546    dyn_value!(hash % 1000)
547}