Skip to main content

posthog_rs/
local_evaluation.rs

1use crate::client::get_default_user_agent;
2use crate::feature_flags::{
3    match_feature_flag, match_feature_flag_with_context, CohortDefinition, EvaluationContext,
4    FeatureFlag, FlagValue, InconclusiveMatchError,
5};
6use crate::Error;
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/// Response from the PostHog local evaluation API.
27///
28/// Contains feature flag definitions, group type mappings, and cohort definitions
29/// that can be cached locally for flag evaluation without server round-trips.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct LocalEvaluationResponse {
32    /// List of feature flag definitions
33    pub flags: Vec<FeatureFlag>,
34    /// Mapping from group type keys to their display names
35    #[serde(default)]
36    pub group_type_mapping: HashMap<String, String>,
37    /// Cohort definitions for evaluating cohort membership
38    #[serde(default)]
39    pub cohorts: HashMap<String, Cohort>,
40}
41
42/// A cohort definition for local evaluation.
43///
44/// Cohorts are groups of users defined by property filters, used for
45/// targeting feature flags to specific user segments.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Cohort {
48    /// Unique identifier for this cohort
49    pub id: String,
50    /// Human-readable name of the cohort
51    pub name: String,
52    /// Property filters that define cohort membership
53    pub properties: serde_json::Value,
54}
55
56/// Thread-safe cache for feature flag definitions.
57///
58/// Stores feature flags, group type mappings, and cohort definitions that have
59/// been fetched from the PostHog API. The cache is shared between the poller
60/// (which updates it) and the evaluator (which reads from it).
61#[derive(Clone)]
62pub struct FlagCache {
63    flags: Arc<RwLock<HashMap<String, FeatureFlag>>>,
64    group_type_mapping: Arc<RwLock<HashMap<String, String>>>,
65    cohorts: Arc<RwLock<HashMap<String, Cohort>>>,
66}
67
68impl Default for FlagCache {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl FlagCache {
75    /// Create an empty shared flag cache.
76    pub fn new() -> Self {
77        Self {
78            flags: Arc::new(RwLock::new(HashMap::new())),
79            group_type_mapping: Arc::new(RwLock::new(HashMap::new())),
80            cohorts: Arc::new(RwLock::new(HashMap::new())),
81        }
82    }
83
84    /// Replace cached flags, group type mappings, and cohorts from a local
85    /// evaluation API response.
86    pub fn update(&self, response: LocalEvaluationResponse) {
87        let flag_count = response.flags.len();
88        let mut flags = self.flags.write().unwrap();
89        flags.clear();
90        for flag in response.flags {
91            flags.insert(flag.key.clone(), flag);
92        }
93
94        let mut mapping = self.group_type_mapping.write().unwrap();
95        *mapping = response.group_type_mapping;
96
97        let mut cohorts = self.cohorts.write().unwrap();
98        *cohorts = response.cohorts;
99
100        debug!(flag_count, "Updated flag cache");
101    }
102
103    /// Return a cached feature flag by key.
104    pub fn get_flag(&self, key: &str) -> Option<FeatureFlag> {
105        self.flags.read().unwrap().get(key).cloned()
106    }
107
108    /// Return all cached feature flag definitions.
109    pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
110        self.flags.read().unwrap().values().cloned().collect()
111    }
112
113    /// Return a cached cohort by ID.
114    pub fn get_cohort(&self, id: &str) -> Option<Cohort> {
115        self.cohorts.read().unwrap().get(id).cloned()
116    }
117
118    /// Return all cached cohorts, keyed by cohort ID.
119    pub fn get_all_cohorts(&self) -> HashMap<String, Cohort> {
120        self.cohorts.read().unwrap().clone()
121    }
122
123    /// Get all cohorts as CohortDefinitions for evaluation context
124    pub fn get_cohort_definitions(&self) -> HashMap<String, CohortDefinition> {
125        self.cohorts
126            .read()
127            .unwrap()
128            .iter()
129            .map(|(k, v)| {
130                (
131                    k.clone(),
132                    CohortDefinition {
133                        id: v.id.clone(),
134                        properties: v.properties.clone(),
135                    },
136                )
137            })
138            .collect()
139    }
140
141    /// Get all flags as a HashMap for evaluation context
142    pub fn get_flags_map(&self) -> HashMap<String, FeatureFlag> {
143        self.flags.read().unwrap().clone()
144    }
145
146    /// Get the group type mapping (group type index → group type name).
147    pub fn get_group_type_mapping(&self) -> HashMap<String, String> {
148        self.group_type_mapping.read().unwrap().clone()
149    }
150
151    /// Remove all cached flags, group type mappings, and cohorts.
152    pub fn clear(&self) {
153        self.flags.write().unwrap().clear();
154        self.group_type_mapping.write().unwrap().clear();
155        self.cohorts.write().unwrap().clear();
156    }
157}
158
159/// Configuration for local flag evaluation.
160///
161/// Specifies the credentials and settings needed to fetch feature flag
162/// definitions from the PostHog API for local evaluation.
163#[derive(Clone)]
164pub struct LocalEvaluationConfig {
165    /// Personal API key for authentication (found in PostHog project settings)
166    pub personal_api_key: String,
167    /// Project API key to identify which project's flags to fetch
168    pub project_api_key: String,
169    /// PostHog API host URL (for example, `https://us.i.posthog.com`).
170    /// Use `https://eu.i.posthog.com` for EU-hosted projects.
171    pub api_host: String,
172    /// How often to poll for updated flag definitions
173    pub poll_interval: Duration,
174    /// Timeout for API requests
175    pub request_timeout: Duration,
176}
177
178/// Synchronous poller for feature flag definitions.
179///
180/// Runs a background thread that periodically fetches flag definitions from
181/// the PostHog API and updates the shared cache. Use this for blocking/sync
182/// applications. With the `async-client` feature enabled, use
183/// [`AsyncFlagPoller`] for async applications instead.
184pub struct FlagPoller {
185    config: LocalEvaluationConfig,
186    cache: FlagCache,
187    client: reqwest::blocking::Client,
188    stop_signal: Arc<AtomicBool>,
189    thread_handle: Option<std::thread::JoinHandle<()>>,
190}
191
192impl FlagPoller {
193    /// Create a synchronous flag definition poller.
194    ///
195    /// # Parameters
196    ///
197    /// - `config`: Credentials, host, polling interval, and request timeout.
198    /// - `cache`: Shared cache updated by the poller.
199    pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self {
200        let client = reqwest::blocking::Client::builder()
201            .timeout(config.request_timeout)
202            .build()
203            .unwrap();
204
205        Self {
206            config,
207            cache,
208            client,
209            stop_signal: Arc::new(AtomicBool::new(false)),
210            thread_handle: None,
211        }
212    }
213
214    /// Start the polling thread.
215    ///
216    /// Performs an initial synchronous load, then refreshes definitions in the
217    /// background until [`FlagPoller::stop`] is called or the poller is dropped.
218    pub fn start(&mut self) {
219        info!(
220            poll_interval_secs = self.config.poll_interval.as_secs(),
221            "Starting feature flag poller"
222        );
223
224        // Initial load
225        match self.load_flags() {
226            Ok(()) => info!("Initial flag definitions loaded successfully"),
227            Err(e) => warn!(error = %e, "Failed to load initial flags, will retry on next poll"),
228        }
229
230        let config = self.config.clone();
231        let cache = self.cache.clone();
232        let stop_signal = self.stop_signal.clone();
233
234        let handle = std::thread::spawn(move || {
235            let client = reqwest::blocking::Client::builder()
236                .timeout(config.request_timeout)
237                .build()
238                .unwrap();
239
240            let mut last_etag: Option<String> = None;
241
242            loop {
243                std::thread::sleep(config.poll_interval);
244
245                if stop_signal.load(Ordering::Relaxed) {
246                    debug!("Flag poller received stop signal");
247                    break;
248                }
249
250                let url = format!(
251                    "{}/flags/definitions/?send_cohorts",
252                    config.api_host.trim_end_matches('/')
253                );
254
255                let mut request = client
256                    .get(&url)
257                    .header(
258                        "Authorization",
259                        format!("Bearer {}", config.personal_api_key),
260                    )
261                    .header("X-PostHog-Project-Api-Key", &config.project_api_key)
262                    .header(USER_AGENT, get_default_user_agent());
263
264                if let Some(ref etag) = last_etag {
265                    request = request.header(IF_NONE_MATCH, etag.as_str());
266                }
267
268                match request.send() {
269                    Ok(response) => {
270                        if response.status() == StatusCode::NOT_MODIFIED {
271                            debug!("Flag definitions unchanged (304 Not Modified)");
272                        } else if response.status().is_success() {
273                            // Extract ETag before consuming the response body
274                            let new_etag = extract_etag(response.headers());
275
276                            match response.json::<LocalEvaluationResponse>() {
277                                Ok(data) => {
278                                    trace!("Successfully fetched flag definitions");
279                                    cache.update(data);
280                                    last_etag = new_etag;
281                                }
282                                Err(e) => {
283                                    warn!(error = %e, "Failed to parse flag response");
284                                }
285                            }
286                        } else {
287                            warn!(status = %response.status(), "Failed to fetch flags");
288                        }
289                    }
290                    Err(e) => {
291                        warn!(error = %e, "Failed to fetch flags");
292                    }
293                }
294            }
295        });
296
297        self.thread_handle = Some(handle);
298    }
299
300    /// Load flags synchronously and update the cache once.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`Error::Connection`] for request failures or non-success HTTP
305    /// statuses, and [`Error::Serialization`] when the response cannot be
306    /// parsed.
307    #[instrument(skip(self), level = "debug")]
308    pub fn load_flags(&self) -> Result<(), Error> {
309        let url = format!(
310            "{}/flags/definitions/?send_cohorts",
311            self.config.api_host.trim_end_matches('/')
312        );
313
314        let response = self
315            .client
316            .get(&url)
317            .header(
318                "Authorization",
319                format!("Bearer {}", self.config.personal_api_key),
320            )
321            .header("X-PostHog-Project-Api-Key", &self.config.project_api_key)
322            .header(USER_AGENT, get_default_user_agent())
323            .send()
324            .map_err(|e| {
325                error!(error = %e, "Connection error loading flags");
326                Error::Connection(e.to_string())
327            })?;
328
329        if !response.status().is_success() {
330            let status = response.status();
331            error!(status = %status, "HTTP error loading flags");
332            return Err(Error::Connection(format!("HTTP {}", status)));
333        }
334
335        let data = response.json::<LocalEvaluationResponse>().map_err(|e| {
336            error!(error = %e, "Failed to parse flag response");
337            Error::Serialization(e.to_string())
338        })?;
339
340        self.cache.update(data);
341        Ok(())
342    }
343
344    /// Stop the polling thread and wait for it to exit.
345    pub fn stop(&mut self) {
346        debug!("Stopping flag poller");
347        self.stop_signal.store(true, Ordering::Relaxed);
348        if let Some(handle) = self.thread_handle.take() {
349            handle.join().ok();
350        }
351    }
352}
353
354impl Drop for FlagPoller {
355    fn drop(&mut self) {
356        self.stop();
357    }
358}
359
360/// Asynchronous poller for feature flag definitions.
361///
362/// Runs a tokio task that periodically fetches flag definitions from the
363/// PostHog API and updates the shared cache. Use this for async applications.
364/// For blocking/sync applications, use [`FlagPoller`] instead.
365#[cfg(feature = "async-client")]
366pub struct AsyncFlagPoller {
367    config: LocalEvaluationConfig,
368    cache: FlagCache,
369    client: reqwest::Client,
370    stop_signal: Arc<AtomicBool>,
371    task_handle: Option<tokio::task::JoinHandle<()>>,
372    is_running: Arc<tokio::sync::RwLock<bool>>,
373}
374
375#[cfg(feature = "async-client")]
376impl AsyncFlagPoller {
377    /// Create an asynchronous flag definition poller.
378    ///
379    /// # Parameters
380    ///
381    /// - `config`: Credentials, host, polling interval, and request timeout.
382    /// - `cache`: Shared cache updated by the poller.
383    pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self {
384        let client = reqwest::Client::builder()
385            .timeout(config.request_timeout)
386            .build()
387            .unwrap();
388
389        Self {
390            config,
391            cache,
392            client,
393            stop_signal: Arc::new(AtomicBool::new(false)),
394            task_handle: None,
395            is_running: Arc::new(tokio::sync::RwLock::new(false)),
396        }
397    }
398
399    /// Start the polling task.
400    ///
401    /// Performs an initial async load, then refreshes definitions in the
402    /// background until [`AsyncFlagPoller::stop`] is called or the poller is
403    /// dropped.
404    pub async fn start(&mut self) {
405        // Check if already running
406        {
407            let mut is_running = self.is_running.write().await;
408            if *is_running {
409                debug!("Flag poller already running, skipping start");
410                return;
411            }
412            *is_running = true;
413        }
414
415        info!(
416            poll_interval_secs = self.config.poll_interval.as_secs(),
417            "Starting async feature flag poller"
418        );
419
420        // Initial load
421        match self.load_flags().await {
422            Ok(()) => info!("Initial flag definitions loaded successfully"),
423            Err(e) => warn!(error = %e, "Failed to load initial flags, will retry on next poll"),
424        }
425
426        let config = self.config.clone();
427        let cache = self.cache.clone();
428        let stop_signal = self.stop_signal.clone();
429        let is_running = self.is_running.clone();
430        let client = self.client.clone();
431
432        let task = tokio::spawn(async move {
433            let mut interval = tokio::time::interval(config.poll_interval);
434            interval.tick().await; // Skip the first immediate tick
435
436            let mut last_etag: Option<String> = None;
437
438            loop {
439                tokio::select! {
440                    _ = interval.tick() => {
441                        if stop_signal.load(Ordering::Relaxed) {
442                            debug!("Async flag poller received stop signal");
443                            break;
444                        }
445
446                        let url = format!(
447                            "{}/flags/definitions/?send_cohorts",
448                            config.api_host.trim_end_matches('/')
449                        );
450
451                        let mut request = client
452                            .get(&url)
453                            .header("Authorization", format!("Bearer {}", config.personal_api_key))
454                            .header("X-PostHog-Project-Api-Key", &config.project_api_key)
455                            .header(USER_AGENT, get_default_user_agent());
456
457                        if let Some(ref etag) = last_etag {
458                            request = request.header(IF_NONE_MATCH, etag.as_str());
459                        }
460
461                        match request.send().await {
462                            Ok(response) => {
463                                if response.status() == StatusCode::NOT_MODIFIED {
464                                    debug!("Flag definitions unchanged (304 Not Modified)");
465                                } else if response.status().is_success() {
466                                    // Extract ETag before consuming the response body
467                                    let new_etag = extract_etag(response.headers());
468
469                                    match response.json::<LocalEvaluationResponse>().await {
470                                        Ok(data) => {
471                                            trace!("Successfully fetched flag definitions");
472                                            cache.update(data);
473                                            last_etag = new_etag;
474                                        }
475                                        Err(e) => {
476                                            warn!(error = %e, "Failed to parse flag response");
477                                        }
478                                    }
479                                } else {
480                                    warn!(status = %response.status(), "Failed to fetch flags");
481                                }
482                            }
483                            Err(e) => {
484                                warn!(error = %e, "Failed to fetch flags");
485                            }
486                        }
487                    }
488                }
489            }
490
491            // Clear running flag when task exits
492            *is_running.write().await = false;
493        });
494
495        self.task_handle = Some(task);
496    }
497
498    /// Load flags asynchronously and update the cache once.
499    ///
500    /// # Errors
501    ///
502    /// Returns [`Error::Connection`] for request failures or non-success HTTP
503    /// statuses, and [`Error::Serialization`] when the response cannot be
504    /// parsed.
505    #[instrument(skip(self), level = "debug")]
506    pub async fn load_flags(&self) -> Result<(), Error> {
507        let url = format!(
508            "{}/flags/definitions/?send_cohorts",
509            self.config.api_host.trim_end_matches('/')
510        );
511
512        let response = self
513            .client
514            .get(&url)
515            .header(
516                "Authorization",
517                format!("Bearer {}", self.config.personal_api_key),
518            )
519            .header("X-PostHog-Project-Api-Key", &self.config.project_api_key)
520            .header(USER_AGENT, get_default_user_agent())
521            .send()
522            .await
523            .map_err(|e| {
524                error!(error = %e, "Connection error loading flags");
525                Error::Connection(e.to_string())
526            })?;
527
528        if !response.status().is_success() {
529            let status = response.status();
530            error!(status = %status, "HTTP error loading flags");
531            return Err(Error::Connection(format!("HTTP {}", status)));
532        }
533
534        let data = response
535            .json::<LocalEvaluationResponse>()
536            .await
537            .map_err(|e| {
538                error!(error = %e, "Failed to parse flag response");
539                Error::Serialization(e.to_string())
540            })?;
541
542        self.cache.update(data);
543        Ok(())
544    }
545
546    /// Stop the polling task.
547    pub async fn stop(&mut self) {
548        debug!("Stopping async flag poller");
549        self.stop_signal.store(true, Ordering::Relaxed);
550        if let Some(handle) = self.task_handle.take() {
551            handle.abort();
552        }
553        *self.is_running.write().await = false;
554    }
555
556    /// Check if the poller currently has a running background task.
557    pub async fn is_running(&self) -> bool {
558        *self.is_running.read().await
559    }
560}
561
562#[cfg(feature = "async-client")]
563impl Drop for AsyncFlagPoller {
564    fn drop(&mut self) {
565        // Abort the task if still running
566        if let Some(handle) = self.task_handle.take() {
567            handle.abort();
568        }
569    }
570}
571
572/// Evaluates feature flags using locally cached definitions.
573///
574/// The evaluator reads from a [`FlagCache`] to determine flag values without
575/// making network requests. Supports cohort membership checks and flag
576/// dependencies through the evaluation context.
577#[derive(Clone)]
578pub struct LocalEvaluator {
579    cache: FlagCache,
580}
581
582impl LocalEvaluator {
583    /// Create an evaluator backed by a shared [`FlagCache`].
584    pub fn new(cache: FlagCache) -> Self {
585        Self { cache }
586    }
587
588    /// Access the underlying flag cache (e.g. to read group type mappings).
589    pub fn cache(&self) -> &FlagCache {
590        &self.cache
591    }
592
593    /// Evaluate a feature flag locally with full context support.
594    ///
595    /// Supports cohort membership checks, flag dependency evaluation, and
596    /// group / mixed-targeting flags. `groups` and `group_properties` are
597    /// only consulted when the flag (or one of its conditions) targets a
598    /// group via `aggregation_group_type_index`; pass empty maps for
599    /// person-targeted flags.
600    ///
601    /// # Returns
602    ///
603    /// `Ok(Some(value))` when the flag is present and evaluated,
604    /// `Ok(None)` when the flag is absent from the cache.
605    ///
606    /// # Errors
607    ///
608    /// Returns [`InconclusiveMatchError`] when required properties, cohorts, or
609    /// dependent flags are unavailable locally.
610    #[instrument(
611        skip(self, person_properties, groups, group_properties),
612        level = "trace"
613    )]
614    pub fn evaluate_flag(
615        &self,
616        key: &str,
617        distinct_id: &str,
618        person_properties: &HashMap<String, serde_json::Value>,
619        groups: &HashMap<String, String>,
620        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
621    ) -> Result<Option<FlagValue>, InconclusiveMatchError> {
622        match self.cache.get_flag(key) {
623            Some(flag) => {
624                // Build evaluation context with cohorts, flags, and group info
625                let cohorts = self.cache.get_cohort_definitions();
626                let flags = self.cache.get_flags_map();
627                let group_type_mapping = self.cache.get_group_type_mapping();
628
629                let ctx = EvaluationContext {
630                    cohorts: &cohorts,
631                    flags: &flags,
632                    distinct_id,
633                    groups,
634                    group_properties,
635                    group_type_mapping: &group_type_mapping,
636                };
637
638                let result = match_feature_flag_with_context(&flag, person_properties, &ctx);
639                trace!(key, ?result, "Local flag evaluation");
640                result.map(Some)
641            }
642            None => {
643                trace!(key, "Flag not found in local cache");
644                Ok(None)
645            }
646        }
647    }
648
649    /// Evaluate a feature flag locally without cohort or flag dependency
650    /// support.
651    ///
652    /// Use this when you know the flag doesn't have cohort or flag dependency
653    /// conditions.
654    ///
655    /// # Returns
656    ///
657    /// `Ok(Some(value))` when the flag is present and evaluated,
658    /// `Ok(None)` when the flag is absent from the cache.
659    ///
660    /// # Errors
661    ///
662    /// Returns [`InconclusiveMatchError`] when required properties are
663    /// unavailable locally.
664    #[instrument(
665        skip(self, person_properties, groups, group_properties),
666        level = "trace"
667    )]
668    pub fn evaluate_flag_simple(
669        &self,
670        key: &str,
671        distinct_id: &str,
672        person_properties: &HashMap<String, serde_json::Value>,
673        groups: &HashMap<String, String>,
674        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
675    ) -> Result<Option<FlagValue>, InconclusiveMatchError> {
676        match self.cache.get_flag(key) {
677            Some(flag) => {
678                let group_type_mapping = self.cache.get_group_type_mapping();
679                let result = match_feature_flag(
680                    &flag,
681                    distinct_id,
682                    person_properties,
683                    groups,
684                    group_properties,
685                    &group_type_mapping,
686                );
687                trace!(key, ?result, "Local flag evaluation (simple)");
688                result.map(Some)
689            }
690            None => {
691                trace!(key, "Flag not found in local cache");
692                Ok(None)
693            }
694        }
695    }
696
697    /// Get all flags and evaluate them with full context support.
698    ///
699    /// The returned map is keyed by feature flag key. Each value can be an
700    /// inconclusive error if that particular flag could not be evaluated from
701    /// the supplied context.
702    #[instrument(
703        skip(self, person_properties, groups, group_properties),
704        level = "debug"
705    )]
706    pub fn evaluate_all_flags(
707        &self,
708        distinct_id: &str,
709        person_properties: &HashMap<String, serde_json::Value>,
710        groups: &HashMap<String, String>,
711        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
712    ) -> HashMap<String, Result<FlagValue, InconclusiveMatchError>> {
713        let mut results = HashMap::new();
714
715        // Build evaluation context once for all flags
716        let cohorts = self.cache.get_cohort_definitions();
717        let flags = self.cache.get_flags_map();
718        let group_type_mapping = self.cache.get_group_type_mapping();
719
720        let ctx = EvaluationContext {
721            cohorts: &cohorts,
722            flags: &flags,
723            distinct_id,
724            groups,
725            group_properties,
726            group_type_mapping: &group_type_mapping,
727        };
728
729        for flag in self.cache.get_all_flags() {
730            let result = match_feature_flag_with_context(&flag, person_properties, &ctx);
731            results.insert(flag.key.clone(), result);
732        }
733
734        debug!(flag_count = results.len(), "Evaluated all local flags");
735        results
736    }
737}