LCOV - code coverage report
Current view: top level - libs/posthog_client_lite/src - lib.rs (source / functions) Coverage Total Hit
Test: 1e20c4f2b28aa592527961bb32170ebbd2c9172f.info Lines: 63.6 % 621 395
Test Date: 2025-07-16 12:29:03 Functions: 27.3 % 44 12

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

Generated by: LCOV version 2.1-beta