1use std::collections::{HashMap, HashSet};
2#[cfg(feature = "error-tracking")]
3use std::error::Error as StdError;
4use std::sync::{Arc, OnceLock};
5use std::time::Duration;
6
7use reqwest::{header::CONTENT_TYPE, header::USER_AGENT, Client as HttpClient};
8use serde::Serialize;
9use serde_json::json;
10use tracing::{debug, instrument, trace, warn};
11
12use super::get_default_user_agent;
13use crate::endpoints::Endpoint;
14#[cfg(feature = "error-tracking")]
15use crate::error_tracking::{build_exception_event, CaptureExceptionOptions};
16use crate::feature_flag_evaluations::{
17 EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost,
18 FlagCalledEventParams,
19};
20use crate::feature_flags::{match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagValue};
21use crate::local_evaluation::{AsyncFlagPoller, FlagCache, LocalEvaluationConfig, LocalEvaluator};
22use crate::{Error, Event};
23
24fn is_retryable_feature_flags_error(err: &reqwest::Error) -> bool {
25 if err.is_timeout() {
26 return true;
27 }
28
29 let mut source = std::error::Error::source(err);
30 while let Some(error) = source {
31 if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
32 return matches!(
33 io_error.kind(),
34 std::io::ErrorKind::ConnectionReset
35 | std::io::ErrorKind::TimedOut
36 | std::io::ErrorKind::UnexpectedEof
37 );
38 }
39 source = std::error::Error::source(error);
40 }
41
42 !err.to_string()
43 .to_lowercase()
44 .contains("connection refused")
45}
46
47use super::common::{
48 already_reported, build_dedup_key, extract_flag_details, flag_called_event,
49 flag_event_dedup_cache, local_record, remote_record_from_detail, report_flags_error,
50 DetailedFlagsResponse, FlagEventDedupCache,
51};
52use super::transport::{Completion, Control, TransportHandle};
53use super::{CaptureSummary, ClientOptions};
54#[cfg(not(feature = "capture-v1"))]
55use reqwest::header::CONTENT_ENCODING;
56
57pub struct Client {
59 options: ClientOptions,
60 client: HttpClient,
61 local_evaluator: Option<LocalEvaluator>,
62 _flag_poller: Option<AsyncFlagPoller>,
63 flag_event_host: OnceLock<Arc<dyn FeatureFlagEvaluationsHost>>,
64 transport: Option<Arc<TransportHandle>>,
66}
67
68struct AsyncFlagEventHost {
72 options: ClientOptions,
73 transport: Option<Arc<TransportHandle>>,
74 dedup_cache: FlagEventDedupCache,
75}
76
77impl AsyncFlagEventHost {
78 fn from_options(options: &ClientOptions, transport: Option<Arc<TransportHandle>>) -> Self {
79 Self {
80 options: options.clone(),
81 transport,
82 dedup_cache: flag_event_dedup_cache(),
83 }
84 }
85
86 fn enqueue(&self, event: Event) {
87 if let Some(transport) = &self.transport {
88 transport.enqueue(event);
89 }
90 }
91}
92
93impl FeatureFlagEvaluationsHost for AsyncFlagEventHost {
94 fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) {
95 let dedup_key = build_dedup_key(¶ms.key, params.response.as_ref(), ¶ms.groups);
96 if already_reported(&self.dedup_cache, ¶ms.distinct_id, &dedup_key) {
97 return;
98 }
99
100 if let Some(event) =
101 flag_called_event(params, self.options.disable_geoip, self.options.is_server)
102 {
103 self.enqueue(event);
104 }
105 }
106
107 fn log_warning(&self, message: &str) {
108 warn!("{message}");
111 }
112}
113
114pub async fn client<C: Into<ClientOptions>>(options: C) -> Client {
130 let options = options.into().sanitize();
131 let client = HttpClient::builder()
132 .timeout(Duration::from_secs(options.request_timeout_seconds))
133 .build()
134 .unwrap(); let (local_evaluator, flag_poller) =
137 if options.enable_local_evaluation && !options.is_disabled() {
138 if let Some(ref secret_key) = options.secret_key {
139 let cache = FlagCache::new();
140
141 let config = LocalEvaluationConfig {
142 personal_api_key: secret_key.clone(),
143 project_api_key: options.api_key.clone(),
144 api_host: options.endpoints().api_host(),
145 poll_interval: Duration::from_secs(options.poll_interval_seconds),
146 request_timeout: Duration::from_secs(options.request_timeout_seconds),
147 };
148
149 let mut poller = AsyncFlagPoller::new(config, cache.clone());
150 poller.set_on_error(options.on_error.clone());
151 poller.start().await;
152
153 (Some(LocalEvaluator::new(cache)), Some(poller))
154 } else {
155 warn!(
156 "Local evaluation enabled but secret_key not set, falling back to API evaluation"
157 );
158 (None, None)
159 }
160 } else {
161 (None, None)
162 };
163
164 let transport = if options.is_disabled() {
165 None
166 } else {
167 Some(Arc::new(TransportHandle::spawn(options.clone())))
168 };
169
170 Client {
171 options,
172 client,
173 local_evaluator,
174 _flag_poller: flag_poller,
175 flag_event_host: OnceLock::new(),
176 transport,
177 }
178}
179
180impl Client {
181 #[instrument(skip(self, event), level = "debug")]
196 pub fn capture(&self, event: Event) {
197 if let Some(transport) = &self.transport {
198 transport.enqueue(event);
199 }
200 }
201
202 pub fn alias<P: Into<String>, D: Into<String>>(&self, previous_id: P, distinct_id: D) {
229 if let Some(event) = Event::alias(previous_id.into(), distinct_id.into()) {
230 self.capture(event);
231 }
232 }
233
234 pub fn group_identify<T: Into<String>, K: Into<String>, P: Serialize>(
276 &self,
277 group_type: T,
278 group_key: K,
279 properties: P,
280 ) -> Result<(), Error> {
281 if let Some(event) = Event::group_identify(group_type.into(), group_key.into(), properties)?
282 {
283 self.capture(event);
284 }
285 Ok(())
286 }
287
288 pub async fn flush(&self) {
292 let Some(transport) = &self.transport else {
293 return;
294 };
295 if transport.is_closed() {
296 return;
297 }
298 let (tx, rx) = tokio::sync::oneshot::channel();
299 if transport.send_control(Control::Flush(Completion::Async(tx))) {
300 let _ = rx.await;
301 }
302 }
303
304 #[cfg(feature = "error-tracking")]
307 pub(crate) fn is_disabled(&self) -> bool {
308 self.options.is_disabled()
309 }
310
311 #[cfg(feature = "error-tracking")]
314 pub(crate) fn error_tracking_options(&self) -> &crate::error_tracking::ErrorTrackingOptions {
315 self.options.error_tracking()
316 }
317
318 #[cfg(test)]
322 pub(crate) fn flush_blocking(&self) {
323 if let Some(transport) = &self.transport {
324 transport.flush_blocking();
325 }
326 }
327
328 #[cfg(feature = "error-tracking")]
332 pub(crate) fn flush_blocking_timeout(&self, timeout: Duration) {
333 if let Some(transport) = &self.transport {
334 transport.flush_blocking_timeout(timeout);
335 }
336 }
337
338 #[cfg(feature = "error-tracking")]
341 pub(crate) fn on_transport_worker(&self) -> bool {
342 self.transport
343 .as_ref()
344 .is_some_and(|t| t.on_worker_thread())
345 }
346
347 #[cfg(feature = "error-tracking")]
352 pub(crate) fn enqueue_panic_event(&self, event: Event) {
353 if let Some(transport) = &self.transport {
354 transport.enqueue_panic(event);
355 }
356 }
357
358 pub async fn shutdown(&self) {
363 let Some(transport) = &self.transport else {
364 return;
365 };
366 let on_worker = transport.on_worker_thread();
367 if transport.begin_close() {
368 let (tx, rx) = tokio::sync::oneshot::channel();
369 if transport.send_control(Control::Shutdown(Completion::Async(tx))) && !on_worker {
370 let _ = rx.await;
371 }
372 }
373 if !on_worker {
378 transport.join();
379 }
380 }
381
382 #[cfg(feature = "error-tracking")]
408 pub async fn capture_exception<E>(&self, error: &E) -> Result<(), Error>
409 where
410 E: StdError + ?Sized,
411 {
412 self.capture_exception_with(error, CaptureExceptionOptions::default())
413 .await
414 }
415
416 #[cfg(feature = "error-tracking")]
443 pub async fn capture_exception_with<E>(
444 &self,
445 error: &E,
446 options: CaptureExceptionOptions,
447 ) -> Result<(), Error>
448 where
449 E: StdError + ?Sized,
450 {
451 if self.options.is_disabled() {
452 trace!("Client is disabled, skipping exception capture");
453 return Ok(());
454 }
455
456 self.capture(build_exception_event(
457 error,
458 options,
459 self.options.error_tracking(),
460 )?);
461 Ok(())
462 }
463
464 pub fn capture_batch(&self, events: Vec<Event>, historical_migration: bool) {
480 if let Some(transport) = &self.transport {
481 if historical_migration {
482 transport.enqueue_historical(events);
483 } else {
484 for event in events {
485 transport.enqueue(event);
486 }
487 }
488 }
489 }
490
491 #[must_use = "the delivery outcome should be inspected"]
506 pub async fn capture_immediate(&self, event: Event) -> Result<CaptureSummary, Error> {
507 self.capture_batch_immediate(vec![event], false).await
508 }
509
510 #[must_use = "the delivery outcome should be inspected"]
537 #[instrument(
538 skip(self, events),
539 fields(event_count = events.len(), historical_migration),
540 level = "debug"
541 )]
542 pub async fn capture_batch_immediate(
543 &self,
544 events: Vec<Event>,
545 historical_migration: bool,
546 ) -> Result<CaptureSummary, Error> {
547 if self.options.is_disabled() || events.is_empty() {
548 return Ok(CaptureSummary::default());
549 }
550 self.send_immediate(events, historical_migration).await
551 }
552
553 #[cfg(feature = "capture-v1")]
557 async fn send_immediate(
558 &self,
559 events: Vec<Event>,
560 historical_migration: bool,
561 ) -> Result<CaptureSummary, Error> {
562 use super::v1_capture::{self, Step};
563
564 let Some(mut prep) =
565 v1_capture::prepare_immediate(&self.options, events, historical_migration)
566 else {
567 return Ok(CaptureSummary::default());
568 };
569 let mut final_results = HashMap::new();
570 let mut attempt: u32 = 1;
571
572 loop {
573 let (headers, body) = v1_capture::build_attempt_parts(
574 &self.options,
575 &prep.request_id,
576 attempt,
577 &prep.created_at,
578 prep.historical_migration,
579 &prep.pending,
580 )?;
581
582 let step = match self
583 .client
584 .post(&prep.url)
585 .headers(headers)
586 .body(body)
587 .send()
588 .await
589 {
590 Err(e) => v1_capture::after_transport_error(
591 &self.options,
592 &prep.request_id,
593 attempt,
594 e.to_string(),
595 ),
596 Ok(response) => {
597 let status = response.status().as_u16();
598 let retry_after = v1_capture::parse_retry_after(response.headers());
599 let text = response
600 .text()
601 .await
602 .unwrap_or_else(|_| "Unknown error".to_string());
603 v1_capture::after_response(
604 &self.options,
605 &prep.request_id,
606 attempt,
607 status,
608 retry_after,
609 &text,
610 &mut prep.pending,
611 &mut final_results,
612 )
613 }
614 };
615
616 match step {
617 Step::Done => {
618 return Ok(CaptureSummary::from_results(prep.submitted, final_results))
619 }
620 Step::Fail(e) => return Err(e),
621 Step::Backoff(delay) => {
622 attempt += 1;
623 tokio::time::sleep(delay).await;
624 }
625 }
626 }
627 }
628
629 #[cfg(not(feature = "capture-v1"))]
632 async fn send_immediate(
633 &self,
634 events: Vec<Event>,
635 historical_migration: bool,
636 ) -> Result<CaptureSummary, Error> {
637 use super::retry::{self, v0_after_response, v0_after_transport_error, Step};
638 use super::v0_capture;
639
640 let Some(prep) =
641 v0_capture::prepare_immediate(&self.options, events, historical_migration)?
642 else {
643 return Ok(CaptureSummary::default());
644 };
645
646 let mut attempt: u32 = 1;
647 loop {
648 let mut request = self
649 .client
650 .post(&prep.url)
651 .header(CONTENT_TYPE, "application/json")
652 .header(USER_AGENT, get_default_user_agent())
653 .body(prep.body.clone());
654 if let Some(token) = prep.encoding {
655 request = request.header(CONTENT_ENCODING, token);
656 }
657 #[cfg(feature = "test-harness")]
658 if let Some(ref extra) = self.options.extra_capture_headers {
659 for (k, v) in extra {
660 request = request.header(k.as_str(), v.as_str());
661 }
662 }
663
664 let step = match request.send().await {
665 Err(e) => v0_after_transport_error(&self.options, attempt, e.to_string()),
666 Ok(response) => {
667 let status = response.status().as_u16();
668 let retry_after = retry::parse_retry_after(response.headers());
669 let text = response
670 .text()
671 .await
672 .unwrap_or_else(|_| "Unknown error".to_string());
673 v0_after_response(&self.options, attempt, status, retry_after, &text)
674 }
675 };
676
677 match step {
678 Step::Done => return Ok(CaptureSummary::delivered(prep.kept)),
679 Step::Fail(e) => return Err(e),
680 Step::Backoff(delay) => {
681 attempt += 1;
682 tokio::time::sleep(delay).await;
683 }
684 }
685 }
686 }
687
688 #[cfg(feature = "test-harness")]
695 pub fn pending_events(&self) -> usize {
696 self.transport.as_ref().map_or(0, |t| t.pending())
697 }
698
699 #[must_use = "feature flags result should be used"]
725 pub async fn get_feature_flags<S: Into<String>>(
726 &self,
727 distinct_id: S,
728 groups: Option<HashMap<String, String>>,
729 person_properties: Option<HashMap<String, serde_json::Value>>,
730 group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
731 ) -> Result<
732 (
733 HashMap<String, FlagValue>,
734 HashMap<String, serde_json::Value>,
735 ),
736 Error,
737 > {
738 if self.options.is_disabled() {
739 trace!("Client is disabled, skipping feature flags request");
740 return Ok((HashMap::new(), HashMap::new()));
741 }
742
743 let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
744
745 let mut payload = json!({
746 "api_key": self.options.api_key,
747 "distinct_id": distinct_id.into(),
748 });
749
750 if let Some(groups) = groups {
751 payload["groups"] = json!(groups);
752 }
753
754 if let Some(person_properties) = person_properties {
755 payload["person_properties"] = json!(person_properties);
756 }
757
758 if let Some(group_properties) = group_properties {
759 payload["group_properties"] = json!(group_properties);
760 }
761
762 if self.options.disable_geoip {
764 payload["disable_geoip"] = json!(true);
765 }
766
767 let response = self
768 .send_feature_flags_request(&flags_endpoint, &payload)
769 .await?;
770
771 let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str());
772 if !response.status().is_success() {
773 let status = response.status();
774 let text = response
775 .text()
776 .await
777 .unwrap_or_else(|_| "Unknown error".to_string());
778 let err = Error::Connection(format!("API request failed with status {status}: {text}"));
779 report_flags_error(
780 &self.options.on_error,
781 &flags_endpoint,
782 distinct_id,
783 Some(status.as_u16()),
784 Some(&text),
785 &err,
786 );
787 return Err(err);
788 }
789
790 let status = response.status().as_u16();
791 let flags_response = match response.json::<FeatureFlagsResponse>().await {
792 Ok(r) => r,
793 Err(e) => {
794 let err =
795 Error::Serialization(format!("Failed to parse feature flags response: {e}"));
796 report_flags_error(
797 &self.options.on_error,
798 &flags_endpoint,
799 distinct_id,
800 Some(status),
801 None,
802 &err,
803 );
804 return Err(err);
805 }
806 };
807
808 Ok(flags_response.normalize())
809 }
810
811 #[must_use = "feature flag result should be used"]
832 #[instrument(skip_all, level = "debug")]
833 #[deprecated(
834 since = "0.6.0",
835 note = "Use Client::evaluate_flags() to fetch a snapshot, then call .get_flag(key) on it. \
836 The snapshot deduplicates $feature_flag_called events and supports attaching \
837 rich metadata to captured events via Event::with_flags()."
838 )]
839 pub async fn get_feature_flag<K: Into<String>, D: Into<String>>(
840 &self,
841 key: K,
842 distinct_id: D,
843 groups: Option<HashMap<String, String>>,
844 person_properties: Option<HashMap<String, serde_json::Value>>,
845 group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
846 ) -> Result<Option<FlagValue>, Error> {
847 let key_str = key.into();
848 let distinct_id_str = distinct_id.into();
849
850 if let Some(ref evaluator) = self.local_evaluator {
852 let empty_props = HashMap::new();
853 let empty_groups: HashMap<String, String> = HashMap::new();
854 let empty_group_props: HashMap<String, HashMap<String, serde_json::Value>> =
855 HashMap::new();
856 let mut local_props;
857 let props = if let Some(props) = person_properties.as_ref() {
858 local_props = props.clone();
859 local_props
860 .entry("distinct_id".to_string())
861 .or_insert_with(|| json!(distinct_id_str.clone()));
862 &local_props
863 } else {
864 local_props = empty_props;
865 local_props.insert("distinct_id".to_string(), json!(distinct_id_str.clone()));
866 &local_props
867 };
868 let groups_ref = groups.as_ref().unwrap_or(&empty_groups);
869 let group_props_ref = group_properties.as_ref().unwrap_or(&empty_group_props);
870 match evaluator.evaluate_flag(
871 &key_str,
872 &distinct_id_str,
873 props,
874 groups_ref,
875 group_props_ref,
876 ) {
877 Ok(Some(value)) => {
878 debug!(flag = %key_str, ?value, "Flag evaluated locally");
879 return Ok(Some(value));
880 }
881 Ok(None) => {
882 if self.options.local_evaluation_only {
883 debug!(flag = %key_str, "Flag not found locally, skipping remote fallback");
884 return Ok(None);
885 }
886 debug!(flag = %key_str, "Flag not found locally, falling back to API");
887 }
888 Err(e) => {
889 if self.options.local_evaluation_only {
890 debug!(flag = %key_str, error = %e.message, "Inconclusive local evaluation, skipping remote fallback");
891 return Ok(None);
892 }
893 debug!(flag = %key_str, error = %e.message, "Inconclusive local evaluation, falling back to API");
894 }
895 }
896 }
897
898 trace!(flag = %key_str, "Fetching flag from API");
900 let (feature_flags, _payloads) = self
901 .get_feature_flags(distinct_id_str, groups, person_properties, group_properties)
902 .await?;
903 Ok(feature_flags.get(&key_str).cloned())
904 }
905
906 #[must_use = "feature flag enabled check result should be used"]
917 #[deprecated(
918 since = "0.6.0",
919 note = "Use Client::evaluate_flags() to fetch a snapshot, then call .is_enabled(key) \
920 on it. The snapshot deduplicates $feature_flag_called events and supports \
921 attaching rich metadata to captured events via Event::with_flags()."
922 )]
923 #[allow(deprecated)] pub async fn is_feature_enabled<K: Into<String>, D: Into<String>>(
925 &self,
926 key: K,
927 distinct_id: D,
928 groups: Option<HashMap<String, String>>,
929 person_properties: Option<HashMap<String, serde_json::Value>>,
930 group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
931 ) -> Result<bool, Error> {
932 let flag_value = self
933 .get_feature_flag(
934 key.into(),
935 distinct_id.into(),
936 groups,
937 person_properties,
938 group_properties,
939 )
940 .await?;
941 Ok(match flag_value {
942 Some(FlagValue::Boolean(b)) => b,
943 Some(FlagValue::String(_)) => true, None => false,
945 })
946 }
947
948 #[must_use = "feature flag payload result should be used"]
965 #[deprecated(
966 since = "0.6.0",
967 note = "Use Client::evaluate_flags() to fetch a snapshot, then call \
968 .get_flag_payload(key) on it. Reading the payload from a snapshot is \
969 event-free, matching this method's behavior, and avoids the per-call \
970 /flags request."
971 )]
972 pub async fn get_feature_flag_payload<K: Into<String>, D: Into<String>>(
973 &self,
974 key: K,
975 distinct_id: D,
976 ) -> Result<Option<serde_json::Value>, Error> {
977 if self.options.is_disabled() {
978 trace!("Client is disabled, skipping feature flag payload request");
979 return Ok(None);
980 }
981
982 let key_str = key.into();
983 let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
984
985 let mut payload = json!({
986 "api_key": self.options.api_key,
987 "distinct_id": distinct_id.into(),
988 });
989
990 if self.options.disable_geoip {
992 payload["disable_geoip"] = json!(true);
993 }
994
995 let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str());
996 let response = match self
997 .client
998 .post(&flags_endpoint)
999 .header(CONTENT_TYPE, "application/json")
1000 .header(USER_AGENT, get_default_user_agent())
1001 .json(&payload)
1002 .timeout(Duration::from_secs(
1003 self.options.feature_flags_request_timeout_seconds,
1004 ))
1005 .send()
1006 .await
1007 {
1008 Ok(r) => r,
1009 Err(e) => {
1010 let err = Error::Connection(e.to_string());
1011 report_flags_error(
1012 &self.options.on_error,
1013 &flags_endpoint,
1014 distinct_id,
1015 None,
1016 None,
1017 &err,
1018 );
1019 return Err(err);
1020 }
1021 };
1022
1023 if !response.status().is_success() {
1024 return Ok(None);
1025 }
1026
1027 let status = response.status().as_u16();
1028 let flags_response: FeatureFlagsResponse = match response.json().await {
1029 Ok(r) => r,
1030 Err(e) => {
1031 let err = Error::Serialization(format!("Failed to parse response: {e}"));
1032 report_flags_error(
1033 &self.options.on_error,
1034 &flags_endpoint,
1035 distinct_id,
1036 Some(status),
1037 None,
1038 &err,
1039 );
1040 return Err(err);
1041 }
1042 };
1043
1044 let (_flags, payloads) = flags_response.normalize();
1045 Ok(payloads.get(&key_str).cloned())
1046 }
1047
1048 #[allow(clippy::too_many_arguments)]
1068 pub fn evaluate_feature_flag_locally(
1069 &self,
1070 flag: &FeatureFlag,
1071 distinct_id: &str,
1072 person_properties: &HashMap<String, serde_json::Value>,
1073 groups: &HashMap<String, String>,
1074 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
1075 ) -> Result<FlagValue, Error> {
1076 let group_type_mapping = self
1077 .local_evaluator
1078 .as_ref()
1079 .map(|ev| ev.cache().get_group_type_mapping())
1080 .unwrap_or_default();
1081 match_feature_flag(
1082 flag,
1083 distinct_id,
1084 person_properties,
1085 groups,
1086 group_properties,
1087 &group_type_mapping,
1088 )
1089 .map_err(|e| Error::InconclusiveMatch(e.message))
1090 }
1091
1092 pub async fn evaluate_flags<S: Into<String>>(
1115 &self,
1116 distinct_id: S,
1117 options: EvaluateFlagsOptions,
1118 ) -> Result<FeatureFlagEvaluations, Error> {
1119 let distinct_id: String = distinct_id.into();
1120 let host = self.flag_event_host();
1121 if distinct_id.is_empty() || self.options.is_disabled() {
1122 return Ok(FeatureFlagEvaluations::empty(host));
1123 }
1124
1125 if options.flag_keys.as_ref().is_some_and(Vec::is_empty) {
1126 return Ok(FeatureFlagEvaluations::new(
1127 host,
1128 distinct_id,
1129 HashMap::new(),
1130 options.groups.unwrap_or_default(),
1131 options.disable_geoip,
1132 None,
1133 None,
1134 false,
1135 false,
1136 ));
1137 }
1138
1139 let mut options = options;
1140 options.groups.get_or_insert_with(HashMap::new);
1141 options.group_properties.get_or_insert_with(HashMap::new);
1142
1143 let mut records: HashMap<String, EvaluatedFlagRecord> = HashMap::new();
1144 let mut locally_evaluated_keys: HashSet<String> = HashSet::new();
1145
1146 if let Some(evaluator) = &self.local_evaluator {
1147 let mut person_props_owned = options.person_properties.clone().unwrap_or_default();
1148 person_props_owned
1149 .entry("distinct_id".to_string())
1150 .or_insert_with(|| json!(distinct_id.clone()));
1151 let groups_owned = options.groups.clone().unwrap_or_default();
1152 let group_props_owned = options.group_properties.clone().unwrap_or_default();
1153 let local_results = evaluator.evaluate_all_flags_with_details(
1154 &distinct_id,
1155 &person_props_owned,
1156 &groups_owned,
1157 &group_props_owned,
1158 );
1159 let local_minimal_gate = evaluator.cache().minimal_flag_called_events();
1163 for (key, result) in local_results {
1164 if let Some(filter) = &options.flag_keys {
1165 if !filter.iter().any(|k| k == &key) {
1166 continue;
1167 }
1168 }
1169 if let Ok(value) = result.result {
1170 records.insert(
1171 key.clone(),
1172 local_record(
1173 value,
1174 result.payload,
1175 result.has_experiment,
1176 local_minimal_gate,
1177 ),
1178 );
1179 locally_evaluated_keys.insert(key);
1180 }
1181 }
1182 }
1183
1184 let mut request_id: Option<String> = None;
1185 let mut errors_while_computing = false;
1186 let mut quota_limited = false;
1187
1188 let local_covers_request = options
1193 .flag_keys
1194 .as_ref()
1195 .is_some_and(|keys| keys.iter().all(|k| locally_evaluated_keys.contains(k)));
1196
1197 if !options.only_evaluate_locally && !local_covers_request {
1198 match self.fetch_flag_details(&distinct_id, &options).await {
1203 Ok(response) => {
1204 request_id = response.request_id;
1205 errors_while_computing = response.errors_while_computing_flags;
1206 quota_limited = response.quota_limited;
1207 let remote_minimal_gate = response.minimal_flag_called_events;
1210 for (key, detail) in response.flags {
1211 if locally_evaluated_keys.contains(&key) {
1212 continue;
1213 }
1214 records.insert(key, remote_record_from_detail(detail, remote_minimal_gate));
1215 }
1216 }
1217 Err(e) => {
1218 if records.is_empty() {
1219 return Err(e);
1220 }
1221 debug!(
1222 error = e.to_string(),
1223 local_count = records.len(),
1224 "/flags fetch failed; returning snapshot from local results only"
1225 );
1226 errors_while_computing = true;
1227 }
1228 }
1229 }
1230
1231 Ok(FeatureFlagEvaluations::new(
1232 host,
1233 distinct_id,
1234 records,
1235 options.groups.unwrap_or_default(),
1236 options.disable_geoip,
1237 request_id,
1238 None,
1239 errors_while_computing,
1240 quota_limited,
1241 ))
1242 }
1243
1244 fn flag_event_host(&self) -> Arc<dyn FeatureFlagEvaluationsHost> {
1245 self.flag_event_host
1246 .get_or_init(|| {
1247 Arc::new(AsyncFlagEventHost::from_options(
1248 &self.options,
1249 self.transport.clone(),
1250 )) as Arc<dyn FeatureFlagEvaluationsHost>
1251 })
1252 .clone()
1253 }
1254
1255 async fn send_feature_flags_request(
1256 &self,
1257 flags_endpoint: &str,
1258 payload: &serde_json::Value,
1259 ) -> Result<reqwest::Response, Error> {
1260 let mut attempt = 1;
1261 loop {
1262 let request = self
1263 .client
1264 .post(flags_endpoint)
1265 .header(CONTENT_TYPE, "application/json")
1266 .header(USER_AGENT, get_default_user_agent())
1267 .json(payload)
1268 .timeout(Duration::from_secs(
1269 self.options.feature_flags_request_timeout_seconds,
1270 ));
1271 #[cfg(feature = "test-harness")]
1272 let request = {
1273 let mut request = request;
1274 if let Some(ref extra) = self.options.extra_capture_headers {
1275 for (k, v) in extra {
1276 request = request.header(k.as_str(), v.as_str());
1277 }
1278 }
1279 request
1280 };
1281 let result = request.send().await;
1282
1283 match result {
1284 Ok(response) => match super::retry::feature_flags_after_response(
1285 &self.options,
1286 attempt,
1287 response.status().as_u16(),
1288 ) {
1289 super::retry::FeatureFlagsResponseStep::Backoff(delay) => {
1290 tokio::time::sleep(delay).await;
1291 attempt += 1;
1292 }
1293 super::retry::FeatureFlagsResponseStep::Done => return Ok(response),
1294 },
1295 Err(e) => {
1296 let err_msg = e.to_string();
1297 match super::retry::feature_flags_after_transport_error(
1298 &self.options,
1299 attempt,
1300 is_retryable_feature_flags_error(&e),
1301 err_msg,
1302 ) {
1303 super::retry::FeatureFlagsTransportStep::Backoff(delay) => {
1304 tokio::time::sleep(delay).await;
1305 attempt += 1;
1306 }
1307 super::retry::FeatureFlagsTransportStep::Fail(err) => {
1308 report_flags_error(
1309 &self.options.on_error,
1310 flags_endpoint,
1311 payload.get("distinct_id").and_then(|v| v.as_str()),
1312 None,
1313 None,
1314 &err,
1315 );
1316 return Err(err);
1317 }
1318 }
1319 }
1320 }
1321 }
1322 }
1323
1324 async fn fetch_flag_details(
1325 &self,
1326 distinct_id: &str,
1327 options: &EvaluateFlagsOptions,
1328 ) -> Result<DetailedFlagsResponse, Error> {
1329 let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
1330
1331 let person_properties = options.person_properties.clone().unwrap_or_default();
1332 let groups = options.groups.clone().unwrap_or_default();
1333 let group_properties = options.group_properties.clone().unwrap_or_default();
1334 let effective_disable_geoip = options.disable_geoip.unwrap_or(self.options.disable_geoip);
1335
1336 let mut payload = json!({
1337 "api_key": self.options.api_key,
1338 "distinct_id": distinct_id,
1339 "groups": groups,
1340 "person_properties": person_properties,
1341 "group_properties": group_properties,
1342 "geoip_disable": effective_disable_geoip,
1343 });
1344 if let Some(flag_keys) = &options.flag_keys {
1345 payload["flag_keys_to_evaluate"] = json!(flag_keys);
1346 }
1347
1348 let response = self
1349 .send_feature_flags_request(&flags_endpoint, &payload)
1350 .await?;
1351
1352 if !response.status().is_success() {
1353 let status = response.status();
1354 let text = response
1355 .text()
1356 .await
1357 .unwrap_or_else(|_| "Unknown error".to_string());
1358 let err = Error::Connection(format!("API request failed with status {status}: {text}"));
1359 report_flags_error(
1360 &self.options.on_error,
1361 &flags_endpoint,
1362 Some(distinct_id),
1363 Some(status.as_u16()),
1364 Some(&text),
1365 &err,
1366 );
1367 return Err(err);
1368 }
1369
1370 let status = response.status().as_u16();
1371 let parsed = match response.json::<FeatureFlagsResponse>().await {
1372 Ok(p) => p,
1373 Err(e) => {
1374 let err =
1375 Error::Serialization(format!("Failed to parse feature flags response: {e}"));
1376 report_flags_error(
1377 &self.options.on_error,
1378 &flags_endpoint,
1379 Some(distinct_id),
1380 Some(status),
1381 None,
1382 &err,
1383 );
1384 return Err(err);
1385 }
1386 };
1387 Ok(extract_flag_details(parsed))
1388 }
1389}
1390
1391impl Drop for Client {
1392 fn drop(&mut self) {
1400 if let Some(transport) = &self.transport {
1401 transport.close_blocking();
1402 }
1403 }
1404}
1405
1406#[cfg(test)]
1407mod teardown_tests {
1408 use super::*;
1409 use std::sync::{mpsc, Mutex};
1410
1411 #[tokio::test]
1412 async fn shutdown_from_worker_callback_closes_without_blocking() {
1413 let client_slot = Arc::new(Mutex::new(None::<Arc<Client>>));
1414 let callback_slot = Arc::clone(&client_slot);
1415 let (shutdown_tx, shutdown_rx) = mpsc::channel();
1416 let options = crate::ClientOptionsBuilder::default()
1417 .api_key("phc_test".to_string())
1418 .host("http://localhost:0".to_string())
1419 .flush_at(1usize)
1420 .before_send(move |_| {
1421 let client = Arc::clone(
1422 callback_slot
1423 .lock()
1424 .unwrap_or_else(|p| p.into_inner())
1425 .as_ref()
1426 .expect("client installed before capture"),
1427 );
1428 futures::executor::block_on(client.shutdown());
1429 shutdown_tx.send(()).unwrap();
1430 None
1431 })
1432 .build()
1433 .unwrap();
1434 let client = Arc::new(client(options).await);
1435 *client_slot.lock().unwrap_or_else(|p| p.into_inner()) = Some(Arc::clone(&client));
1436
1437 client.capture(Event::new("shutdown-in-callback", "user-1"));
1438 shutdown_rx
1439 .recv_timeout(Duration::from_secs(2))
1440 .expect("client shutdown blocked its transport worker");
1441 client_slot.lock().unwrap_or_else(|p| p.into_inner()).take();
1442
1443 client.shutdown().await;
1445 client.shutdown().await;
1446 assert!(client.transport.as_ref().unwrap().is_closed());
1447 }
1448
1449 #[tokio::test]
1450 async fn drop_from_worker_callback_closes_without_blocking() {
1451 let client_slot = Arc::new(Mutex::new(None::<Client>));
1452 let callback_slot = Arc::clone(&client_slot);
1453 let (dropped_tx, dropped_rx) = mpsc::channel();
1454 let options = crate::ClientOptionsBuilder::default()
1455 .api_key("phc_test".to_string())
1456 .host("http://localhost:0".to_string())
1457 .flush_at(1usize)
1458 .before_send(move |_| {
1459 let client = callback_slot
1460 .lock()
1461 .unwrap_or_else(|p| p.into_inner())
1462 .take()
1463 .expect("client installed before capture");
1464 drop(client);
1465 dropped_tx.send(()).unwrap();
1466 None
1467 })
1468 .build()
1469 .unwrap();
1470 let client = client(options).await;
1471 let transport = Arc::clone(client.transport.as_ref().unwrap());
1472 *client_slot.lock().unwrap_or_else(|p| p.into_inner()) = Some(client);
1473
1474 client_slot
1475 .lock()
1476 .unwrap_or_else(|p| p.into_inner())
1477 .as_ref()
1478 .unwrap()
1479 .capture(Event::new("drop-in-callback", "user-1"));
1480 dropped_rx
1481 .recv_timeout(Duration::from_secs(2))
1482 .expect("client drop blocked its transport worker");
1483
1484 transport.close_blocking();
1486 transport.close_blocking();
1487 assert!(transport.is_closed());
1488 }
1489}
1490
1491#[cfg(test)]
1492mod minimal_gate_tests {
1493 use super::*;
1494 use crate::client::minimal_gate_test_support::{definitions, RecordingHost};
1495
1496 fn test_client(cache: FlagCache, host: Arc<dyn FeatureFlagEvaluationsHost>) -> Client {
1497 let options = ClientOptions::from(("phc_test", "http://localhost:0"));
1498 let client = Client {
1499 options,
1500 client: HttpClient::builder().build().unwrap(),
1501 local_evaluator: Some(LocalEvaluator::new(cache)),
1502 _flag_poller: None,
1503 flag_event_host: OnceLock::new(),
1504 transport: None,
1505 };
1506 client
1507 .flag_event_host
1508 .set(host)
1509 .unwrap_or_else(|_| panic!("host already set"));
1510 client
1511 }
1512
1513 async fn evaluate(client: &Client) -> FeatureFlagEvaluations {
1514 client
1515 .evaluate_flags(
1516 "user-1",
1517 EvaluateFlagsOptions {
1518 only_evaluate_locally: true,
1519 ..Default::default()
1520 },
1521 )
1522 .await
1523 .expect("local evaluate_flags")
1524 }
1525
1526 #[tokio::test]
1531 async fn local_gate_pinned_at_evaluation_survives_cache_mutation_to_off() {
1532 let cache = FlagCache::new();
1533 cache.update(definitions(Some(false), true)); let host = Arc::new(RecordingHost::default());
1535 let client = test_client(cache.clone(), Arc::clone(&host) as _);
1536
1537 let snapshot = evaluate(&client).await;
1538 cache.update(definitions(Some(false), false));
1540
1541 assert!(snapshot.is_enabled("gated"));
1542 let captured = host.captured.lock().unwrap();
1543 assert_eq!(captured.len(), 1);
1544 assert!(
1545 captured[0].minimal,
1546 "event must reflect the gate pinned at evaluation (on), not the mutated cache (off)"
1547 );
1548 }
1549
1550 #[tokio::test]
1551 async fn local_gate_pinned_at_evaluation_survives_cache_mutation_to_on() {
1552 let cache = FlagCache::new();
1553 cache.update(definitions(Some(false), false)); let host = Arc::new(RecordingHost::default());
1555 let client = test_client(cache.clone(), Arc::clone(&host) as _);
1556
1557 let snapshot = evaluate(&client).await;
1558 cache.update(definitions(Some(false), true));
1560
1561 assert!(snapshot.is_enabled("gated"));
1562 let captured = host.captured.lock().unwrap();
1563 assert_eq!(captured.len(), 1);
1564 assert!(
1565 !captured[0].minimal,
1566 "event must reflect the gate pinned at evaluation (off), not the mutated cache (on)"
1567 );
1568 }
1569
1570 #[tokio::test]
1571 async fn local_has_experiment_is_threaded_from_definitions() {
1572 let cache = FlagCache::new();
1573 cache.update(definitions(Some(false), true));
1574 let host = Arc::new(RecordingHost::default());
1575 let client = test_client(cache, Arc::clone(&host) as _);
1576
1577 assert!(evaluate(&client).await.is_enabled("gated"));
1578 let captured = host.captured.lock().unwrap();
1579 assert_eq!(
1580 captured[0].properties.get("$feature_flag_has_experiment"),
1581 Some(&serde_json::json!(false))
1582 );
1583 assert!(captured[0].minimal);
1584 }
1585}
1586
1587#[cfg(test)]
1588mod local_payload_tests {
1589 use super::*;
1590 use crate::client::local_payload_test_support::payload_definitions;
1591 use crate::client::minimal_gate_test_support::RecordingHost;
1592 use serde_json::json;
1593
1594 async fn snapshot() -> FeatureFlagEvaluations {
1595 let cache = FlagCache::new();
1596 cache.update(payload_definitions());
1597 let options = ClientOptions::from(("phc_test", "http://localhost:0"));
1598 let client = Client {
1599 options,
1600 client: HttpClient::builder().build().unwrap(),
1601 local_evaluator: Some(LocalEvaluator::new(cache)),
1602 _flag_poller: None,
1603 flag_event_host: OnceLock::new(),
1604 transport: None,
1605 };
1606 client
1607 .flag_event_host
1608 .set(Arc::new(RecordingHost::default()) as _)
1609 .unwrap_or_else(|_| panic!("host already set"));
1610 client
1611 .evaluate_flags(
1612 "user-1",
1613 EvaluateFlagsOptions {
1614 only_evaluate_locally: true,
1615 ..Default::default()
1616 },
1617 )
1618 .await
1619 .expect("local evaluate_flags")
1620 }
1621
1622 #[tokio::test]
1627 async fn local_evaluation_surfaces_payloads_matching_the_remote_shape() {
1628 let snapshot = snapshot().await;
1629
1630 assert_eq!(
1631 snapshot.get_flag_payload("json-string-payload"),
1632 Some(json!({"color": "blue"}))
1633 );
1634 assert_eq!(
1635 snapshot.get_flag_payload("parsed-payload"),
1636 Some(json!({"color": "blue"}))
1637 );
1638 assert_eq!(
1639 snapshot.get_flag_payload("quoted-string-payload"),
1640 Some(json!("just text"))
1641 );
1642 assert_eq!(
1643 snapshot.get_flag_payload("undecodable-payload"),
1644 Some(json!("not json"))
1645 );
1646 }
1647
1648 #[tokio::test]
1649 async fn local_payload_is_keyed_by_the_matched_variant() {
1650 let snapshot = snapshot().await;
1651
1652 assert_eq!(
1653 snapshot.get_flag("variant-payload"),
1654 Some(FlagValue::String("test".to_string()))
1655 );
1656 assert_eq!(
1657 snapshot.get_flag_payload("variant-payload"),
1658 Some(json!({"tier": 2}))
1659 );
1660 }
1661
1662 #[tokio::test]
1663 async fn local_payload_is_absent_without_a_matching_payload() {
1664 let snapshot = snapshot().await;
1665
1666 assert_eq!(snapshot.get_flag_payload("no-payload"), None);
1667 assert_eq!(snapshot.get_flag_payload("not-a-flag"), None);
1668
1669 assert_eq!(
1673 snapshot.get_flag("disabled-with-payload"),
1674 Some(FlagValue::Boolean(false))
1675 );
1676 assert_eq!(snapshot.get_flag_payload("disabled-with-payload"), None);
1677 }
1678}