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