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