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
16fn 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
26fn 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
45fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct LocalEvaluationResponse {
61 pub flags: Vec<FeatureFlag>,
63 #[serde(default)]
65 pub group_type_mapping: HashMap<String, String>,
66 #[serde(default)]
68 pub cohorts: HashMap<String, Cohort>,
69 #[serde(default)]
73 pub minimal_flag_called_events: bool,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(transparent)]
93pub struct Cohort {
94 pub properties: serde_json::Value,
98}
99
100#[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 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 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 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 pub fn minimal_flag_called_events(&self) -> bool {
160 self.minimal_flag_called_events.load(Ordering::Relaxed)
161 }
162
163 pub fn get_flag(&self, key: &str) -> Option<FeatureFlag> {
165 self.flags.read().unwrap().get(key).cloned()
166 }
167
168 pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
170 self.flags.read().unwrap().values().cloned().collect()
171 }
172
173 pub fn get_cohort(&self, id: &str) -> Option<Cohort> {
175 self.cohorts.read().unwrap().get(id).cloned()
176 }
177
178 pub fn get_all_cohorts(&self) -> HashMap<String, Cohort> {
180 self.cohorts.read().unwrap().clone()
181 }
182
183 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 pub fn get_flags_map(&self) -> HashMap<String, FeatureFlag> {
203 self.flags.read().unwrap().clone()
204 }
205
206 pub fn get_group_type_mapping(&self) -> HashMap<String, String> {
208 self.group_type_mapping.read().unwrap().clone()
209 }
210
211 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#[derive(Clone)]
224pub struct LocalEvaluationConfig {
225 pub personal_api_key: String,
227 pub project_api_key: String,
229 pub api_host: String,
232 pub poll_interval: Duration,
234 pub request_timeout: Duration,
236}
237
238pub 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 on_error: Vec<OnErrorHook>,
254}
255
256impl FlagPoller {
257 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 #[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 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 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 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 #[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 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#[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 on_error: Vec<OnErrorHook>,
469}
470
471#[cfg(feature = "async-client")]
472impl AsyncFlagPoller {
473 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 pub(crate) fn set_on_error(&mut self, hooks: Vec<OnErrorHook>) {
499 self.on_error = hooks;
500 }
501
502 pub async fn start(&mut self) {
508 {
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 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; 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 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 *is_running.write().await = false;
604 });
605
606 self.task_handle = Some(task);
607 }
608
609 #[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 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 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 if let Some(handle) = self.task_handle.take() {
688 handle.abort();
689 }
690 }
691}
692
693#[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 pub fn new(cache: FlagCache) -> Self {
721 Self { cache }
722 }
723
724 pub fn cache(&self) -> &FlagCache {
726 &self.cache
727 }
728
729 #[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 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 #[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 #[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 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}