Skip to main content

posthog_rs/
local_evaluation.rs

1use crate::client::{apply_on_error_hooks, get_default_user_agent, OnErrorHook};
2use crate::feature_flags::{
3    match_feature_flag, match_feature_flag_with_context, CohortDefinition, EvaluationContext,
4    FeatureFlag, FlagValue, InconclusiveMatchError,
5};
6use crate::{Error, LocalEvaluationFailure, PostHogError};
7use reqwest::header::{HeaderMap, ETAG, IF_NONE_MATCH, USER_AGENT};
8use reqwest::StatusCode;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, RwLock};
13use std::time::Duration;
14use tracing::{debug, error, info, instrument, trace, warn};
15
16/// Extract the ETag header value from a response's headers.
17/// Returns None if the header is missing, invalid UTF-8, or empty.
18fn extract_etag(headers: &HeaderMap) -> Option<String> {
19    headers
20        .get(ETAG)
21        .and_then(|v| v.to_str().ok())
22        .filter(|s| !s.is_empty())
23        .map(|s| s.to_string())
24}
25
26/// Sleep up to `duration`, waking early when `stop_signal` is set. Returns
27/// `true` if a stop was requested (either already pending or observed while
28/// waiting). Polling in short steps keeps shutdown latency bounded even when
29/// the poll interval is large — a plain `sleep(poll_interval)` would make
30/// `stop`/`Drop` block for the remainder of the current interval.
31fn sleep_until_stop(stop_signal: &AtomicBool, duration: Duration) -> bool {
32    const STEP: Duration = Duration::from_millis(200);
33    let mut remaining = duration;
34    while !remaining.is_zero() {
35        if stop_signal.load(Ordering::Relaxed) {
36            return true;
37        }
38        let step = remaining.min(STEP);
39        std::thread::sleep(step);
40        remaining -= step;
41    }
42    stop_signal.load(Ordering::Relaxed)
43}
44
45/// Fire the `on_error` hooks for a failed definitions poll. The personal API
46/// key is never included — only the cause and HTTP status are surfaced.
47fn report_local_eval_error(hooks: &[OnErrorHook], status: Option<u16>, error: &Error) {
48    if hooks.is_empty() {
49        return;
50    }
51    let failure = PostHogError::LocalEvaluation(LocalEvaluationFailure { error, status });
52    apply_on_error_hooks(hooks, &failure);
53}
54
55/// Response from the PostHog local evaluation API.
56///
57/// Contains feature flag definitions, group type mappings, and cohort definitions
58/// that can be cached locally for flag evaluation without server round-trips.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct LocalEvaluationResponse {
61    /// List of feature flag definitions
62    pub flags: Vec<FeatureFlag>,
63    /// Mapping from group type keys to their display names
64    #[serde(default)]
65    pub group_type_mapping: HashMap<String, String>,
66    /// Cohort definitions for evaluating cohort membership
67    #[serde(default)]
68    pub cohorts: HashMap<String, Cohort>,
69    /// Server-controlled gate: when `true`, `$feature_flag_called` events for
70    /// non-experiment flags evaluated from these definitions are minimized to a
71    /// strict property allowlist. Absent fails safe to `false` (full event).
72    #[serde(default)]
73    pub minimal_flag_called_events: bool,
74}
75
76/// A cohort definition for local evaluation.
77///
78/// Cohorts are groups of users defined by property filters, used for
79/// targeting feature flags to specific user segments.
80///
81/// The `/flags/definitions/?send_cohorts` endpoint maps each cohort ID
82/// straight to its property group, `{"type": "AND"|"OR", "values": [...]}`,
83/// with no wrapping `id`/`name` fields — so this type is transparent over the
84/// raw property group and the owning ID is the map key in
85/// [`LocalEvaluationResponse::cohorts`].
86///
87/// The backend dependency loader can serialize cohort references that form a
88/// cycle across map entries. Serde preserves those references as-is; cohort
89/// evaluation must track active IDs before following them recursively to avoid
90/// a stack overflow.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(transparent)]
93pub struct Cohort {
94    /// The raw cohort property group exactly as returned by the API, e.g.
95    /// `{"type": "AND", "values": [{"key": "email", "value": "...", ...}]}`.
96    /// `values` entries may be leaf property filters or nested property groups.
97    pub properties: serde_json::Value,
98}
99
100/// Thread-safe cache for feature flag definitions.
101///
102/// Stores feature flags, group type mappings, and cohort definitions that have
103/// been fetched from the PostHog API. The cache is shared between the poller
104/// (which updates it) and the evaluator (which reads from it).
105#[derive(Clone)]
106pub struct FlagCache {
107    flags: Arc<RwLock<HashMap<String, FeatureFlag>>>,
108    group_type_mapping: Arc<RwLock<HashMap<String, String>>>,
109    cohorts: Arc<RwLock<HashMap<String, Cohort>>>,
110    /// The `minimal_flag_called_events` gate from the most recent definitions
111    /// poll. Read once when a local evaluation succeeds and pinned onto that
112    /// flag's record, so the minimization decision reflects the definitions
113    /// snapshot that produced the value.
114    minimal_flag_called_events: Arc<AtomicBool>,
115}
116
117impl Default for FlagCache {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl FlagCache {
124    /// Create an empty shared flag cache.
125    pub fn new() -> Self {
126        Self {
127            flags: Arc::new(RwLock::new(HashMap::new())),
128            group_type_mapping: Arc::new(RwLock::new(HashMap::new())),
129            cohorts: Arc::new(RwLock::new(HashMap::new())),
130            minimal_flag_called_events: Arc::new(AtomicBool::new(false)),
131        }
132    }
133
134    /// Replace cached flags, group type mappings, and cohorts from a local
135    /// evaluation API response.
136    pub fn update(&self, response: LocalEvaluationResponse) {
137        let flag_count = response.flags.len();
138        let mut flags = self.flags.write().unwrap();
139        flags.clear();
140        for flag in response.flags {
141            flags.insert(flag.key.clone(), flag);
142        }
143
144        let mut mapping = self.group_type_mapping.write().unwrap();
145        *mapping = response.group_type_mapping;
146
147        let mut cohorts = self.cohorts.write().unwrap();
148        *cohorts = response.cohorts;
149
150        self.minimal_flag_called_events
151            .store(response.minimal_flag_called_events, Ordering::Relaxed);
152
153        debug!(flag_count, "Updated flag cache");
154    }
155
156    /// Whether the most recent definitions poll enabled minimal
157    /// `$feature_flag_called` events. `false` until definitions load, so a
158    /// missing signal always yields full events.
159    pub fn minimal_flag_called_events(&self) -> bool {
160        self.minimal_flag_called_events.load(Ordering::Relaxed)
161    }
162
163    /// Return a cached feature flag by key.
164    pub fn get_flag(&self, key: &str) -> Option<FeatureFlag> {
165        self.flags.read().unwrap().get(key).cloned()
166    }
167
168    /// Return all cached feature flag definitions.
169    pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
170        self.flags.read().unwrap().values().cloned().collect()
171    }
172
173    /// Return a cached cohort by ID.
174    pub fn get_cohort(&self, id: &str) -> Option<Cohort> {
175        self.cohorts.read().unwrap().get(id).cloned()
176    }
177
178    /// Return all cached cohorts, keyed by cohort ID.
179    pub fn get_all_cohorts(&self) -> HashMap<String, Cohort> {
180        self.cohorts.read().unwrap().clone()
181    }
182
183    /// Get all cohorts as CohortDefinitions for evaluation context
184    pub fn get_cohort_definitions(&self) -> HashMap<String, CohortDefinition> {
185        self.cohorts
186            .read()
187            .unwrap()
188            .iter()
189            .map(|(k, v)| {
190                (
191                    k.clone(),
192                    CohortDefinition {
193                        id: k.clone(),
194                        properties: v.properties.clone(),
195                    },
196                )
197            })
198            .collect()
199    }
200
201    /// Get all flags as a HashMap for evaluation context
202    pub fn get_flags_map(&self) -> HashMap<String, FeatureFlag> {
203        self.flags.read().unwrap().clone()
204    }
205
206    /// Get the group type mapping (group type index → group type name).
207    pub fn get_group_type_mapping(&self) -> HashMap<String, String> {
208        self.group_type_mapping.read().unwrap().clone()
209    }
210
211    /// Remove all cached flags, group type mappings, and cohorts.
212    pub fn clear(&self) {
213        self.flags.write().unwrap().clear();
214        self.group_type_mapping.write().unwrap().clear();
215        self.cohorts.write().unwrap().clear();
216    }
217}
218
219/// Configuration for local flag evaluation.
220///
221/// Specifies the credentials and settings needed to fetch feature flag
222/// definitions from the PostHog API for local evaluation.
223#[derive(Clone)]
224pub struct LocalEvaluationConfig {
225    /// Personal API key for authentication (found in PostHog project settings)
226    pub personal_api_key: String,
227    /// Project API key to identify which project's flags to fetch
228    pub project_api_key: String,
229    /// PostHog API host URL (for example, `https://us.i.posthog.com`).
230    /// Use `https://eu.i.posthog.com` for EU-hosted projects.
231    pub api_host: String,
232    /// How often to poll for updated flag definitions
233    pub poll_interval: Duration,
234    /// Timeout for API requests
235    pub request_timeout: Duration,
236}
237
238/// Synchronous poller for feature flag definitions.
239///
240/// Runs a background thread that periodically fetches flag definitions from
241/// the PostHog API and updates the shared cache. Use this for blocking/sync
242/// applications. With the `async-client` feature enabled, use
243/// [`AsyncFlagPoller`] for async applications instead.
244pub struct FlagPoller {
245    config: LocalEvaluationConfig,
246    cache: FlagCache,
247    client: reqwest::blocking::Client,
248    stop_signal: Arc<AtomicBool>,
249    thread_handle: Option<std::thread::JoinHandle<()>>,
250    /// Observability hooks, injected by the client builder before `start`.
251    /// Kept here rather than on `LocalEvaluationConfig` so the public config
252    /// struct stays unchanged.
253    on_error: Vec<OnErrorHook>,
254}
255
256impl FlagPoller {
257    /// Create a synchronous flag definition poller.
258    ///
259    /// # Parameters
260    ///
261    /// - `config`: Credentials, host, polling interval, and request timeout.
262    /// - `cache`: Shared cache updated by the poller.
263    pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self {
264        let client = reqwest::blocking::Client::builder()
265            .timeout(config.request_timeout)
266            .build()
267            .unwrap();
268
269        Self {
270            config,
271            cache,
272            client,
273            stop_signal: Arc::new(AtomicBool::new(false)),
274            thread_handle: None,
275            on_error: Vec::new(),
276        }
277    }
278
279    /// Register `on_error` hooks. Called by the client builder before
280    /// [`FlagPoller::start`]; not part of the public flag-poller API.
281    // Only the blocking client (built when `async-client` is off) injects hooks.
282    #[cfg_attr(feature = "async-client", allow(dead_code))]
283    pub(crate) fn set_on_error(&mut self, hooks: Vec<OnErrorHook>) {
284        self.on_error = hooks;
285    }
286
287    /// Start the polling thread.
288    ///
289    /// Performs an initial synchronous load, then refreshes definitions in the
290    /// background until [`FlagPoller::stop`] is called or the poller is dropped.
291    pub fn start(&mut self) {
292        info!(
293            poll_interval_secs = self.config.poll_interval.as_secs(),
294            "Starting feature flag poller"
295        );
296
297        // Initial load
298        match self.load_flags() {
299            Ok(()) => info!("Initial flag definitions loaded successfully"),
300            Err(e) => warn!(error = %e, "Failed to load initial flags, will retry on next poll"),
301        }
302
303        let config = self.config.clone();
304        let cache = self.cache.clone();
305        let stop_signal = self.stop_signal.clone();
306        let on_error = self.on_error.clone();
307
308        let handle = std::thread::spawn(move || {
309            let client = reqwest::blocking::Client::builder()
310                .timeout(config.request_timeout)
311                .build()
312                .unwrap();
313
314            let mut last_etag: Option<String> = None;
315
316            loop {
317                if sleep_until_stop(&stop_signal, config.poll_interval) {
318                    debug!("Flag poller received stop signal");
319                    break;
320                }
321
322                let url = format!(
323                    "{}/flags/definitions/?send_cohorts",
324                    config.api_host.trim_end_matches('/')
325                );
326
327                let mut request = client
328                    .get(&url)
329                    .header(
330                        "Authorization",
331                        format!("Bearer {}", config.personal_api_key),
332                    )
333                    .header("X-PostHog-Project-Api-Key", &config.project_api_key)
334                    .header(USER_AGENT, get_default_user_agent());
335
336                if let Some(ref etag) = last_etag {
337                    request = request.header(IF_NONE_MATCH, etag.as_str());
338                }
339
340                match request.send() {
341                    Ok(response) => {
342                        let status = response.status();
343                        if status == StatusCode::NOT_MODIFIED {
344                            debug!("Flag definitions unchanged (304 Not Modified)");
345                        } else if status.is_success() {
346                            // Extract ETag before consuming the response body
347                            let new_etag = extract_etag(response.headers());
348
349                            match response.json::<LocalEvaluationResponse>() {
350                                Ok(data) => {
351                                    trace!("Successfully fetched flag definitions");
352                                    cache.update(data);
353                                    last_etag = new_etag;
354                                }
355                                Err(e) => {
356                                    warn!(error = %e, "Failed to parse flag response");
357                                    let err = Error::Serialization(e.to_string());
358                                    report_local_eval_error(&on_error, Some(status.as_u16()), &err);
359                                }
360                            }
361                        } else {
362                            warn!(status = %status, "Failed to fetch flags");
363                            let err = Error::Connection(format!("HTTP {}", status));
364                            report_local_eval_error(&on_error, Some(status.as_u16()), &err);
365                        }
366                    }
367                    Err(e) => {
368                        warn!(error = %e, "Failed to fetch flags");
369                        let err = Error::Connection(e.to_string());
370                        report_local_eval_error(&on_error, None, &err);
371                    }
372                }
373            }
374        });
375
376        self.thread_handle = Some(handle);
377    }
378
379    /// Load flags synchronously and update the cache once.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`Error::Connection`] for request failures or non-success HTTP
384    /// statuses, and [`Error::Serialization`] when the response cannot be
385    /// parsed.
386    #[instrument(skip(self), level = "debug")]
387    pub fn load_flags(&self) -> Result<(), Error> {
388        let url = format!(
389            "{}/flags/definitions/?send_cohorts",
390            self.config.api_host.trim_end_matches('/')
391        );
392
393        let response = match self
394            .client
395            .get(&url)
396            .header(
397                "Authorization",
398                format!("Bearer {}", self.config.personal_api_key),
399            )
400            .header("X-PostHog-Project-Api-Key", &self.config.project_api_key)
401            .header(USER_AGENT, get_default_user_agent())
402            .send()
403        {
404            Ok(r) => r,
405            Err(e) => {
406                error!(error = %e, "Connection error loading flags");
407                let err = Error::Connection(e.to_string());
408                report_local_eval_error(&self.on_error, None, &err);
409                return Err(err);
410            }
411        };
412
413        if !response.status().is_success() {
414            let status = response.status();
415            error!(status = %status, "HTTP error loading flags");
416            let err = Error::Connection(format!("HTTP {}", status));
417            report_local_eval_error(&self.on_error, Some(status.as_u16()), &err);
418            return Err(err);
419        }
420
421        let status = response.status().as_u16();
422        let data = match response.json::<LocalEvaluationResponse>() {
423            Ok(d) => d,
424            Err(e) => {
425                error!(error = %e, "Failed to parse flag response");
426                let err = Error::Serialization(e.to_string());
427                report_local_eval_error(&self.on_error, Some(status), &err);
428                return Err(err);
429            }
430        };
431
432        self.cache.update(data);
433        Ok(())
434    }
435
436    /// Stop the polling thread and wait for it to exit.
437    pub fn stop(&mut self) {
438        debug!("Stopping flag poller");
439        self.stop_signal.store(true, Ordering::Relaxed);
440        if let Some(handle) = self.thread_handle.take() {
441            handle.join().ok();
442        }
443    }
444}
445
446impl Drop for FlagPoller {
447    fn drop(&mut self) {
448        self.stop();
449    }
450}
451
452/// Asynchronous poller for feature flag definitions.
453///
454/// Runs a tokio task that periodically fetches flag definitions from the
455/// PostHog API and updates the shared cache. Use this for async applications.
456/// For blocking/sync applications, use [`FlagPoller`] instead.
457#[cfg(feature = "async-client")]
458pub struct AsyncFlagPoller {
459    config: LocalEvaluationConfig,
460    cache: FlagCache,
461    client: reqwest::Client,
462    stop_signal: Arc<AtomicBool>,
463    task_handle: Option<tokio::task::JoinHandle<()>>,
464    is_running: Arc<tokio::sync::RwLock<bool>>,
465    /// Observability hooks, injected by the client builder before `start`.
466    /// Kept here rather than on `LocalEvaluationConfig` so the public config
467    /// struct stays unchanged.
468    on_error: Vec<OnErrorHook>,
469}
470
471#[cfg(feature = "async-client")]
472impl AsyncFlagPoller {
473    /// Create an asynchronous flag definition poller.
474    ///
475    /// # Parameters
476    ///
477    /// - `config`: Credentials, host, polling interval, and request timeout.
478    /// - `cache`: Shared cache updated by the poller.
479    pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self {
480        let client = reqwest::Client::builder()
481            .timeout(config.request_timeout)
482            .build()
483            .unwrap();
484
485        Self {
486            config,
487            cache,
488            client,
489            stop_signal: Arc::new(AtomicBool::new(false)),
490            task_handle: None,
491            is_running: Arc::new(tokio::sync::RwLock::new(false)),
492            on_error: Vec::new(),
493        }
494    }
495
496    /// Register `on_error` hooks. Called by the client builder before
497    /// [`AsyncFlagPoller::start`]; not part of the public flag-poller API.
498    pub(crate) fn set_on_error(&mut self, hooks: Vec<OnErrorHook>) {
499        self.on_error = hooks;
500    }
501
502    /// Start the polling task.
503    ///
504    /// Performs an initial async load, then refreshes definitions in the
505    /// background until [`AsyncFlagPoller::stop`] is called or the poller is
506    /// dropped.
507    pub async fn start(&mut self) {
508        // Check if already running
509        {
510            let mut is_running = self.is_running.write().await;
511            if *is_running {
512                debug!("Flag poller already running, skipping start");
513                return;
514            }
515            *is_running = true;
516        }
517
518        info!(
519            poll_interval_secs = self.config.poll_interval.as_secs(),
520            "Starting async feature flag poller"
521        );
522
523        // Initial load
524        match self.load_flags().await {
525            Ok(()) => info!("Initial flag definitions loaded successfully"),
526            Err(e) => warn!(error = %e, "Failed to load initial flags, will retry on next poll"),
527        }
528
529        let config = self.config.clone();
530        let cache = self.cache.clone();
531        let stop_signal = self.stop_signal.clone();
532        let is_running = self.is_running.clone();
533        let client = self.client.clone();
534        let on_error = self.on_error.clone();
535
536        let task = tokio::spawn(async move {
537            let mut interval = tokio::time::interval(config.poll_interval);
538            interval.tick().await; // Skip the first immediate tick
539
540            let mut last_etag: Option<String> = None;
541
542            loop {
543                tokio::select! {
544                    _ = interval.tick() => {
545                        if stop_signal.load(Ordering::Relaxed) {
546                            debug!("Async flag poller received stop signal");
547                            break;
548                        }
549
550                        let url = format!(
551                            "{}/flags/definitions/?send_cohorts",
552                            config.api_host.trim_end_matches('/')
553                        );
554
555                        let mut request = client
556                            .get(&url)
557                            .header("Authorization", format!("Bearer {}", config.personal_api_key))
558                            .header("X-PostHog-Project-Api-Key", &config.project_api_key)
559                            .header(USER_AGENT, get_default_user_agent());
560
561                        if let Some(ref etag) = last_etag {
562                            request = request.header(IF_NONE_MATCH, etag.as_str());
563                        }
564
565                        match request.send().await {
566                            Ok(response) => {
567                                let status = response.status();
568                                if status == StatusCode::NOT_MODIFIED {
569                                    debug!("Flag definitions unchanged (304 Not Modified)");
570                                } else if status.is_success() {
571                                    // Extract ETag before consuming the response body
572                                    let new_etag = extract_etag(response.headers());
573
574                                    match response.json::<LocalEvaluationResponse>().await {
575                                        Ok(data) => {
576                                            trace!("Successfully fetched flag definitions");
577                                            cache.update(data);
578                                            last_etag = new_etag;
579                                        }
580                                        Err(e) => {
581                                            warn!(error = %e, "Failed to parse flag response");
582                                            let err = Error::Serialization(e.to_string());
583                                            report_local_eval_error(&on_error, Some(status.as_u16()), &err);
584                                        }
585                                    }
586                                } else {
587                                    warn!(status = %status, "Failed to fetch flags");
588                                    let err = Error::Connection(format!("HTTP {}", status));
589                                    report_local_eval_error(&on_error, Some(status.as_u16()), &err);
590                                }
591                            }
592                            Err(e) => {
593                                warn!(error = %e, "Failed to fetch flags");
594                                let err = Error::Connection(e.to_string());
595                                report_local_eval_error(&on_error, None, &err);
596                            }
597                        }
598                    }
599                }
600            }
601
602            // Clear running flag when task exits
603            *is_running.write().await = false;
604        });
605
606        self.task_handle = Some(task);
607    }
608
609    /// Load flags asynchronously and update the cache once.
610    ///
611    /// # Errors
612    ///
613    /// Returns [`Error::Connection`] for request failures or non-success HTTP
614    /// statuses, and [`Error::Serialization`] when the response cannot be
615    /// parsed.
616    #[instrument(skip(self), level = "debug")]
617    pub async fn load_flags(&self) -> Result<(), Error> {
618        let url = format!(
619            "{}/flags/definitions/?send_cohorts",
620            self.config.api_host.trim_end_matches('/')
621        );
622
623        let response = match self
624            .client
625            .get(&url)
626            .header(
627                "Authorization",
628                format!("Bearer {}", self.config.personal_api_key),
629            )
630            .header("X-PostHog-Project-Api-Key", &self.config.project_api_key)
631            .header(USER_AGENT, get_default_user_agent())
632            .send()
633            .await
634        {
635            Ok(r) => r,
636            Err(e) => {
637                error!(error = %e, "Connection error loading flags");
638                let err = Error::Connection(e.to_string());
639                report_local_eval_error(&self.on_error, None, &err);
640                return Err(err);
641            }
642        };
643
644        if !response.status().is_success() {
645            let status = response.status();
646            error!(status = %status, "HTTP error loading flags");
647            let err = Error::Connection(format!("HTTP {}", status));
648            report_local_eval_error(&self.on_error, Some(status.as_u16()), &err);
649            return Err(err);
650        }
651
652        let status = response.status().as_u16();
653        let data = match response.json::<LocalEvaluationResponse>().await {
654            Ok(d) => d,
655            Err(e) => {
656                error!(error = %e, "Failed to parse flag response");
657                let err = Error::Serialization(e.to_string());
658                report_local_eval_error(&self.on_error, Some(status), &err);
659                return Err(err);
660            }
661        };
662
663        self.cache.update(data);
664        Ok(())
665    }
666
667    /// Stop the polling task.
668    pub async fn stop(&mut self) {
669        debug!("Stopping async flag poller");
670        self.stop_signal.store(true, Ordering::Relaxed);
671        if let Some(handle) = self.task_handle.take() {
672            handle.abort();
673        }
674        *self.is_running.write().await = false;
675    }
676
677    /// Check if the poller currently has a running background task.
678    pub async fn is_running(&self) -> bool {
679        *self.is_running.read().await
680    }
681}
682
683#[cfg(feature = "async-client")]
684impl Drop for AsyncFlagPoller {
685    fn drop(&mut self) {
686        // Abort the task if still running
687        if let Some(handle) = self.task_handle.take() {
688            handle.abort();
689        }
690    }
691}
692
693/// Evaluates feature flags using locally cached definitions.
694///
695/// The evaluator reads from a [`FlagCache`] to determine flag values without
696/// making network requests. Supports cohort membership checks and flag
697/// dependencies through the evaluation context.
698#[derive(Clone)]
699pub struct LocalEvaluator {
700    cache: FlagCache,
701}
702
703pub(crate) struct LocalFlagEvaluation {
704    pub(crate) result: Result<FlagValue, InconclusiveMatchError>,
705    pub(crate) payload: Option<serde_json::Value>,
706    pub(crate) has_experiment: Option<bool>,
707}
708
709fn flag_payload(flag: &FeatureFlag, value: &FlagValue) -> Option<serde_json::Value> {
710    let payload_key = match value {
711        FlagValue::Boolean(true) => "true",
712        FlagValue::Boolean(false) => return None,
713        FlagValue::String(variant) => variant.as_str(),
714    };
715    flag.filters.payloads.get(payload_key).cloned()
716}
717
718impl LocalEvaluator {
719    /// Create an evaluator backed by a shared [`FlagCache`].
720    pub fn new(cache: FlagCache) -> Self {
721        Self { cache }
722    }
723
724    /// Access the underlying flag cache (e.g. to read group type mappings).
725    pub fn cache(&self) -> &FlagCache {
726        &self.cache
727    }
728
729    /// Evaluate a feature flag locally with full context support.
730    ///
731    /// Supports cohort membership checks, flag dependency evaluation, and
732    /// group / mixed-targeting flags. `groups` and `group_properties` are
733    /// only consulted when the flag (or one of its conditions) targets a
734    /// group via `aggregation_group_type_index`; pass empty maps for
735    /// person-targeted flags.
736    ///
737    /// # Returns
738    ///
739    /// `Ok(Some(value))` when the flag is present and evaluated,
740    /// `Ok(None)` when the flag is absent from the cache.
741    ///
742    /// # Errors
743    ///
744    /// Returns [`InconclusiveMatchError`] when required properties, cohorts, or
745    /// dependent flags are unavailable locally.
746    #[instrument(
747        skip(self, person_properties, groups, group_properties),
748        level = "trace"
749    )]
750    pub fn evaluate_flag(
751        &self,
752        key: &str,
753        distinct_id: &str,
754        person_properties: &HashMap<String, serde_json::Value>,
755        groups: &HashMap<String, String>,
756        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
757    ) -> Result<Option<FlagValue>, InconclusiveMatchError> {
758        match self.cache.get_flag(key) {
759            Some(flag) => {
760                // Build evaluation context with cohorts, flags, and group info
761                let cohorts = self.cache.get_cohort_definitions();
762                let flags = self.cache.get_flags_map();
763                let group_type_mapping = self.cache.get_group_type_mapping();
764
765                let ctx = EvaluationContext {
766                    cohorts: &cohorts,
767                    flags: &flags,
768                    distinct_id,
769                    groups,
770                    group_properties,
771                    group_type_mapping: &group_type_mapping,
772                };
773
774                let result = match_feature_flag_with_context(&flag, person_properties, &ctx);
775                trace!(key, ?result, "Local flag evaluation");
776                result.map(Some)
777            }
778            None => {
779                trace!(key, "Flag not found in local cache");
780                Ok(None)
781            }
782        }
783    }
784
785    /// Evaluate a feature flag locally without cohort or flag dependency
786    /// support.
787    ///
788    /// Use this when you know the flag doesn't have cohort or flag dependency
789    /// conditions.
790    ///
791    /// # Returns
792    ///
793    /// `Ok(Some(value))` when the flag is present and evaluated,
794    /// `Ok(None)` when the flag is absent from the cache.
795    ///
796    /// # Errors
797    ///
798    /// Returns [`InconclusiveMatchError`] when required properties are
799    /// unavailable locally.
800    #[instrument(
801        skip(self, person_properties, groups, group_properties),
802        level = "trace"
803    )]
804    pub fn evaluate_flag_simple(
805        &self,
806        key: &str,
807        distinct_id: &str,
808        person_properties: &HashMap<String, serde_json::Value>,
809        groups: &HashMap<String, String>,
810        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
811    ) -> Result<Option<FlagValue>, InconclusiveMatchError> {
812        match self.cache.get_flag(key) {
813            Some(flag) => {
814                let group_type_mapping = self.cache.get_group_type_mapping();
815                let result = match_feature_flag(
816                    &flag,
817                    distinct_id,
818                    person_properties,
819                    groups,
820                    group_properties,
821                    &group_type_mapping,
822                );
823                trace!(key, ?result, "Local flag evaluation (simple)");
824                result.map(Some)
825            }
826            None => {
827                trace!(key, "Flag not found in local cache");
828                Ok(None)
829            }
830        }
831    }
832
833    /// Get all flags and evaluate them with full context support.
834    ///
835    /// The returned map is keyed by feature flag key. Each value can be an
836    /// inconclusive error if that particular flag could not be evaluated from
837    /// the supplied context.
838    #[instrument(
839        skip(self, person_properties, groups, group_properties),
840        level = "debug"
841    )]
842    pub fn evaluate_all_flags(
843        &self,
844        distinct_id: &str,
845        person_properties: &HashMap<String, serde_json::Value>,
846        groups: &HashMap<String, String>,
847        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
848    ) -> HashMap<String, Result<FlagValue, InconclusiveMatchError>> {
849        self.evaluate_all_flags_with_details(
850            distinct_id,
851            person_properties,
852            groups,
853            group_properties,
854        )
855        .into_iter()
856        .map(|(key, evaluation)| (key, evaluation.result))
857        .collect()
858    }
859
860    pub(crate) fn evaluate_all_flags_with_details(
861        &self,
862        distinct_id: &str,
863        person_properties: &HashMap<String, serde_json::Value>,
864        groups: &HashMap<String, String>,
865        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
866    ) -> HashMap<String, LocalFlagEvaluation> {
867        let mut results = HashMap::new();
868
869        // Build one definitions snapshot for evaluation and its metadata.
870        let cohorts = self.cache.get_cohort_definitions();
871        let flags = self.cache.get_flags_map();
872        let group_type_mapping = self.cache.get_group_type_mapping();
873
874        let ctx = EvaluationContext {
875            cohorts: &cohorts,
876            flags: &flags,
877            distinct_id,
878            groups,
879            group_properties,
880            group_type_mapping: &group_type_mapping,
881        };
882
883        for flag in flags.values() {
884            let result = match_feature_flag_with_context(flag, person_properties, &ctx);
885            let payload = result
886                .as_ref()
887                .ok()
888                .and_then(|value| flag_payload(flag, value));
889            results.insert(
890                flag.key.clone(),
891                LocalFlagEvaluation {
892                    result,
893                    payload,
894                    has_experiment: flag.has_experiment,
895                },
896            );
897        }
898
899        debug!(flag_count = results.len(), "Evaluated all local flags");
900        results
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use crate::feature_flags::{FeatureFlagCondition, FeatureFlagFilters};
908    use serde_json::json;
909
910    fn definitions(
911        active: bool,
912        payload: serde_json::Value,
913        has_experiment: bool,
914    ) -> LocalEvaluationResponse {
915        LocalEvaluationResponse {
916            flags: vec![FeatureFlag {
917                key: "snapshot-flag".to_string(),
918                active,
919                filters: FeatureFlagFilters {
920                    groups: vec![FeatureFlagCondition {
921                        properties: vec![],
922                        rollout_percentage: Some(100.0),
923                        variant: None,
924                        aggregation_group_type_index: None,
925                    }],
926                    payloads: HashMap::from([("true".to_string(), payload)]),
927                    ..Default::default()
928                },
929                has_experiment: Some(has_experiment),
930            }],
931            group_type_mapping: HashMap::new(),
932            cohorts: HashMap::new(),
933            minimal_flag_called_events: false,
934        }
935    }
936
937    #[test]
938    fn evaluated_details_survive_a_cache_refresh() {
939        let cache = FlagCache::new();
940        cache.update(definitions(true, json!({"snapshot": "old"}), true));
941        let evaluator = LocalEvaluator::new(cache.clone());
942
943        let evaluations = evaluator.evaluate_all_flags_with_details(
944            "user-1",
945            &HashMap::new(),
946            &HashMap::new(),
947            &HashMap::new(),
948        );
949        cache.update(definitions(false, json!({"snapshot": "new"}), false));
950
951        let evaluation = evaluations.get("snapshot-flag").unwrap();
952        assert!(matches!(evaluation.result, Ok(FlagValue::Boolean(true))));
953        assert_eq!(evaluation.payload, Some(json!({"snapshot": "old"})));
954        assert_eq!(evaluation.has_experiment, Some(true));
955    }
956}