LCOV - code coverage report
Current view: top level - libs/posthog_client_lite/src - lib.rs (source / functions) Coverage Total Hit
Test: 2ff680a820af2e5030dd8e14ace9c8cb73b50f66.info Lines: 62.9 % 380 239
Test Date: 2025-05-27 12:46:00 Functions: 29.6 % 54 16

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

Generated by: LCOV version 2.1-beta