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
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
26#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct LocalEvaluationResponse {
32 pub flags: Vec<FeatureFlag>,
34 #[serde(default)]
36 pub group_type_mapping: HashMap<String, String>,
37 #[serde(default)]
39 pub cohorts: HashMap<String, Cohort>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Cohort {
48 pub id: String,
50 pub name: String,
52 pub properties: serde_json::Value,
54}
55
56#[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 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 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 pub fn get_flag(&self, key: &str) -> Option<FeatureFlag> {
105 self.flags.read().unwrap().get(key).cloned()
106 }
107
108 pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
110 self.flags.read().unwrap().values().cloned().collect()
111 }
112
113 pub fn get_cohort(&self, id: &str) -> Option<Cohort> {
115 self.cohorts.read().unwrap().get(id).cloned()
116 }
117
118 pub fn get_all_cohorts(&self) -> HashMap<String, Cohort> {
120 self.cohorts.read().unwrap().clone()
121 }
122
123 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 pub fn get_flags_map(&self) -> HashMap<String, FeatureFlag> {
143 self.flags.read().unwrap().clone()
144 }
145
146 pub fn get_group_type_mapping(&self) -> HashMap<String, String> {
148 self.group_type_mapping.read().unwrap().clone()
149 }
150
151 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#[derive(Clone)]
164pub struct LocalEvaluationConfig {
165 pub personal_api_key: String,
167 pub project_api_key: String,
169 pub api_host: String,
172 pub poll_interval: Duration,
174 pub request_timeout: Duration,
176}
177
178pub 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 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 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 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 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 #[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 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#[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 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 pub async fn start(&mut self) {
405 {
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 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; 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 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 *is_running.write().await = false;
493 });
494
495 self.task_handle = Some(task);
496 }
497
498 #[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 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 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 if let Some(handle) = self.task_handle.take() {
567 handle.abort();
568 }
569 }
570}
571
572#[derive(Clone)]
578pub struct LocalEvaluator {
579 cache: FlagCache,
580}
581
582impl LocalEvaluator {
583 pub fn new(cache: FlagCache) -> Self {
585 Self { cache }
586 }
587
588 pub fn cache(&self) -> &FlagCache {
590 &self.cache
591 }
592
593 #[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 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 #[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 #[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 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}