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}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Cohort {
77 pub id: String,
79 pub name: String,
81 pub properties: serde_json::Value,
83}
84
85#[derive(Clone)]
91pub struct FlagCache {
92 flags: Arc<RwLock<HashMap<String, FeatureFlag>>>,
93 group_type_mapping: Arc<RwLock<HashMap<String, String>>>,
94 cohorts: Arc<RwLock<HashMap<String, Cohort>>>,
95}
96
97impl Default for FlagCache {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103impl FlagCache {
104 pub fn new() -> Self {
106 Self {
107 flags: Arc::new(RwLock::new(HashMap::new())),
108 group_type_mapping: Arc::new(RwLock::new(HashMap::new())),
109 cohorts: Arc::new(RwLock::new(HashMap::new())),
110 }
111 }
112
113 pub fn update(&self, response: LocalEvaluationResponse) {
116 let flag_count = response.flags.len();
117 let mut flags = self.flags.write().unwrap();
118 flags.clear();
119 for flag in response.flags {
120 flags.insert(flag.key.clone(), flag);
121 }
122
123 let mut mapping = self.group_type_mapping.write().unwrap();
124 *mapping = response.group_type_mapping;
125
126 let mut cohorts = self.cohorts.write().unwrap();
127 *cohorts = response.cohorts;
128
129 debug!(flag_count, "Updated flag cache");
130 }
131
132 pub fn get_flag(&self, key: &str) -> Option<FeatureFlag> {
134 self.flags.read().unwrap().get(key).cloned()
135 }
136
137 pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
139 self.flags.read().unwrap().values().cloned().collect()
140 }
141
142 pub fn get_cohort(&self, id: &str) -> Option<Cohort> {
144 self.cohorts.read().unwrap().get(id).cloned()
145 }
146
147 pub fn get_all_cohorts(&self) -> HashMap<String, Cohort> {
149 self.cohorts.read().unwrap().clone()
150 }
151
152 pub fn get_cohort_definitions(&self) -> HashMap<String, CohortDefinition> {
154 self.cohorts
155 .read()
156 .unwrap()
157 .iter()
158 .map(|(k, v)| {
159 (
160 k.clone(),
161 CohortDefinition {
162 id: v.id.clone(),
163 properties: v.properties.clone(),
164 },
165 )
166 })
167 .collect()
168 }
169
170 pub fn get_flags_map(&self) -> HashMap<String, FeatureFlag> {
172 self.flags.read().unwrap().clone()
173 }
174
175 pub fn get_group_type_mapping(&self) -> HashMap<String, String> {
177 self.group_type_mapping.read().unwrap().clone()
178 }
179
180 pub fn clear(&self) {
182 self.flags.write().unwrap().clear();
183 self.group_type_mapping.write().unwrap().clear();
184 self.cohorts.write().unwrap().clear();
185 }
186}
187
188#[derive(Clone)]
193pub struct LocalEvaluationConfig {
194 pub personal_api_key: String,
196 pub project_api_key: String,
198 pub api_host: String,
201 pub poll_interval: Duration,
203 pub request_timeout: Duration,
205}
206
207pub struct FlagPoller {
214 config: LocalEvaluationConfig,
215 cache: FlagCache,
216 client: reqwest::blocking::Client,
217 stop_signal: Arc<AtomicBool>,
218 thread_handle: Option<std::thread::JoinHandle<()>>,
219 on_error: Vec<OnErrorHook>,
223}
224
225impl FlagPoller {
226 pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self {
233 let client = reqwest::blocking::Client::builder()
234 .timeout(config.request_timeout)
235 .build()
236 .unwrap();
237
238 Self {
239 config,
240 cache,
241 client,
242 stop_signal: Arc::new(AtomicBool::new(false)),
243 thread_handle: None,
244 on_error: Vec::new(),
245 }
246 }
247
248 #[cfg_attr(feature = "async-client", allow(dead_code))]
252 pub(crate) fn set_on_error(&mut self, hooks: Vec<OnErrorHook>) {
253 self.on_error = hooks;
254 }
255
256 pub fn start(&mut self) {
261 info!(
262 poll_interval_secs = self.config.poll_interval.as_secs(),
263 "Starting feature flag poller"
264 );
265
266 match self.load_flags() {
268 Ok(()) => info!("Initial flag definitions loaded successfully"),
269 Err(e) => warn!(error = %e, "Failed to load initial flags, will retry on next poll"),
270 }
271
272 let config = self.config.clone();
273 let cache = self.cache.clone();
274 let stop_signal = self.stop_signal.clone();
275 let on_error = self.on_error.clone();
276
277 let handle = std::thread::spawn(move || {
278 let client = reqwest::blocking::Client::builder()
279 .timeout(config.request_timeout)
280 .build()
281 .unwrap();
282
283 let mut last_etag: Option<String> = None;
284
285 loop {
286 if sleep_until_stop(&stop_signal, config.poll_interval) {
287 debug!("Flag poller received stop signal");
288 break;
289 }
290
291 let url = format!(
292 "{}/flags/definitions/?send_cohorts",
293 config.api_host.trim_end_matches('/')
294 );
295
296 let mut request = client
297 .get(&url)
298 .header(
299 "Authorization",
300 format!("Bearer {}", config.personal_api_key),
301 )
302 .header("X-PostHog-Project-Api-Key", &config.project_api_key)
303 .header(USER_AGENT, get_default_user_agent());
304
305 if let Some(ref etag) = last_etag {
306 request = request.header(IF_NONE_MATCH, etag.as_str());
307 }
308
309 match request.send() {
310 Ok(response) => {
311 let status = response.status();
312 if status == StatusCode::NOT_MODIFIED {
313 debug!("Flag definitions unchanged (304 Not Modified)");
314 } else if status.is_success() {
315 let new_etag = extract_etag(response.headers());
317
318 match response.json::<LocalEvaluationResponse>() {
319 Ok(data) => {
320 trace!("Successfully fetched flag definitions");
321 cache.update(data);
322 last_etag = new_etag;
323 }
324 Err(e) => {
325 warn!(error = %e, "Failed to parse flag response");
326 let err = Error::Serialization(e.to_string());
327 report_local_eval_error(&on_error, Some(status.as_u16()), &err);
328 }
329 }
330 } else {
331 warn!(status = %status, "Failed to fetch flags");
332 let err = Error::Connection(format!("HTTP {}", status));
333 report_local_eval_error(&on_error, Some(status.as_u16()), &err);
334 }
335 }
336 Err(e) => {
337 warn!(error = %e, "Failed to fetch flags");
338 let err = Error::Connection(e.to_string());
339 report_local_eval_error(&on_error, None, &err);
340 }
341 }
342 }
343 });
344
345 self.thread_handle = Some(handle);
346 }
347
348 #[instrument(skip(self), level = "debug")]
356 pub fn load_flags(&self) -> Result<(), Error> {
357 let url = format!(
358 "{}/flags/definitions/?send_cohorts",
359 self.config.api_host.trim_end_matches('/')
360 );
361
362 let response = match self
363 .client
364 .get(&url)
365 .header(
366 "Authorization",
367 format!("Bearer {}", self.config.personal_api_key),
368 )
369 .header("X-PostHog-Project-Api-Key", &self.config.project_api_key)
370 .header(USER_AGENT, get_default_user_agent())
371 .send()
372 {
373 Ok(r) => r,
374 Err(e) => {
375 error!(error = %e, "Connection error loading flags");
376 let err = Error::Connection(e.to_string());
377 report_local_eval_error(&self.on_error, None, &err);
378 return Err(err);
379 }
380 };
381
382 if !response.status().is_success() {
383 let status = response.status();
384 error!(status = %status, "HTTP error loading flags");
385 let err = Error::Connection(format!("HTTP {}", status));
386 report_local_eval_error(&self.on_error, Some(status.as_u16()), &err);
387 return Err(err);
388 }
389
390 let status = response.status().as_u16();
391 let data = match response.json::<LocalEvaluationResponse>() {
392 Ok(d) => d,
393 Err(e) => {
394 error!(error = %e, "Failed to parse flag response");
395 let err = Error::Serialization(e.to_string());
396 report_local_eval_error(&self.on_error, Some(status), &err);
397 return Err(err);
398 }
399 };
400
401 self.cache.update(data);
402 Ok(())
403 }
404
405 pub fn stop(&mut self) {
407 debug!("Stopping flag poller");
408 self.stop_signal.store(true, Ordering::Relaxed);
409 if let Some(handle) = self.thread_handle.take() {
410 handle.join().ok();
411 }
412 }
413}
414
415impl Drop for FlagPoller {
416 fn drop(&mut self) {
417 self.stop();
418 }
419}
420
421#[cfg(feature = "async-client")]
427pub struct AsyncFlagPoller {
428 config: LocalEvaluationConfig,
429 cache: FlagCache,
430 client: reqwest::Client,
431 stop_signal: Arc<AtomicBool>,
432 task_handle: Option<tokio::task::JoinHandle<()>>,
433 is_running: Arc<tokio::sync::RwLock<bool>>,
434 on_error: Vec<OnErrorHook>,
438}
439
440#[cfg(feature = "async-client")]
441impl AsyncFlagPoller {
442 pub fn new(config: LocalEvaluationConfig, cache: FlagCache) -> Self {
449 let client = reqwest::Client::builder()
450 .timeout(config.request_timeout)
451 .build()
452 .unwrap();
453
454 Self {
455 config,
456 cache,
457 client,
458 stop_signal: Arc::new(AtomicBool::new(false)),
459 task_handle: None,
460 is_running: Arc::new(tokio::sync::RwLock::new(false)),
461 on_error: Vec::new(),
462 }
463 }
464
465 pub(crate) fn set_on_error(&mut self, hooks: Vec<OnErrorHook>) {
468 self.on_error = hooks;
469 }
470
471 pub async fn start(&mut self) {
477 {
479 let mut is_running = self.is_running.write().await;
480 if *is_running {
481 debug!("Flag poller already running, skipping start");
482 return;
483 }
484 *is_running = true;
485 }
486
487 info!(
488 poll_interval_secs = self.config.poll_interval.as_secs(),
489 "Starting async feature flag poller"
490 );
491
492 match self.load_flags().await {
494 Ok(()) => info!("Initial flag definitions loaded successfully"),
495 Err(e) => warn!(error = %e, "Failed to load initial flags, will retry on next poll"),
496 }
497
498 let config = self.config.clone();
499 let cache = self.cache.clone();
500 let stop_signal = self.stop_signal.clone();
501 let is_running = self.is_running.clone();
502 let client = self.client.clone();
503 let on_error = self.on_error.clone();
504
505 let task = tokio::spawn(async move {
506 let mut interval = tokio::time::interval(config.poll_interval);
507 interval.tick().await; let mut last_etag: Option<String> = None;
510
511 loop {
512 tokio::select! {
513 _ = interval.tick() => {
514 if stop_signal.load(Ordering::Relaxed) {
515 debug!("Async flag poller received stop signal");
516 break;
517 }
518
519 let url = format!(
520 "{}/flags/definitions/?send_cohorts",
521 config.api_host.trim_end_matches('/')
522 );
523
524 let mut request = client
525 .get(&url)
526 .header("Authorization", format!("Bearer {}", config.personal_api_key))
527 .header("X-PostHog-Project-Api-Key", &config.project_api_key)
528 .header(USER_AGENT, get_default_user_agent());
529
530 if let Some(ref etag) = last_etag {
531 request = request.header(IF_NONE_MATCH, etag.as_str());
532 }
533
534 match request.send().await {
535 Ok(response) => {
536 let status = response.status();
537 if status == StatusCode::NOT_MODIFIED {
538 debug!("Flag definitions unchanged (304 Not Modified)");
539 } else if status.is_success() {
540 let new_etag = extract_etag(response.headers());
542
543 match response.json::<LocalEvaluationResponse>().await {
544 Ok(data) => {
545 trace!("Successfully fetched flag definitions");
546 cache.update(data);
547 last_etag = new_etag;
548 }
549 Err(e) => {
550 warn!(error = %e, "Failed to parse flag response");
551 let err = Error::Serialization(e.to_string());
552 report_local_eval_error(&on_error, Some(status.as_u16()), &err);
553 }
554 }
555 } else {
556 warn!(status = %status, "Failed to fetch flags");
557 let err = Error::Connection(format!("HTTP {}", status));
558 report_local_eval_error(&on_error, Some(status.as_u16()), &err);
559 }
560 }
561 Err(e) => {
562 warn!(error = %e, "Failed to fetch flags");
563 let err = Error::Connection(e.to_string());
564 report_local_eval_error(&on_error, None, &err);
565 }
566 }
567 }
568 }
569 }
570
571 *is_running.write().await = false;
573 });
574
575 self.task_handle = Some(task);
576 }
577
578 #[instrument(skip(self), level = "debug")]
586 pub async fn load_flags(&self) -> Result<(), Error> {
587 let url = format!(
588 "{}/flags/definitions/?send_cohorts",
589 self.config.api_host.trim_end_matches('/')
590 );
591
592 let response = match self
593 .client
594 .get(&url)
595 .header(
596 "Authorization",
597 format!("Bearer {}", self.config.personal_api_key),
598 )
599 .header("X-PostHog-Project-Api-Key", &self.config.project_api_key)
600 .header(USER_AGENT, get_default_user_agent())
601 .send()
602 .await
603 {
604 Ok(r) => r,
605 Err(e) => {
606 error!(error = %e, "Connection error loading flags");
607 let err = Error::Connection(e.to_string());
608 report_local_eval_error(&self.on_error, None, &err);
609 return Err(err);
610 }
611 };
612
613 if !response.status().is_success() {
614 let status = response.status();
615 error!(status = %status, "HTTP error loading flags");
616 let err = Error::Connection(format!("HTTP {}", status));
617 report_local_eval_error(&self.on_error, Some(status.as_u16()), &err);
618 return Err(err);
619 }
620
621 let status = response.status().as_u16();
622 let data = match response.json::<LocalEvaluationResponse>().await {
623 Ok(d) => d,
624 Err(e) => {
625 error!(error = %e, "Failed to parse flag response");
626 let err = Error::Serialization(e.to_string());
627 report_local_eval_error(&self.on_error, Some(status), &err);
628 return Err(err);
629 }
630 };
631
632 self.cache.update(data);
633 Ok(())
634 }
635
636 pub async fn stop(&mut self) {
638 debug!("Stopping async flag poller");
639 self.stop_signal.store(true, Ordering::Relaxed);
640 if let Some(handle) = self.task_handle.take() {
641 handle.abort();
642 }
643 *self.is_running.write().await = false;
644 }
645
646 pub async fn is_running(&self) -> bool {
648 *self.is_running.read().await
649 }
650}
651
652#[cfg(feature = "async-client")]
653impl Drop for AsyncFlagPoller {
654 fn drop(&mut self) {
655 if let Some(handle) = self.task_handle.take() {
657 handle.abort();
658 }
659 }
660}
661
662#[derive(Clone)]
668pub struct LocalEvaluator {
669 cache: FlagCache,
670}
671
672impl LocalEvaluator {
673 pub fn new(cache: FlagCache) -> Self {
675 Self { cache }
676 }
677
678 pub fn cache(&self) -> &FlagCache {
680 &self.cache
681 }
682
683 #[instrument(
701 skip(self, person_properties, groups, group_properties),
702 level = "trace"
703 )]
704 pub fn evaluate_flag(
705 &self,
706 key: &str,
707 distinct_id: &str,
708 person_properties: &HashMap<String, serde_json::Value>,
709 groups: &HashMap<String, String>,
710 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
711 ) -> Result<Option<FlagValue>, InconclusiveMatchError> {
712 match self.cache.get_flag(key) {
713 Some(flag) => {
714 let cohorts = self.cache.get_cohort_definitions();
716 let flags = self.cache.get_flags_map();
717 let group_type_mapping = self.cache.get_group_type_mapping();
718
719 let ctx = EvaluationContext {
720 cohorts: &cohorts,
721 flags: &flags,
722 distinct_id,
723 groups,
724 group_properties,
725 group_type_mapping: &group_type_mapping,
726 };
727
728 let result = match_feature_flag_with_context(&flag, person_properties, &ctx);
729 trace!(key, ?result, "Local flag evaluation");
730 result.map(Some)
731 }
732 None => {
733 trace!(key, "Flag not found in local cache");
734 Ok(None)
735 }
736 }
737 }
738
739 #[instrument(
755 skip(self, person_properties, groups, group_properties),
756 level = "trace"
757 )]
758 pub fn evaluate_flag_simple(
759 &self,
760 key: &str,
761 distinct_id: &str,
762 person_properties: &HashMap<String, serde_json::Value>,
763 groups: &HashMap<String, String>,
764 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
765 ) -> Result<Option<FlagValue>, InconclusiveMatchError> {
766 match self.cache.get_flag(key) {
767 Some(flag) => {
768 let group_type_mapping = self.cache.get_group_type_mapping();
769 let result = match_feature_flag(
770 &flag,
771 distinct_id,
772 person_properties,
773 groups,
774 group_properties,
775 &group_type_mapping,
776 );
777 trace!(key, ?result, "Local flag evaluation (simple)");
778 result.map(Some)
779 }
780 None => {
781 trace!(key, "Flag not found in local cache");
782 Ok(None)
783 }
784 }
785 }
786
787 #[instrument(
793 skip(self, person_properties, groups, group_properties),
794 level = "debug"
795 )]
796 pub fn evaluate_all_flags(
797 &self,
798 distinct_id: &str,
799 person_properties: &HashMap<String, serde_json::Value>,
800 groups: &HashMap<String, String>,
801 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
802 ) -> HashMap<String, Result<FlagValue, InconclusiveMatchError>> {
803 let mut results = HashMap::new();
804
805 let cohorts = self.cache.get_cohort_definitions();
807 let flags = self.cache.get_flags_map();
808 let group_type_mapping = self.cache.get_group_type_mapping();
809
810 let ctx = EvaluationContext {
811 cohorts: &cohorts,
812 flags: &flags,
813 distinct_id,
814 groups,
815 group_properties,
816 group_type_mapping: &group_type_mapping,
817 };
818
819 for flag in self.cache.get_all_flags() {
820 let result = match_feature_flag_with_context(&flag, person_properties, &ctx);
821 results.insert(flag.key.clone(), result);
822 }
823
824 debug!(flag_count = results.len(), "Evaluated all local flags");
825 results
826 }
827}