LCOV - code coverage report
Current view: top level - libs/posthog_client_lite/src - lib.rs (source / functions) Coverage Total Hit
Test: 472031e0b71f3195f7f21b1f2b20de09fd07bb56.info Lines: 61.8 % 387 239
Test Date: 2025-05-26 10:37:33 Functions: 30.2 % 53 16

            Line data    Source code
       1              : //! A lite version of the PostHog client that only supports local evaluation of feature flags.
       2              : 
       3              : use std::collections::HashMap;
       4              : 
       5              : use serde::{Deserialize, Serialize};
       6              : use serde_json::json;
       7              : use sha2::Digest;
       8              : 
       9              : #[derive(Debug, thiserror::Error)]
      10              : pub enum PostHogEvaluationError {
      11              :     /// The feature flag is not available, for example, because the local evaluation data is not populated yet.
      12              :     #[error("Feature flag not available: {0}")]
      13              :     NotAvailable(String),
      14              :     #[error("No condition group is matched")]
      15              :     NoConditionGroupMatched,
      16              :     /// Real errors, e.g., the rollout percentage does not add up to 100.
      17              :     #[error("Failed to evaluate feature flag: {0}")]
      18              :     Internal(String),
      19              : }
      20              : 
      21            6 : #[derive(Deserialize)]
      22              : pub struct LocalEvaluationResponse {
      23              :     #[allow(dead_code)]
      24              :     flags: Vec<LocalEvaluationFlag>,
      25              : }
      26              : 
      27           20 : #[derive(Deserialize)]
      28              : pub struct LocalEvaluationFlag {
      29              :     key: String,
      30              :     filters: LocalEvaluationFlagFilters,
      31              :     active: bool,
      32              : }
      33              : 
      34            6 : #[derive(Deserialize)]
      35              : pub struct LocalEvaluationFlagFilters {
      36              :     groups: Vec<LocalEvaluationFlagFilterGroup>,
      37              :     multivariate: LocalEvaluationFlagMultivariate,
      38              : }
      39              : 
      40           10 : #[derive(Deserialize)]
      41              : pub struct LocalEvaluationFlagFilterGroup {
      42              :     variant: Option<String>,
      43              :     properties: Option<Vec<LocalEvaluationFlagFilterProperty>>,
      44              :     rollout_percentage: i64,
      45              : }
      46              : 
      47           32 : #[derive(Deserialize)]
      48              : pub struct LocalEvaluationFlagFilterProperty {
      49              :     key: String,
      50              :     value: PostHogFlagFilterPropertyValue,
      51              :     operator: String,
      52              : }
      53              : 
      54              : #[derive(Debug, Serialize, Deserialize)]
      55              : #[serde(untagged)]
      56              : pub enum PostHogFlagFilterPropertyValue {
      57              :     String(String),
      58              :     Number(f64),
      59              :     Boolean(bool),
      60              :     List(Vec<String>),
      61              : }
      62              : 
      63            2 : #[derive(Deserialize)]
      64              : pub struct LocalEvaluationFlagMultivariate {
      65              :     variants: Vec<LocalEvaluationFlagMultivariateVariant>,
      66              : }
      67              : 
      68           30 : #[derive(Deserialize)]
      69              : pub struct LocalEvaluationFlagMultivariateVariant {
      70              :     key: String,
      71              :     rollout_percentage: i64,
      72              : }
      73              : 
      74              : pub struct FeatureStore {
      75              :     flags: HashMap<String, LocalEvaluationFlag>,
      76              : }
      77              : 
      78              : impl Default for FeatureStore {
      79            0 :     fn default() -> Self {
      80            0 :         Self::new()
      81            0 :     }
      82              : }
      83              : 
      84              : enum GroupEvaluationResult {
      85              :     MatchedAndOverride(String),
      86              :     MatchedAndEvaluate,
      87              :     Unmatched,
      88              : }
      89              : 
      90              : impl FeatureStore {
      91            1 :     pub fn new() -> Self {
      92            1 :         Self {
      93            1 :             flags: HashMap::new(),
      94            1 :         }
      95            1 :     }
      96              : 
      97            1 :     pub fn set_flags(&mut self, flags: Vec<LocalEvaluationFlag>) {
      98            1 :         self.flags.clear();
      99            2 :         for flag in flags {
     100            1 :             self.flags.insert(flag.key.clone(), flag);
     101            1 :         }
     102            1 :     }
     103              : 
     104              :     /// Generate a consistent hash for a user ID (e.g., tenant ID).
     105              :     ///
     106              :     /// The implementation is different from PostHog SDK. In PostHog SDK, it is sha1 of `user_id.distinct_id.salt`.
     107              :     /// However, as we do not upload all of our tenant IDs to PostHog, we do not have the PostHog distinct_id for a
     108              :     /// tenant. Therefore, the way we compute it is sha256 of `user_id.feature_id.salt`.
     109            0 :     fn consistent_hash(user_id: &str, flag_key: &str, salt: &str) -> f64 {
     110            0 :         let mut hasher = sha2::Sha256::new();
     111            0 :         hasher.update(user_id);
     112            0 :         hasher.update(".");
     113            0 :         hasher.update(flag_key);
     114            0 :         hasher.update(".");
     115            0 :         hasher.update(salt);
     116            0 :         let hash = hasher.finalize();
     117            0 :         let hash_int = u64::from_le_bytes(hash[..8].try_into().unwrap());
     118            0 :         hash_int as f64 / u64::MAX as f64
     119            0 :     }
     120              : 
     121              :     /// Evaluate a condition. Returns an error if the condition cannot be evaluated due to parsing error or missing
     122              :     /// property.
     123           18 :     fn evaluate_condition(
     124           18 :         &self,
     125           18 :         operator: &str,
     126           18 :         provided: &PostHogFlagFilterPropertyValue,
     127           18 :         requested: &PostHogFlagFilterPropertyValue,
     128           18 :     ) -> Result<bool, PostHogEvaluationError> {
     129           18 :         match operator {
     130           18 :             "exact" => {
     131           11 :                 let PostHogFlagFilterPropertyValue::String(provided) = provided else {
     132              :                     // Left should be a string
     133            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     134            0 :                         "The left side of the condition is not a string: {:?}",
     135            0 :                         provided
     136            0 :                     )));
     137              :                 };
     138           11 :                 let PostHogFlagFilterPropertyValue::List(requested) = requested else {
     139              :                     // Right should be a list of string
     140            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     141            0 :                         "The right side of the condition is not a list: {:?}",
     142            0 :                         requested
     143            0 :                     )));
     144              :                 };
     145           11 :                 Ok(requested.contains(provided))
     146              :             }
     147            7 :             "lt" | "gt" => {
     148            7 :                 let PostHogFlagFilterPropertyValue::String(requested) = requested else {
     149              :                     // Right should be a string
     150            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     151            0 :                         "The right side of the condition is not a string: {:?}",
     152            0 :                         requested
     153            0 :                     )));
     154              :                 };
     155            7 :                 let Ok(requested) = requested.parse::<f64>() else {
     156            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     157            0 :                         "Can not parse the right side of the condition as a number: {:?}",
     158            0 :                         requested
     159            0 :                     )));
     160              :                 };
     161              :                 // Left can either be a number or a string
     162            7 :                 let provided = match provided {
     163            7 :                     PostHogFlagFilterPropertyValue::Number(provided) => *provided,
     164            0 :                     PostHogFlagFilterPropertyValue::String(provided) => {
     165            0 :                         let Ok(provided) = provided.parse::<f64>() else {
     166            0 :                             return Err(PostHogEvaluationError::Internal(format!(
     167            0 :                                 "Can not parse the left side of the condition as a number: {:?}",
     168            0 :                                 provided
     169            0 :                             )));
     170              :                         };
     171            0 :                         provided
     172              :                     }
     173              :                     _ => {
     174            0 :                         return Err(PostHogEvaluationError::Internal(format!(
     175            0 :                             "The left side of the condition is not a number or a string: {:?}",
     176            0 :                             provided
     177            0 :                         )));
     178              :                     }
     179              :                 };
     180            7 :                 match operator {
     181            7 :                     "lt" => Ok(provided < requested),
     182            0 :                     "gt" => Ok(provided > requested),
     183            0 :                     op => Err(PostHogEvaluationError::Internal(format!(
     184            0 :                         "Unsupported operator: {}",
     185            0 :                         op
     186            0 :                     ))),
     187              :                 }
     188              :             }
     189            0 :             _ => Err(PostHogEvaluationError::Internal(format!(
     190            0 :                 "Unsupported operator: {}",
     191            0 :                 operator
     192            0 :             ))),
     193              :         }
     194           18 :     }
     195              : 
     196              :     /// Evaluate a percentage.
     197           10 :     fn evaluate_percentage(&self, mapped_user_id: f64, percentage: i64) -> bool {
     198           10 :         mapped_user_id <= percentage as f64 / 100.0
     199           10 :     }
     200              : 
     201              :     /// Evaluate a filter group for a feature flag. Returns an error if there are errors during the evaluation.
     202              :     ///
     203              :     /// Return values:
     204              :     /// Ok(GroupEvaluationResult::MatchedAndOverride(variant)): matched and evaluated to this value
     205              :     /// Ok(GroupEvaluationResult::MatchedAndEvaluate): condition matched but no variant override, use the global rollout percentage
     206              :     /// Ok(GroupEvaluationResult::Unmatched): condition unmatched
     207           12 :     fn evaluate_group(
     208           12 :         &self,
     209           12 :         group: &LocalEvaluationFlagFilterGroup,
     210           12 :         hash_on_group_rollout_percentage: f64,
     211           12 :         provided_properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     212           12 :     ) -> Result<GroupEvaluationResult, PostHogEvaluationError> {
     213           12 :         if let Some(ref properties) = group.properties {
     214           26 :             for property in properties {
     215           19 :                 if let Some(value) = provided_properties.get(&property.key) {
     216              :                     // The user provided the property value
     217           18 :                     if !self.evaluate_condition(
     218           18 :                         property.operator.as_ref(),
     219           18 :                         value,
     220           18 :                         &property.value,
     221           18 :                     )? {
     222            4 :                         return Ok(GroupEvaluationResult::Unmatched);
     223           14 :                     }
     224              :                 } else {
     225              :                     // We cannot evaluate, the property is not available
     226            1 :                     return Err(PostHogEvaluationError::NotAvailable(format!(
     227            1 :                         "The required property in the condition is not available: {}",
     228            1 :                         property.key
     229            1 :                     )));
     230              :                 }
     231              :             }
     232            0 :         }
     233              : 
     234              :         // The group has no condition matchers or we matched the properties
     235            7 :         if self.evaluate_percentage(hash_on_group_rollout_percentage, group.rollout_percentage) {
     236            3 :             if let Some(ref variant_override) = group.variant {
     237            1 :                 Ok(GroupEvaluationResult::MatchedAndOverride(
     238            1 :                     variant_override.clone(),
     239            1 :                 ))
     240              :             } else {
     241            2 :                 Ok(GroupEvaluationResult::MatchedAndEvaluate)
     242              :             }
     243              :         } else {
     244            4 :             Ok(GroupEvaluationResult::Unmatched)
     245              :         }
     246           12 :     }
     247              : 
     248              :     /// Evaluate a multivariate feature flag. Returns `None` if the flag is not available or if there are errors
     249              :     /// during the evaluation.
     250              :     ///
     251              :     /// The parsing logic is as follows:
     252              :     ///
     253              :     /// * Match each filter group.
     254              :     ///   - If a group is matched, it will first determine whether the user is in the range of the group's rollout
     255              :     ///     percentage. We will generate a consistent hash for the user ID on the group rollout percentage. This hash
     256              :     ///     is shared across all groups.
     257              :     ///   - If the hash falls within the group's rollout percentage, return the variant if it's overridden, or
     258              :     ///   - Evaluate the variant using the global config and the global rollout percentage.
     259              :     /// * Otherwise, continue with the next group until all groups are evaluated and no group is within the
     260              :     ///   rollout percentage.
     261              :     /// * If there are no matching groups, return an error.
     262              :     ///
     263              :     /// Example: we have a multivariate flag with 3 groups of the configured global rollout percentage: A (10%), B (20%), C (70%).
     264              :     /// There is a single group with a condition that has a rollout percentage of 10% and it does not have a variant override.
     265              :     /// Then, we will have 1% of the users evaluated to A, 2% to B, and 7% to C.
     266            0 :     pub fn evaluate_multivariate(
     267            0 :         &self,
     268            0 :         flag_key: &str,
     269            0 :         user_id: &str,
     270            0 :     ) -> Result<String, PostHogEvaluationError> {
     271            0 :         let hash_on_global_rollout_percentage =
     272            0 :             Self::consistent_hash(user_id, flag_key, "multivariate");
     273            0 :         let hash_on_group_rollout_percentage =
     274            0 :             Self::consistent_hash(user_id, flag_key, "within_group");
     275            0 :         self.evaluate_multivariate_inner(
     276            0 :             flag_key,
     277            0 :             hash_on_global_rollout_percentage,
     278            0 :             hash_on_group_rollout_percentage,
     279            0 :             &HashMap::new(),
     280            0 :         )
     281            0 :     }
     282              : 
     283              :     /// Evaluate a multivariate feature flag. Note that we directly take the mapped user ID
     284              :     /// (a consistent hash ranging from 0 to 1) so that it is easier to use it in the tests
     285              :     /// and avoid duplicate computations.
     286              :     ///
     287              :     /// Use a different consistent hash for evaluating the group rollout percentage.
     288              :     /// The behavior: if the condition is set to rolling out to 10% of the users, and
     289              :     /// we set the variant A to 20% in the global config, then 2% of the total users will
     290              :     /// be evaluated to variant A.
     291              :     ///
     292              :     /// Note that the hash to determine group rollout percentage is shared across all groups. So if we have two
     293              :     /// exactly-the-same conditions with 10% and 20% rollout percentage respectively, a total of 20% of the users
     294              :     /// will be evaluated (versus 30% if group evaluation is done independently).
     295            7 :     pub(crate) fn evaluate_multivariate_inner(
     296            7 :         &self,
     297            7 :         flag_key: &str,
     298            7 :         hash_on_global_rollout_percentage: f64,
     299            7 :         hash_on_group_rollout_percentage: f64,
     300            7 :         properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     301            7 :     ) -> Result<String, PostHogEvaluationError> {
     302            7 :         if let Some(flag_config) = self.flags.get(flag_key) {
     303            7 :             if !flag_config.active {
     304            0 :                 return Err(PostHogEvaluationError::NotAvailable(format!(
     305            0 :                     "The feature flag is not active: {}",
     306            0 :                     flag_key
     307            0 :                 )));
     308            7 :             }
     309              :             // TODO: sort the groups so that variant overrides always get evaluated first and it follows the PostHog
     310              :             // Python SDK behavior; for now we do not configure conditions without variant overrides in Neon so it
     311              :             // does not matter.
     312           15 :             for group in &flag_config.filters.groups {
     313           12 :                 match self.evaluate_group(group, hash_on_group_rollout_percentage, properties)? {
     314            1 :                     GroupEvaluationResult::MatchedAndOverride(variant) => return Ok(variant),
     315              :                     GroupEvaluationResult::MatchedAndEvaluate => {
     316            2 :                         let mut percentage = 0;
     317            3 :                         for variant in &flag_config.filters.multivariate.variants {
     318            3 :                             percentage += variant.rollout_percentage;
     319            3 :                             if self
     320            3 :                                 .evaluate_percentage(hash_on_global_rollout_percentage, percentage)
     321              :                             {
     322            2 :                                 return Ok(variant.key.clone());
     323            1 :                             }
     324              :                         }
     325              :                         // This should not happen because the rollout percentage always adds up to 100, but just in case that PostHog
     326              :                         // returned invalid spec, we return an error.
     327            0 :                         return Err(PostHogEvaluationError::Internal(format!(
     328            0 :                             "Rollout percentage does not add up to 100: {}",
     329            0 :                             flag_key
     330            0 :                         )));
     331              :                     }
     332            8 :                     GroupEvaluationResult::Unmatched => continue,
     333              :                 }
     334              :             }
     335              :             // If no group is matched, the feature is not available, and up to the caller to decide what to do.
     336            3 :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     337              :         } else {
     338              :             // The feature flag is not available yet
     339            0 :             Err(PostHogEvaluationError::NotAvailable(format!(
     340            0 :                 "Not found in the local evaluation spec: {}",
     341            0 :                 flag_key
     342            0 :             )))
     343              :         }
     344            7 :     }
     345              : }
     346              : 
     347              : /// A lite PostHog client.
     348              : ///
     349              : /// At the point of writing this code, PostHog does not have a functional Rust client with feature flag support.
     350              : /// This is a lite version that only supports local evaluation of feature flags and only supports those JSON specs
     351              : /// that will be used within Neon.
     352              : ///
     353              : /// PostHog is designed as a browser-server system: the browser (client) side uses the client key and is exposed
     354              : /// to the end users; the server side uses a server key and is not exposed to the end users. The client and the
     355              : /// server has different API keys and provide a different set of APIs. In Neon, we only have the server (that is
     356              : /// pageserver), and it will use both the client API and the server API. So we need to store two API keys within
     357              : /// our PostHog client.
     358              : ///
     359              : /// The server API is used to fetch the feature flag specs. The client API is used to capture events in case we
     360              : /// want to report the feature flag usage back to PostHog. The current plan is to use PostHog only as an UI to
     361              : /// configure feature flags so it is very likely that the client API will not be used.
     362              : pub struct PostHogClient {
     363              :     /// The server API key.
     364              :     server_api_key: String,
     365              :     /// The client API key.
     366              :     client_api_key: String,
     367              :     /// The project ID.
     368              :     project_id: String,
     369              :     /// The private API URL.
     370              :     private_api_url: String,
     371              :     /// The public API URL.
     372              :     public_api_url: String,
     373              :     /// The HTTP client.
     374              :     client: reqwest::Client,
     375              : }
     376              : 
     377              : impl PostHogClient {
     378            0 :     pub fn new(
     379            0 :         server_api_key: String,
     380            0 :         client_api_key: String,
     381            0 :         project_id: String,
     382            0 :         private_api_url: String,
     383            0 :         public_api_url: String,
     384            0 :     ) -> Self {
     385            0 :         let client = reqwest::Client::new();
     386            0 :         Self {
     387            0 :             server_api_key,
     388            0 :             client_api_key,
     389            0 :             project_id,
     390            0 :             private_api_url,
     391            0 :             public_api_url,
     392            0 :             client,
     393            0 :         }
     394            0 :     }
     395              : 
     396            0 :     pub fn new_with_us_region(
     397            0 :         server_api_key: String,
     398            0 :         client_api_key: String,
     399            0 :         project_id: String,
     400            0 :     ) -> Self {
     401            0 :         Self::new(
     402            0 :             server_api_key,
     403            0 :             client_api_key,
     404            0 :             project_id,
     405            0 :             "https://us.posthog.com".to_string(),
     406            0 :             "https://us.i.posthog.com".to_string(),
     407            0 :         )
     408            0 :     }
     409              : 
     410              :     /// Fetch the feature flag specs from the server.
     411              :     ///
     412              :     /// This is unfortunately an undocumented API at:
     413              :     /// - <https://posthog.com/docs/api/feature-flags#get-api-projects-project_id-feature_flags-local_evaluation>
     414              :     /// - <https://posthog.com/docs/feature-flags/local-evaluation>
     415              :     ///
     416              :     /// The handling logic in [`FeatureStore`] mostly follows the Python API implementation.
     417              :     /// See `_compute_flag_locally` in <https://github.com/PostHog/posthog-python/blob/master/posthog/client.py>
     418            0 :     pub async fn get_feature_flags_local_evaluation(
     419            0 :         &self,
     420            0 :     ) -> anyhow::Result<LocalEvaluationResponse> {
     421            0 :         // BASE_URL/api/projects/:project_id/feature_flags/local_evaluation
     422            0 :         // with bearer token of self.server_api_key
     423            0 :         let url = format!(
     424            0 :             "{}/api/projects/{}/feature_flags/local_evaluation",
     425            0 :             self.private_api_url, self.project_id
     426            0 :         );
     427            0 :         let response = self
     428            0 :             .client
     429            0 :             .get(url)
     430            0 :             .bearer_auth(&self.server_api_key)
     431            0 :             .send()
     432            0 :             .await?;
     433            0 :         let body = response.text().await?;
     434            0 :         Ok(serde_json::from_str(&body)?)
     435            0 :     }
     436              : 
     437              :     /// Capture an event. This will only be used to report the feature flag usage back to PostHog, though
     438              :     /// it also support a lot of other functionalities.
     439              :     ///
     440              :     /// <https://posthog.com/docs/api/capture>
     441            0 :     pub async fn capture_event(
     442            0 :         &self,
     443            0 :         event: &str,
     444            0 :         distinct_id: &str,
     445            0 :         properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     446            0 :     ) -> anyhow::Result<()> {
     447            0 :         // PUBLIC_URL/capture/
     448            0 :         // with bearer token of self.client_api_key
     449            0 :         let url = format!("{}/capture/", self.public_api_url);
     450            0 :         self.client
     451            0 :             .post(url)
     452            0 :             .body(serde_json::to_string(&json!({
     453            0 :                 "api_key": self.client_api_key,
     454            0 :                 "distinct_id": distinct_id,
     455            0 :                 "event": event,
     456            0 :                 "properties": properties,
     457            0 :             }))?)
     458            0 :             .send()
     459            0 :             .await?;
     460            0 :         Ok(())
     461            0 :     }
     462              : }
     463              : 
     464              : #[cfg(test)]
     465              : mod tests {
     466              :     use super::*;
     467              : 
     468            2 :     fn data() -> &'static str {
     469            2 :         r#"{
     470            2 :             "flags": [
     471            2 :                 {
     472            2 :                     "id": 132794,
     473            2 :                     "team_id": 152860,
     474            2 :                     "name": "",
     475            2 :                     "key": "gc-compaction",
     476            2 :                     "filters": {
     477            2 :                         "groups": [
     478            2 :                             {
     479            2 :                                 "variant": "enabled-stage-2",
     480            2 :                                 "properties": [
     481            2 :                                     {
     482            2 :                                         "key": "plan_type",
     483            2 :                                         "type": "person",
     484            2 :                                         "value": [
     485            2 :                                             "free"
     486            2 :                                         ],
     487            2 :                                         "operator": "exact"
     488            2 :                                     },
     489            2 :                                     {
     490            2 :                                         "key": "pageserver_remote_size",
     491            2 :                                         "type": "person",
     492            2 :                                         "value": "10000000",
     493            2 :                                         "operator": "lt"
     494            2 :                                     }
     495            2 :                                 ],
     496            2 :                                 "rollout_percentage": 50
     497            2 :                             },
     498            2 :                             {
     499            2 :                                 "properties": [
     500            2 :                                     {
     501            2 :                                         "key": "plan_type",
     502            2 :                                         "type": "person",
     503            2 :                                         "value": [
     504            2 :                                             "free"
     505            2 :                                         ],
     506            2 :                                         "operator": "exact"
     507            2 :                                     },
     508            2 :                                     {
     509            2 :                                         "key": "pageserver_remote_size",
     510            2 :                                         "type": "person",
     511            2 :                                         "value": "10000000",
     512            2 :                                         "operator": "lt"
     513            2 :                                     }
     514            2 :                                 ],
     515            2 :                                 "rollout_percentage": 80
     516            2 :                             }
     517            2 :                         ],
     518            2 :                         "payloads": {},
     519            2 :                         "multivariate": {
     520            2 :                             "variants": [
     521            2 :                                 {
     522            2 :                                     "key": "disabled",
     523            2 :                                     "name": "",
     524            2 :                                     "rollout_percentage": 90
     525            2 :                                 },
     526            2 :                                 {
     527            2 :                                     "key": "enabled-stage-1",
     528            2 :                                     "name": "",
     529            2 :                                     "rollout_percentage": 10
     530            2 :                                 },
     531            2 :                                 {
     532            2 :                                     "key": "enabled-stage-2",
     533            2 :                                     "name": "",
     534            2 :                                     "rollout_percentage": 0
     535            2 :                                 },
     536            2 :                                 {
     537            2 :                                     "key": "enabled-stage-3",
     538            2 :                                     "name": "",
     539            2 :                                     "rollout_percentage": 0
     540            2 :                                 },
     541            2 :                                 {
     542            2 :                                     "key": "enabled",
     543            2 :                                     "name": "",
     544            2 :                                     "rollout_percentage": 0
     545            2 :                                 }
     546            2 :                             ]
     547            2 :                         }
     548            2 :                     },
     549            2 :                     "deleted": false,
     550            2 :                     "active": true,
     551            2 :                     "ensure_experience_continuity": false,
     552            2 :                     "has_encrypted_payloads": false,
     553            2 :                     "version": 6
     554            2 :                 }
     555            2 :             ],
     556            2 :             "group_type_mapping": {},
     557            2 :             "cohorts": {}
     558            2 :         }"#
     559            2 :     }
     560              : 
     561              :     #[test]
     562            1 :     fn parse_local_evaluation() {
     563            1 :         let data = data();
     564            1 :         let _: LocalEvaluationResponse = serde_json::from_str(data).unwrap();
     565            1 :     }
     566              : 
     567              :     #[test]
     568            1 :     fn evaluate_multivariate() {
     569            1 :         let mut store = FeatureStore::new();
     570            1 :         let response: LocalEvaluationResponse = serde_json::from_str(data()).unwrap();
     571            1 :         store.set_flags(response.flags);
     572            1 : 
     573            1 :         // This lacks the required properties and cannot be evaluated.
     574            1 :         let variant =
     575            1 :             store.evaluate_multivariate_inner("gc-compaction", 1.00, 0.40, &HashMap::new());
     576            1 :         assert!(matches!(
     577            1 :             variant,
     578              :             Err(PostHogEvaluationError::NotAvailable(_))
     579              :         ),);
     580              : 
     581            1 :         let properties_unmatched = HashMap::from([
     582            1 :             (
     583            1 :                 "plan_type".to_string(),
     584            1 :                 PostHogFlagFilterPropertyValue::String("paid".to_string()),
     585            1 :             ),
     586            1 :             (
     587            1 :                 "pageserver_remote_size".to_string(),
     588            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     589            1 :             ),
     590            1 :         ]);
     591            1 : 
     592            1 :         // This does not match any group so there will be an error.
     593            1 :         let variant =
     594            1 :             store.evaluate_multivariate_inner("gc-compaction", 1.00, 0.40, &properties_unmatched);
     595            1 :         assert!(matches!(
     596            1 :             variant,
     597              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     598              :         ),);
     599            1 :         let variant =
     600            1 :             store.evaluate_multivariate_inner("gc-compaction", 0.80, 0.80, &properties_unmatched);
     601            1 :         assert!(matches!(
     602            1 :             variant,
     603              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     604              :         ),);
     605              : 
     606            1 :         let properties = HashMap::from([
     607            1 :             (
     608            1 :                 "plan_type".to_string(),
     609            1 :                 PostHogFlagFilterPropertyValue::String("free".to_string()),
     610            1 :             ),
     611            1 :             (
     612            1 :                 "pageserver_remote_size".to_string(),
     613            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     614            1 :             ),
     615            1 :         ]);
     616            1 : 
     617            1 :         // It matches the first group as 0.10 <= 0.50 and the properties are matched. Then it gets evaluated to the variant override.
     618            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 0.10, 0.10, &properties);
     619            1 :         assert_eq!(variant.unwrap(), "enabled-stage-2".to_string());
     620              : 
     621              :         // It matches the second group as 0.50 <= 0.60 <= 0.80 and the properties are matched. Then it gets evaluated using the global percentage.
     622            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 0.99, 0.60, &properties);
     623            1 :         assert_eq!(variant.unwrap(), "enabled-stage-1".to_string());
     624            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 0.80, 0.60, &properties);
     625            1 :         assert_eq!(variant.unwrap(), "disabled".to_string());
     626              : 
     627              :         // It matches the group conditions but not the group rollout percentage.
     628            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 1.00, 0.90, &properties);
     629            1 :         assert!(matches!(
     630            1 :             variant,
     631              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     632              :         ),);
     633            1 :     }
     634              : }
        

Generated by: LCOV version 2.1-beta