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 : }
|