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