LCOV - code coverage report
Current view: top level - libs/posthog_client_lite/src - lib.rs (source / functions) Coverage Total Hit
Test: 553e39c2773e5840c720c90d86e56f89a4330d43.info Lines: 64.3 % 661 425
Test Date: 2025-06-13 20:01:21 Functions: 30.2 % 63 19

            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              : impl PostHogEvaluationError {
      26            0 :     pub fn as_variant_str(&self) -> &'static str {
      27            0 :         match self {
      28            0 :             PostHogEvaluationError::NotAvailable(_) => "not_available",
      29            0 :             PostHogEvaluationError::NoConditionGroupMatched => "no_condition_group_matched",
      30            0 :             PostHogEvaluationError::Internal(_) => "internal",
      31              :         }
      32            0 :     }
      33              : }
      34              : 
      35           12 : #[derive(Deserialize)]
      36              : pub struct LocalEvaluationResponse {
      37              :     pub flags: Vec<LocalEvaluationFlag>,
      38              : }
      39              : 
      40          120 : #[derive(Deserialize)]
      41              : pub struct LocalEvaluationFlag {
      42              :     #[allow(dead_code)]
      43              :     id: u64,
      44              :     team_id: u64,
      45              :     key: String,
      46              :     filters: LocalEvaluationFlagFilters,
      47              :     active: bool,
      48              : }
      49              : 
      50           36 : #[derive(Deserialize)]
      51              : pub struct LocalEvaluationFlagFilters {
      52              :     groups: Vec<LocalEvaluationFlagFilterGroup>,
      53              :     multivariate: Option<LocalEvaluationFlagMultivariate>,
      54              : }
      55              : 
      56           56 : #[derive(Deserialize)]
      57              : pub struct LocalEvaluationFlagFilterGroup {
      58              :     variant: Option<String>,
      59              :     properties: Option<Vec<LocalEvaluationFlagFilterProperty>>,
      60              :     rollout_percentage: i64,
      61              : }
      62              : 
      63           96 : #[derive(Deserialize)]
      64              : pub struct LocalEvaluationFlagFilterProperty {
      65              :     key: String,
      66              :     value: PostHogFlagFilterPropertyValue,
      67              :     operator: String,
      68              : }
      69              : 
      70              : #[derive(Debug, Serialize, Deserialize, Clone)]
      71              : #[serde(untagged)]
      72              : pub enum PostHogFlagFilterPropertyValue {
      73              :     String(String),
      74              :     Number(f64),
      75              :     Boolean(bool),
      76              :     List(Vec<String>),
      77              : }
      78              : 
      79            4 : #[derive(Deserialize)]
      80              : pub struct LocalEvaluationFlagMultivariate {
      81              :     variants: Vec<LocalEvaluationFlagMultivariateVariant>,
      82              : }
      83              : 
      84           60 : #[derive(Deserialize)]
      85              : pub struct LocalEvaluationFlagMultivariateVariant {
      86              :     key: String,
      87              :     rollout_percentage: i64,
      88              : }
      89              : 
      90              : pub struct FeatureStore {
      91              :     flags: HashMap<String, LocalEvaluationFlag>,
      92              : }
      93              : 
      94              : impl Default for FeatureStore {
      95            0 :     fn default() -> Self {
      96            0 :         Self::new()
      97            0 :     }
      98              : }
      99              : 
     100              : enum GroupEvaluationResult {
     101              :     MatchedAndOverride(String),
     102              :     MatchedAndEvaluate,
     103              :     Unmatched,
     104              : }
     105              : 
     106              : impl FeatureStore {
     107            3 :     pub fn new() -> Self {
     108            3 :         Self {
     109            3 :             flags: HashMap::new(),
     110            3 :         }
     111            3 :     }
     112              : 
     113            0 :     pub fn new_with_flags(
     114            0 :         flags: Vec<LocalEvaluationFlag>,
     115            0 :         project_id: Option<u64>,
     116            0 :     ) -> Result<Self, &'static str> {
     117            0 :         let mut store = Self::new();
     118            0 :         store.set_flags(flags, project_id)?;
     119            0 :         Ok(store)
     120            0 :     }
     121              : 
     122            3 :     pub fn set_flags(
     123            3 :         &mut self,
     124            3 :         flags: Vec<LocalEvaluationFlag>,
     125            3 :         project_id: Option<u64>,
     126            3 :     ) -> Result<(), &'static str> {
     127            3 :         self.flags.clear();
     128           12 :         for flag in flags {
     129            9 :             if let Some(project_id) = project_id {
     130            0 :                 if flag.team_id != project_id {
     131            0 :                     return Err(
     132            0 :                         "Retrieved a spec with different project id, wrong config? Discarding the feature flags.",
     133            0 :                     );
     134            0 :                 }
     135            9 :             }
     136            9 :             self.flags.insert(flag.key.clone(), flag);
     137              :         }
     138            3 :         Ok(())
     139            3 :     }
     140              : 
     141              :     /// Generate a consistent hash for a user ID (e.g., tenant ID).
     142              :     ///
     143              :     /// The implementation is different from PostHog SDK. In PostHog SDK, it is sha1 of `user_id.distinct_id.salt`.
     144              :     /// However, as we do not upload all of our tenant IDs to PostHog, we do not have the PostHog distinct_id for a
     145              :     /// tenant. Therefore, the way we compute it is sha256 of `user_id.feature_id.salt`.
     146            0 :     fn consistent_hash(user_id: &str, flag_key: &str, salt: &str) -> f64 {
     147            0 :         let mut hasher = sha2::Sha256::new();
     148            0 :         hasher.update(user_id);
     149            0 :         hasher.update(".");
     150            0 :         hasher.update(flag_key);
     151            0 :         hasher.update(".");
     152            0 :         hasher.update(salt);
     153            0 :         let hash = hasher.finalize();
     154            0 :         let hash_int = u64::from_le_bytes(hash[..8].try_into().unwrap());
     155            0 :         hash_int as f64 / u64::MAX as f64
     156            0 :     }
     157              : 
     158              :     /// Evaluate a condition. Returns an error if the condition cannot be evaluated due to parsing error or missing
     159              :     /// property.
     160           26 :     fn evaluate_condition(
     161           26 :         &self,
     162           26 :         operator: &str,
     163           26 :         provided: &PostHogFlagFilterPropertyValue,
     164           26 :         requested: &PostHogFlagFilterPropertyValue,
     165           26 :     ) -> Result<bool, PostHogEvaluationError> {
     166           26 :         match operator {
     167           26 :             "exact" => {
     168           19 :                 let PostHogFlagFilterPropertyValue::String(provided) = provided else {
     169              :                     // Left should be a string
     170            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     171            0 :                         "The left side of the condition is not a string: {:?}",
     172            0 :                         provided
     173            0 :                     )));
     174              :                 };
     175           19 :                 let PostHogFlagFilterPropertyValue::List(requested) = requested else {
     176              :                     // Right should be a list of string
     177            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     178            0 :                         "The right side of the condition is not a list: {:?}",
     179            0 :                         requested
     180            0 :                     )));
     181              :                 };
     182           19 :                 Ok(requested.contains(provided))
     183              :             }
     184            7 :             "lt" | "gt" => {
     185            7 :                 let PostHogFlagFilterPropertyValue::String(requested) = requested else {
     186              :                     // Right should be a string
     187            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     188            0 :                         "The right side of the condition is not a string: {:?}",
     189            0 :                         requested
     190            0 :                     )));
     191              :                 };
     192            7 :                 let Ok(requested) = requested.parse::<f64>() else {
     193            0 :                     return Err(PostHogEvaluationError::Internal(format!(
     194            0 :                         "Can not parse the right side of the condition as a number: {:?}",
     195            0 :                         requested
     196            0 :                     )));
     197              :                 };
     198              :                 // Left can either be a number or a string
     199            7 :                 let provided = match provided {
     200            7 :                     PostHogFlagFilterPropertyValue::Number(provided) => *provided,
     201            0 :                     PostHogFlagFilterPropertyValue::String(provided) => {
     202            0 :                         let Ok(provided) = provided.parse::<f64>() else {
     203            0 :                             return Err(PostHogEvaluationError::Internal(format!(
     204            0 :                                 "Can not parse the left side of the condition as a number: {:?}",
     205            0 :                                 provided
     206            0 :                             )));
     207              :                         };
     208            0 :                         provided
     209              :                     }
     210              :                     _ => {
     211            0 :                         return Err(PostHogEvaluationError::Internal(format!(
     212            0 :                             "The left side of the condition is not a number or a string: {:?}",
     213            0 :                             provided
     214            0 :                         )));
     215              :                     }
     216              :                 };
     217            7 :                 match operator {
     218            7 :                     "lt" => Ok(provided < requested),
     219            0 :                     "gt" => Ok(provided > requested),
     220            0 :                     op => Err(PostHogEvaluationError::Internal(format!(
     221            0 :                         "Unsupported operator: {}",
     222            0 :                         op
     223            0 :                     ))),
     224              :                 }
     225              :             }
     226            0 :             _ => Err(PostHogEvaluationError::Internal(format!(
     227            0 :                 "Unsupported operator: {}",
     228            0 :                 operator
     229            0 :             ))),
     230              :         }
     231           26 :     }
     232              : 
     233              :     /// Evaluate a percentage.
     234           18 :     fn evaluate_percentage(&self, mapped_user_id: f64, percentage: i64) -> bool {
     235           18 :         mapped_user_id <= percentage as f64 / 100.0
     236           18 :     }
     237              : 
     238              :     /// Evaluate a filter group for a feature flag. Returns an error if there are errors during the evaluation.
     239              :     ///
     240              :     /// Return values:
     241              :     /// Ok(GroupEvaluationResult::MatchedAndOverride(variant)): matched and evaluated to this value
     242              :     /// Ok(GroupEvaluationResult::MatchedAndEvaluate): condition matched but no variant override, use the global rollout percentage
     243              :     /// Ok(GroupEvaluationResult::Unmatched): condition unmatched
     244           25 :     fn evaluate_group(
     245           25 :         &self,
     246           25 :         group: &LocalEvaluationFlagFilterGroup,
     247           25 :         hash_on_group_rollout_percentage: f64,
     248           25 :         provided_properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     249           25 :     ) -> Result<GroupEvaluationResult, PostHogEvaluationError> {
     250           25 :         if let Some(ref properties) = group.properties {
     251           44 :             for property in properties {
     252           29 :                 if let Some(value) = provided_properties.get(&property.key) {
     253              :                     // The user provided the property value
     254           26 :                     if !self.evaluate_condition(
     255           26 :                         property.operator.as_ref(),
     256           26 :                         value,
     257           26 :                         &property.value,
     258           26 :                     )? {
     259            7 :                         return Ok(GroupEvaluationResult::Unmatched);
     260           19 :                     }
     261              :                 } else {
     262              :                     // We cannot evaluate, the property is not available
     263            3 :                     return Err(PostHogEvaluationError::NotAvailable(format!(
     264            3 :                         "The required property in the condition is not available: {}",
     265            3 :                         property.key
     266            3 :                     )));
     267              :                 }
     268              :             }
     269            0 :         }
     270              : 
     271              :         // The group has no condition matchers or we matched the properties
     272           15 :         if self.evaluate_percentage(hash_on_group_rollout_percentage, group.rollout_percentage) {
     273            7 :             if let Some(ref variant_override) = group.variant {
     274            1 :                 Ok(GroupEvaluationResult::MatchedAndOverride(
     275            1 :                     variant_override.clone(),
     276            1 :                 ))
     277              :             } else {
     278            6 :                 Ok(GroupEvaluationResult::MatchedAndEvaluate)
     279              :             }
     280              :         } else {
     281            8 :             Ok(GroupEvaluationResult::Unmatched)
     282              :         }
     283           25 :     }
     284              : 
     285              :     /// Evaluate a multivariate feature flag. Returns an error if the flag is not available or if there are errors
     286              :     /// during the evaluation.
     287              :     ///
     288              :     /// The parsing logic is as follows:
     289              :     ///
     290              :     /// * Match each filter group.
     291              :     ///   - If a group is matched, it will first determine whether the user is in the range of the group's rollout
     292              :     ///     percentage. We will generate a consistent hash for the user ID on the group rollout percentage. This hash
     293              :     ///     is shared across all groups.
     294              :     ///   - If the hash falls within the group's rollout percentage, return the variant if it's overridden, or
     295              :     ///   - Evaluate the variant using the global config and the global rollout percentage.
     296              :     /// * Otherwise, continue with the next group until all groups are evaluated and no group is within the
     297              :     ///   rollout percentage.
     298              :     /// * If there are no matching groups, return an error.
     299              :     ///
     300              :     /// Example: we have a multivariate flag with 3 groups of the configured global rollout percentage: A (10%), B (20%), C (70%).
     301              :     /// There is a single group with a condition that has a rollout percentage of 10% and it does not have a variant override.
     302              :     /// Then, we will have 1% of the users evaluated to A, 2% to B, and 7% to C.
     303              :     ///
     304              :     /// Error handling: the caller should inspect the error and decide the behavior when a feature flag
     305              :     /// cannot be evaluated (i.e., default to false if it cannot be resolved). The error should *not* be
     306              :     /// propagated beyond where the feature flag gets resolved.
     307            0 :     pub fn evaluate_multivariate(
     308            0 :         &self,
     309            0 :         flag_key: &str,
     310            0 :         user_id: &str,
     311            0 :         properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     312            0 :     ) -> Result<String, PostHogEvaluationError> {
     313            0 :         let hash_on_global_rollout_percentage =
     314            0 :             Self::consistent_hash(user_id, flag_key, "multivariate");
     315            0 :         let hash_on_group_rollout_percentage =
     316            0 :             Self::consistent_hash(user_id, flag_key, "within_group");
     317            0 :         self.evaluate_multivariate_inner(
     318            0 :             flag_key,
     319            0 :             hash_on_global_rollout_percentage,
     320            0 :             hash_on_group_rollout_percentage,
     321            0 :             properties,
     322            0 :         )
     323            0 :     }
     324              : 
     325              :     /// Evaluate a boolean feature flag. Returns  an error if the flag is not available or if there are errors
     326              :     /// during the evaluation.
     327              :     ///
     328              :     /// The parsing logic is as follows:
     329              :     ///
     330              :     /// * Generate a consistent hash for the tenant-feature.
     331              :     /// * Match each filter group.
     332              :     ///   - If a group is matched, it will first determine whether the user is in the range of the rollout
     333              :     ///     percentage.
     334              :     ///   - If the hash falls within the group's rollout percentage, return true.
     335              :     /// * Otherwise, continue with the next group until all groups are evaluated and no group is within the
     336              :     ///   rollout percentage.
     337              :     /// * If there are no matching groups, return an error.
     338              :     ///
     339              :     /// Returns `Ok(())` if the feature flag evaluates to true. In the future, it will return a payload.
     340              :     ///
     341              :     /// Error handling: the caller should inspect the error and decide the behavior when a feature flag
     342              :     /// cannot be evaluated (i.e., default to false if it cannot be resolved). The error should *not* be
     343              :     /// propagated beyond where the feature flag gets resolved.
     344            0 :     pub fn evaluate_boolean(
     345            0 :         &self,
     346            0 :         flag_key: &str,
     347            0 :         user_id: &str,
     348            0 :         properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     349            0 :     ) -> Result<(), PostHogEvaluationError> {
     350            0 :         let hash_on_global_rollout_percentage = Self::consistent_hash(user_id, flag_key, "boolean");
     351            0 :         self.evaluate_boolean_inner(flag_key, hash_on_global_rollout_percentage, properties)
     352            0 :     }
     353              : 
     354              :     /// Evaluate a multivariate feature flag. Note that we directly take the mapped user ID
     355              :     /// (a consistent hash ranging from 0 to 1) so that it is easier to use it in the tests
     356              :     /// and avoid duplicate computations.
     357              :     ///
     358              :     /// Use a different consistent hash for evaluating the group rollout percentage.
     359              :     /// The behavior: if the condition is set to rolling out to 10% of the users, and
     360              :     /// we set the variant A to 20% in the global config, then 2% of the total users will
     361              :     /// be evaluated to variant A.
     362              :     ///
     363              :     /// Note that the hash to determine group rollout percentage is shared across all groups. So if we have two
     364              :     /// exactly-the-same conditions with 10% and 20% rollout percentage respectively, a total of 20% of the users
     365              :     /// will be evaluated (versus 30% if group evaluation is done independently).
     366            7 :     pub(crate) fn evaluate_multivariate_inner(
     367            7 :         &self,
     368            7 :         flag_key: &str,
     369            7 :         hash_on_global_rollout_percentage: f64,
     370            7 :         hash_on_group_rollout_percentage: f64,
     371            7 :         properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     372            7 :     ) -> Result<String, PostHogEvaluationError> {
     373            7 :         if let Some(flag_config) = self.flags.get(flag_key) {
     374            7 :             if !flag_config.active {
     375            0 :                 return Err(PostHogEvaluationError::NotAvailable(format!(
     376            0 :                     "The feature flag is not active: {}",
     377            0 :                     flag_key
     378            0 :                 )));
     379            7 :             }
     380            7 :             let Some(ref multivariate) = flag_config.filters.multivariate else {
     381            0 :                 return Err(PostHogEvaluationError::Internal(format!(
     382            0 :                     "No multivariate available, should use evaluate_boolean?: {flag_key}"
     383            0 :                 )));
     384              :             };
     385              :             // TODO: sort the groups so that variant overrides always get evaluated first and it follows the PostHog
     386              :             // Python SDK behavior; for now we do not configure conditions without variant overrides in Neon so it
     387              :             // does not matter.
     388           15 :             for group in &flag_config.filters.groups {
     389           12 :                 match self.evaluate_group(group, hash_on_group_rollout_percentage, properties)? {
     390            1 :                     GroupEvaluationResult::MatchedAndOverride(variant) => return Ok(variant),
     391              :                     GroupEvaluationResult::MatchedAndEvaluate => {
     392            2 :                         let mut percentage = 0;
     393            3 :                         for variant in &multivariate.variants {
     394            3 :                             percentage += variant.rollout_percentage;
     395            3 :                             if self
     396            3 :                                 .evaluate_percentage(hash_on_global_rollout_percentage, percentage)
     397              :                             {
     398            2 :                                 return Ok(variant.key.clone());
     399            1 :                             }
     400              :                         }
     401              :                         // This should not happen because the rollout percentage always adds up to 100, but just in case that PostHog
     402              :                         // returned invalid spec, we return an error.
     403            0 :                         return Err(PostHogEvaluationError::Internal(format!(
     404            0 :                             "Rollout percentage does not add up to 100: {}",
     405            0 :                             flag_key
     406            0 :                         )));
     407              :                     }
     408            8 :                     GroupEvaluationResult::Unmatched => continue,
     409              :                 }
     410              :             }
     411              :             // If no group is matched, the feature is not available, and up to the caller to decide what to do.
     412            3 :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     413              :         } else {
     414              :             // The feature flag is not available yet
     415            0 :             Err(PostHogEvaluationError::NotAvailable(format!(
     416            0 :                 "Not found in the local evaluation spec: {}",
     417            0 :                 flag_key
     418            0 :             )))
     419              :         }
     420            7 :     }
     421              : 
     422              :     /// Evaluate a multivariate feature flag. Note that we directly take the mapped user ID
     423              :     /// (a consistent hash ranging from 0 to 1) so that it is easier to use it in the tests
     424              :     /// and avoid duplicate computations.
     425              :     ///
     426              :     /// Use a different consistent hash for evaluating the group rollout percentage.
     427              :     /// The behavior: if the condition is set to rolling out to 10% of the users, and
     428              :     /// we set the variant A to 20% in the global config, then 2% of the total users will
     429              :     /// be evaluated to variant A.
     430              :     ///
     431              :     /// Note that the hash to determine group rollout percentage is shared across all groups. So if we have two
     432              :     /// exactly-the-same conditions with 10% and 20% rollout percentage respectively, a total of 20% of the users
     433              :     /// will be evaluated (versus 30% if group evaluation is done independently).
     434           10 :     pub(crate) fn evaluate_boolean_inner(
     435           10 :         &self,
     436           10 :         flag_key: &str,
     437           10 :         hash_on_global_rollout_percentage: f64,
     438           10 :         properties: &HashMap<String, PostHogFlagFilterPropertyValue>,
     439           10 :     ) -> Result<(), PostHogEvaluationError> {
     440           10 :         if let Some(flag_config) = self.flags.get(flag_key) {
     441           10 :             if !flag_config.active {
     442            0 :                 return Err(PostHogEvaluationError::NotAvailable(format!(
     443            0 :                     "The feature flag is not active: {}",
     444            0 :                     flag_key
     445            0 :                 )));
     446           10 :             }
     447           10 :             if flag_config.filters.multivariate.is_some() {
     448            0 :                 return Err(PostHogEvaluationError::Internal(format!(
     449            0 :                     "This looks like a multivariate flag, should use evaluate_multivariate?: {flag_key}"
     450            0 :                 )));
     451           10 :             };
     452              :             // TODO: sort the groups so that variant overrides always get evaluated first and it follows the PostHog
     453              :             // Python SDK behavior; for now we do not configure conditions without variant overrides in Neon so it
     454              :             // does not matter.
     455           17 :             for group in &flag_config.filters.groups {
     456           13 :                 match self.evaluate_group(group, hash_on_global_rollout_percentage, properties)? {
     457              :                     GroupEvaluationResult::MatchedAndOverride(_) => {
     458            0 :                         return Err(PostHogEvaluationError::Internal(format!(
     459            0 :                             "Boolean flag cannot have overrides: {}",
     460            0 :                             flag_key
     461            0 :                         )));
     462              :                     }
     463              :                     GroupEvaluationResult::MatchedAndEvaluate => {
     464            4 :                         return Ok(());
     465              :                     }
     466            7 :                     GroupEvaluationResult::Unmatched => continue,
     467              :                 }
     468              :             }
     469              :             // If no group is matched, the feature is not available, and up to the caller to decide what to do.
     470            4 :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     471              :         } else {
     472              :             // The feature flag is not available yet
     473            0 :             Err(PostHogEvaluationError::NotAvailable(format!(
     474            0 :                 "Not found in the local evaluation spec: {}",
     475            0 :                 flag_key
     476            0 :             )))
     477              :         }
     478           10 :     }
     479              : 
     480              :     /// Infer whether a feature flag is a boolean flag by checking if it has a multivariate filter.
     481            0 :     pub fn is_feature_flag_boolean(&self, flag_key: &str) -> Result<bool, PostHogEvaluationError> {
     482            0 :         if let Some(flag_config) = self.flags.get(flag_key) {
     483            0 :             Ok(flag_config.filters.multivariate.is_none())
     484              :         } else {
     485            0 :             Err(PostHogEvaluationError::NotAvailable(format!(
     486            0 :                 "Not found in the local evaluation spec: {}",
     487            0 :                 flag_key
     488            0 :             )))
     489              :         }
     490            0 :     }
     491              : }
     492              : 
     493              : pub struct PostHogClientConfig {
     494              :     /// The server API key.
     495              :     pub server_api_key: String,
     496              :     /// The client API key.
     497              :     pub client_api_key: String,
     498              :     /// The project ID.
     499              :     pub project_id: String,
     500              :     /// The private API URL.
     501              :     pub private_api_url: String,
     502              :     /// The public API URL.
     503              :     pub public_api_url: String,
     504              : }
     505              : 
     506              : /// A lite PostHog client.
     507              : ///
     508              : /// At the point of writing this code, PostHog does not have a functional Rust client with feature flag support.
     509              : /// This is a lite version that only supports local evaluation of feature flags and only supports those JSON specs
     510              : /// that will be used within Neon.
     511              : ///
     512              : /// PostHog is designed as a browser-server system: the browser (client) side uses the client key and is exposed
     513              : /// to the end users; the server side uses a server key and is not exposed to the end users. The client and the
     514              : /// server has different API keys and provide a different set of APIs. In Neon, we only have the server (that is
     515              : /// pageserver), and it will use both the client API and the server API. So we need to store two API keys within
     516              : /// our PostHog client.
     517              : ///
     518              : /// The server API is used to fetch the feature flag specs. The client API is used to capture events in case we
     519              : /// want to report the feature flag usage back to PostHog. The current plan is to use PostHog only as an UI to
     520              : /// configure feature flags so it is very likely that the client API will not be used.
     521              : pub struct PostHogClient {
     522              :     /// The config.
     523              :     config: PostHogClientConfig,
     524              :     /// The HTTP client.
     525              :     client: reqwest::Client,
     526              : }
     527              : 
     528              : #[derive(Serialize, Debug)]
     529              : pub struct CaptureEvent {
     530              :     pub event: String,
     531              :     pub distinct_id: String,
     532              :     pub properties: serde_json::Value,
     533              : }
     534              : 
     535              : impl PostHogClient {
     536            0 :     pub fn new(config: PostHogClientConfig) -> Self {
     537            0 :         let client = reqwest::Client::new();
     538            0 :         Self { config, client }
     539            0 :     }
     540              : 
     541            0 :     pub fn new_with_us_region(
     542            0 :         server_api_key: String,
     543            0 :         client_api_key: String,
     544            0 :         project_id: String,
     545            0 :     ) -> Self {
     546            0 :         Self::new(PostHogClientConfig {
     547            0 :             server_api_key,
     548            0 :             client_api_key,
     549            0 :             project_id,
     550            0 :             private_api_url: "https://us.posthog.com".to_string(),
     551            0 :             public_api_url: "https://us.i.posthog.com".to_string(),
     552            0 :         })
     553            0 :     }
     554              : 
     555              :     /// Check if the server API key is a feature flag secure API key. This key can only be
     556              :     /// used to fetch the feature flag specs and can only be used on a undocumented API
     557              :     /// endpoint.
     558            0 :     fn is_feature_flag_secure_api_key(&self) -> bool {
     559            0 :         self.config.server_api_key.starts_with("phs_")
     560            0 :     }
     561              : 
     562              :     /// Fetch the feature flag specs from the server.
     563              :     ///
     564              :     /// This is unfortunately an undocumented API at:
     565              :     /// - <https://posthog.com/docs/api/feature-flags#get-api-projects-project_id-feature_flags-local_evaluation>
     566              :     /// - <https://posthog.com/docs/feature-flags/local-evaluation>
     567              :     ///
     568              :     /// The handling logic in [`FeatureStore`] mostly follows the Python API implementation.
     569              :     /// See `_compute_flag_locally` in <https://github.com/PostHog/posthog-python/blob/master/posthog/client.py>
     570            0 :     pub async fn get_feature_flags_local_evaluation(
     571            0 :         &self,
     572            0 :     ) -> anyhow::Result<LocalEvaluationResponse> {
     573              :         // BASE_URL/api/projects/:project_id/feature_flags/local_evaluation
     574              :         // with bearer token of self.server_api_key
     575              :         // OR
     576              :         // BASE_URL/api/feature_flag/local_evaluation/
     577              :         // with bearer token of feature flag specific self.server_api_key
     578            0 :         let url = if self.is_feature_flag_secure_api_key() {
     579              :             // The new feature local evaluation secure API token
     580            0 :             format!(
     581            0 :                 "{}/api/feature_flag/local_evaluation",
     582            0 :                 self.config.private_api_url
     583            0 :             )
     584              :         } else {
     585              :             // The old personal API token
     586            0 :             format!(
     587            0 :                 "{}/api/projects/{}/feature_flags/local_evaluation",
     588            0 :                 self.config.private_api_url, self.config.project_id
     589            0 :             )
     590              :         };
     591            0 :         let response = self
     592            0 :             .client
     593            0 :             .get(url)
     594            0 :             .bearer_auth(&self.config.server_api_key)
     595            0 :             .send()
     596            0 :             .await?;
     597            0 :         let status = response.status();
     598            0 :         let body = response.text().await?;
     599            0 :         if !status.is_success() {
     600            0 :             return Err(anyhow::anyhow!(
     601            0 :                 "Failed to get feature flags: {}, {}",
     602            0 :                 status,
     603            0 :                 body
     604            0 :             ));
     605            0 :         }
     606            0 :         Ok(serde_json::from_str(&body)?)
     607            0 :     }
     608              : 
     609              :     /// Capture an event. This will only be used to report the feature flag usage back to PostHog, though
     610              :     /// it also support a lot of other functionalities.
     611              :     ///
     612              :     /// <https://posthog.com/docs/api/capture>
     613            0 :     pub async fn capture_event(
     614            0 :         &self,
     615            0 :         event: &str,
     616            0 :         distinct_id: &str,
     617            0 :         properties: &serde_json::Value,
     618            0 :     ) -> anyhow::Result<()> {
     619            0 :         // PUBLIC_URL/capture/
     620            0 :         let url = format!("{}/capture/", self.config.public_api_url);
     621            0 :         let response = self
     622            0 :             .client
     623            0 :             .post(url)
     624            0 :             .body(serde_json::to_string(&json!({
     625            0 :                 "api_key": self.config.client_api_key,
     626            0 :                 "distinct_id": distinct_id,
     627            0 :                 "event": event,
     628            0 :                 "properties": properties,
     629            0 :             }))?)
     630            0 :             .send()
     631            0 :             .await?;
     632            0 :         let status = response.status();
     633            0 :         let body = response.text().await?;
     634            0 :         if !status.is_success() {
     635            0 :             return Err(anyhow::anyhow!(
     636            0 :                 "Failed to capture events: {}, {}",
     637            0 :                 status,
     638            0 :                 body
     639            0 :             ));
     640            0 :         }
     641            0 :         Ok(())
     642            0 :     }
     643              : 
     644            0 :     pub async fn capture_event_batch(&self, events: &[CaptureEvent]) -> anyhow::Result<()> {
     645            0 :         // PUBLIC_URL/batch/
     646            0 :         let url = format!("{}/batch/", self.config.public_api_url);
     647            0 :         let response = self
     648            0 :             .client
     649            0 :             .post(url)
     650            0 :             .body(serde_json::to_string(&json!({
     651            0 :                 "api_key": self.config.client_api_key,
     652            0 :                 "batch": events,
     653            0 :             }))?)
     654            0 :             .send()
     655            0 :             .await?;
     656            0 :         let status = response.status();
     657            0 :         let body = response.text().await?;
     658            0 :         if !status.is_success() {
     659            0 :             return Err(anyhow::anyhow!(
     660            0 :                 "Failed to capture events: {}, {}",
     661            0 :                 status,
     662            0 :                 body
     663            0 :             ));
     664            0 :         }
     665            0 :         Ok(())
     666            0 :     }
     667              : }
     668              : 
     669              : #[cfg(test)]
     670              : mod tests {
     671              :     use super::*;
     672              : 
     673            4 :     fn data() -> &'static str {
     674            4 :         r#"{
     675            4 :   "flags": [
     676            4 :     {
     677            4 :       "id": 141807,
     678            4 :       "team_id": 152860,
     679            4 :       "name": "",
     680            4 :       "key": "image-compaction-boundary",
     681            4 :       "filters": {
     682            4 :         "groups": [
     683            4 :           {
     684            4 :             "variant": null,
     685            4 :             "properties": [
     686            4 :               {
     687            4 :                 "key": "plan_type",
     688            4 :                 "type": "person",
     689            4 :                 "value": [
     690            4 :                   "free"
     691            4 :                 ],
     692            4 :                 "operator": "exact"
     693            4 :               }
     694            4 :             ],
     695            4 :             "rollout_percentage": 40
     696            4 :           },
     697            4 :           {
     698            4 :             "variant": null,
     699            4 :             "properties": [],
     700            4 :             "rollout_percentage": 10
     701            4 :           }
     702            4 :         ],
     703            4 :         "payloads": {},
     704            4 :         "multivariate": null
     705            4 :       },
     706            4 :       "deleted": false,
     707            4 :       "active": true,
     708            4 :       "ensure_experience_continuity": false,
     709            4 :       "has_encrypted_payloads": false,
     710            4 :       "version": 1
     711            4 :     },
     712            4 :     {
     713            4 :       "id": 135586,
     714            4 :       "team_id": 152860,
     715            4 :       "name": "",
     716            4 :       "key": "boolean-flag",
     717            4 :       "filters": {
     718            4 :         "groups": [
     719            4 :           {
     720            4 :             "variant": null,
     721            4 :             "properties": [
     722            4 :               {
     723            4 :                 "key": "plan_type",
     724            4 :                 "type": "person",
     725            4 :                 "value": [
     726            4 :                   "free"
     727            4 :                 ],
     728            4 :                 "operator": "exact"
     729            4 :               }
     730            4 :             ],
     731            4 :             "rollout_percentage": 47
     732            4 :           }
     733            4 :         ],
     734            4 :         "payloads": {},
     735            4 :         "multivariate": null
     736            4 :       },
     737            4 :       "deleted": false,
     738            4 :       "active": true,
     739            4 :       "ensure_experience_continuity": false,
     740            4 :       "has_encrypted_payloads": false,
     741            4 :       "version": 1
     742            4 :     },
     743            4 :     {
     744            4 :       "id": 132794,
     745            4 :       "team_id": 152860,
     746            4 :       "name": "",
     747            4 :       "key": "gc-compaction",
     748            4 :       "filters": {
     749            4 :         "groups": [
     750            4 :           {
     751            4 :             "variant": "enabled-stage-2",
     752            4 :             "properties": [
     753            4 :               {
     754            4 :                 "key": "plan_type",
     755            4 :                 "type": "person",
     756            4 :                 "value": [
     757            4 :                   "free"
     758            4 :                 ],
     759            4 :                 "operator": "exact"
     760            4 :               },
     761            4 :               {
     762            4 :                 "key": "pageserver_remote_size",
     763            4 :                 "type": "person",
     764            4 :                 "value": "10000000",
     765            4 :                 "operator": "lt"
     766            4 :               }
     767            4 :             ],
     768            4 :              "rollout_percentage": 50
     769            4 :           },
     770            4 :           {
     771            4 :             "properties": [
     772            4 :               {
     773            4 :                 "key": "plan_type",
     774            4 :                 "type": "person",
     775            4 :                 "value": [
     776            4 :                   "free"
     777            4 :                 ],
     778            4 :                 "operator": "exact"
     779            4 :               },
     780            4 :               {
     781            4 :                 "key": "pageserver_remote_size",
     782            4 :                 "type": "person",
     783            4 :                 "value": "10000000",
     784            4 :                 "operator": "lt"
     785            4 :               }
     786            4 :             ],
     787            4 :             "rollout_percentage": 80
     788            4 :           }
     789            4 :         ],
     790            4 :         "payloads": {},
     791            4 :         "multivariate": {
     792            4 :           "variants": [
     793            4 :             {
     794            4 :               "key": "disabled",
     795            4 :               "name": "",
     796            4 :               "rollout_percentage": 90
     797            4 :             },
     798            4 :             {
     799            4 :               "key": "enabled-stage-1",
     800            4 :               "name": "",
     801            4 :               "rollout_percentage": 10
     802            4 :             },
     803            4 :             {
     804            4 :               "key": "enabled-stage-2",
     805            4 :               "name": "",
     806            4 :               "rollout_percentage": 0
     807            4 :             },
     808            4 :             {
     809            4 :               "key": "enabled-stage-3",
     810            4 :               "name": "",
     811            4 :               "rollout_percentage": 0
     812            4 :             },
     813            4 :             {
     814            4 :               "key": "enabled",
     815            4 :               "name": "",
     816            4 :               "rollout_percentage": 0
     817            4 :             }
     818            4 :           ]
     819            4 :         }
     820            4 :       },
     821            4 :       "deleted": false,
     822            4 :       "active": true,
     823            4 :       "ensure_experience_continuity": false,
     824            4 :       "has_encrypted_payloads": false,
     825            4 :       "version": 7
     826            4 :     }
     827            4 :   ],
     828            4 :   "group_type_mapping": {},
     829            4 :   "cohorts": {}
     830            4 : }"#
     831            4 :     }
     832              : 
     833              :     #[test]
     834            1 :     fn parse_local_evaluation() {
     835            1 :         let data = data();
     836            1 :         let _: LocalEvaluationResponse = serde_json::from_str(data).unwrap();
     837            1 :     }
     838              : 
     839              :     #[test]
     840            1 :     fn evaluate_multivariate() {
     841            1 :         let mut store = FeatureStore::new();
     842            1 :         let response: LocalEvaluationResponse = serde_json::from_str(data()).unwrap();
     843            1 :         store.set_flags(response.flags, None).unwrap();
     844            1 : 
     845            1 :         // This lacks the required properties and cannot be evaluated.
     846            1 :         let variant =
     847            1 :             store.evaluate_multivariate_inner("gc-compaction", 1.00, 0.40, &HashMap::new());
     848            1 :         assert!(matches!(
     849            1 :             variant,
     850              :             Err(PostHogEvaluationError::NotAvailable(_))
     851              :         ),);
     852              : 
     853            1 :         let properties_unmatched = HashMap::from([
     854            1 :             (
     855            1 :                 "plan_type".to_string(),
     856            1 :                 PostHogFlagFilterPropertyValue::String("paid".to_string()),
     857            1 :             ),
     858            1 :             (
     859            1 :                 "pageserver_remote_size".to_string(),
     860            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     861            1 :             ),
     862            1 :         ]);
     863            1 : 
     864            1 :         // This does not match any group so there will be an error.
     865            1 :         let variant =
     866            1 :             store.evaluate_multivariate_inner("gc-compaction", 1.00, 0.40, &properties_unmatched);
     867            1 :         assert!(matches!(
     868            1 :             variant,
     869              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     870              :         ),);
     871            1 :         let variant =
     872            1 :             store.evaluate_multivariate_inner("gc-compaction", 0.80, 0.80, &properties_unmatched);
     873            1 :         assert!(matches!(
     874            1 :             variant,
     875              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     876              :         ),);
     877              : 
     878            1 :         let properties = HashMap::from([
     879            1 :             (
     880            1 :                 "plan_type".to_string(),
     881            1 :                 PostHogFlagFilterPropertyValue::String("free".to_string()),
     882            1 :             ),
     883            1 :             (
     884            1 :                 "pageserver_remote_size".to_string(),
     885            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     886            1 :             ),
     887            1 :         ]);
     888            1 : 
     889            1 :         // It matches the first group as 0.10 <= 0.50 and the properties are matched. Then it gets evaluated to the variant override.
     890            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 0.10, 0.10, &properties);
     891            1 :         assert_eq!(variant.unwrap(), "enabled-stage-2".to_string());
     892              : 
     893              :         // 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.
     894            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 0.99, 0.60, &properties);
     895            1 :         assert_eq!(variant.unwrap(), "enabled-stage-1".to_string());
     896            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 0.80, 0.60, &properties);
     897            1 :         assert_eq!(variant.unwrap(), "disabled".to_string());
     898              : 
     899              :         // It matches the group conditions but not the group rollout percentage.
     900            1 :         let variant = store.evaluate_multivariate_inner("gc-compaction", 1.00, 0.90, &properties);
     901            1 :         assert!(matches!(
     902            1 :             variant,
     903              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     904              :         ),);
     905            1 :     }
     906              : 
     907              :     #[test]
     908            1 :     fn evaluate_boolean_1() {
     909            1 :         // The `boolean-flag` feature flag only has one group that matches on the free user.
     910            1 : 
     911            1 :         let mut store = FeatureStore::new();
     912            1 :         let response: LocalEvaluationResponse = serde_json::from_str(data()).unwrap();
     913            1 :         store.set_flags(response.flags, None).unwrap();
     914            1 : 
     915            1 :         // This lacks the required properties and cannot be evaluated.
     916            1 :         let variant = store.evaluate_boolean_inner("boolean-flag", 1.00, &HashMap::new());
     917            1 :         assert!(matches!(
     918            1 :             variant,
     919              :             Err(PostHogEvaluationError::NotAvailable(_))
     920              :         ),);
     921              : 
     922            1 :         let properties_unmatched = HashMap::from([
     923            1 :             (
     924            1 :                 "plan_type".to_string(),
     925            1 :                 PostHogFlagFilterPropertyValue::String("paid".to_string()),
     926            1 :             ),
     927            1 :             (
     928            1 :                 "pageserver_remote_size".to_string(),
     929            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     930            1 :             ),
     931            1 :         ]);
     932            1 : 
     933            1 :         // This does not match any group so there will be an error.
     934            1 :         let variant = store.evaluate_boolean_inner("boolean-flag", 1.00, &properties_unmatched);
     935            1 :         assert!(matches!(
     936            1 :             variant,
     937              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     938              :         ),);
     939              : 
     940            1 :         let properties = HashMap::from([
     941            1 :             (
     942            1 :                 "plan_type".to_string(),
     943            1 :                 PostHogFlagFilterPropertyValue::String("free".to_string()),
     944            1 :             ),
     945            1 :             (
     946            1 :                 "pageserver_remote_size".to_string(),
     947            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     948            1 :             ),
     949            1 :         ]);
     950            1 : 
     951            1 :         // It matches the first group as 0.10 <= 0.50 and the properties are matched. Then it gets evaluated to the variant override.
     952            1 :         let variant = store.evaluate_boolean_inner("boolean-flag", 0.10, &properties);
     953            1 :         assert!(variant.is_ok());
     954              : 
     955              :         // It matches the group conditions but not the group rollout percentage.
     956            1 :         let variant = store.evaluate_boolean_inner("boolean-flag", 1.00, &properties);
     957            1 :         assert!(matches!(
     958            1 :             variant,
     959              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     960              :         ),);
     961            1 :     }
     962              : 
     963              :     #[test]
     964            1 :     fn evaluate_boolean_2() {
     965            1 :         // The `image-compaction-boundary` feature flag has one group that matches on the free user and a group that matches on all users.
     966            1 : 
     967            1 :         let mut store = FeatureStore::new();
     968            1 :         let response: LocalEvaluationResponse = serde_json::from_str(data()).unwrap();
     969            1 :         store.set_flags(response.flags, None).unwrap();
     970            1 : 
     971            1 :         // This lacks the required properties and cannot be evaluated.
     972            1 :         let variant =
     973            1 :             store.evaluate_boolean_inner("image-compaction-boundary", 1.00, &HashMap::new());
     974            1 :         assert!(matches!(
     975            1 :             variant,
     976              :             Err(PostHogEvaluationError::NotAvailable(_))
     977              :         ),);
     978              : 
     979            1 :         let properties_unmatched = HashMap::from([
     980            1 :             (
     981            1 :                 "plan_type".to_string(),
     982            1 :                 PostHogFlagFilterPropertyValue::String("paid".to_string()),
     983            1 :             ),
     984            1 :             (
     985            1 :                 "pageserver_remote_size".to_string(),
     986            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
     987            1 :             ),
     988            1 :         ]);
     989            1 : 
     990            1 :         // This does not match the filtered group but the all user group.
     991            1 :         let variant =
     992            1 :             store.evaluate_boolean_inner("image-compaction-boundary", 1.00, &properties_unmatched);
     993            1 :         assert!(matches!(
     994            1 :             variant,
     995              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
     996              :         ),);
     997            1 :         let variant =
     998            1 :             store.evaluate_boolean_inner("image-compaction-boundary", 0.05, &properties_unmatched);
     999            1 :         assert!(variant.is_ok());
    1000              : 
    1001            1 :         let properties = HashMap::from([
    1002            1 :             (
    1003            1 :                 "plan_type".to_string(),
    1004            1 :                 PostHogFlagFilterPropertyValue::String("free".to_string()),
    1005            1 :             ),
    1006            1 :             (
    1007            1 :                 "pageserver_remote_size".to_string(),
    1008            1 :                 PostHogFlagFilterPropertyValue::Number(1000.0),
    1009            1 :             ),
    1010            1 :         ]);
    1011            1 : 
    1012            1 :         // It matches the first group as 0.30 <= 0.40 and the properties are matched. Then it gets evaluated to the variant override.
    1013            1 :         let variant = store.evaluate_boolean_inner("image-compaction-boundary", 0.30, &properties);
    1014            1 :         assert!(variant.is_ok());
    1015              : 
    1016              :         // It matches the group conditions but not the group rollout percentage.
    1017            1 :         let variant = store.evaluate_boolean_inner("image-compaction-boundary", 1.00, &properties);
    1018            1 :         assert!(matches!(
    1019            1 :             variant,
    1020              :             Err(PostHogEvaluationError::NoConditionGroupMatched)
    1021              :         ),);
    1022              : 
    1023              :         // It matches the second "all" group conditions.
    1024            1 :         let variant = store.evaluate_boolean_inner("image-compaction-boundary", 0.09, &properties);
    1025            1 :         assert!(variant.is_ok());
    1026            1 :     }
    1027              : }
        

Generated by: LCOV version 2.1-beta