Skip to main content

posthog_rs/client/
async_client.rs

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_json::json;
9use tracing::{debug, instrument, trace, warn};
10
11use super::get_default_user_agent;
12use crate::endpoints::Endpoint;
13#[cfg(feature = "error-tracking")]
14use crate::error_tracking::{build_exception_event, CaptureExceptionOptions};
15use crate::feature_flag_evaluations::{
16    EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost,
17    FlagCalledEventParams,
18};
19use crate::feature_flags::{match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagValue};
20use crate::local_evaluation::{AsyncFlagPoller, FlagCache, LocalEvaluationConfig, LocalEvaluator};
21use crate::{Error, Event};
22
23fn is_retryable_feature_flags_error(err: &reqwest::Error) -> bool {
24    if err.is_timeout() {
25        return true;
26    }
27
28    let mut source = std::error::Error::source(err);
29    while let Some(error) = source {
30        if let Some(io_error) = error.downcast_ref::<std::io::Error>() {
31            return matches!(
32                io_error.kind(),
33                std::io::ErrorKind::ConnectionReset
34                    | std::io::ErrorKind::TimedOut
35                    | std::io::ErrorKind::UnexpectedEof
36            );
37        }
38        source = std::error::Error::source(error);
39    }
40
41    !err.to_string()
42        .to_lowercase()
43        .contains("connection refused")
44}
45
46use super::common::{
47    already_reported, build_dedup_key, extract_flag_details, flag_called_event,
48    flag_event_dedup_cache, local_record, remote_record_from_detail, report_flags_error,
49    DetailedFlagsResponse, FlagEventDedupCache,
50};
51use super::transport::{Completion, Control, TransportHandle};
52use super::ClientOptions;
53
54/// A [`Client`] facilitates interactions with the PostHog API over HTTP.
55pub struct Client {
56    options: ClientOptions,
57    client: HttpClient,
58    local_evaluator: Option<LocalEvaluator>,
59    _flag_poller: Option<AsyncFlagPoller>,
60    flag_event_host: OnceLock<Arc<dyn FeatureFlagEvaluationsHost>>,
61    /// Background event transport. `None` for disabled clients.
62    transport: Option<Arc<TransportHandle>>,
63}
64
65/// Implementation of [`FeatureFlagEvaluationsHost`] that emits dedup-aware
66/// `$feature_flag_called` events through the same background capture transport
67/// as any other event.
68struct AsyncFlagEventHost {
69    options: ClientOptions,
70    transport: Option<Arc<TransportHandle>>,
71    dedup_cache: FlagEventDedupCache,
72}
73
74impl AsyncFlagEventHost {
75    fn from_options(options: &ClientOptions, transport: Option<Arc<TransportHandle>>) -> Self {
76        Self {
77            options: options.clone(),
78            transport,
79            dedup_cache: flag_event_dedup_cache(),
80        }
81    }
82
83    fn enqueue(&self, event: Event) {
84        if let Some(transport) = &self.transport {
85            transport.enqueue(event);
86        }
87    }
88}
89
90impl FeatureFlagEvaluationsHost for AsyncFlagEventHost {
91    fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) {
92        let dedup_key = build_dedup_key(&params.key, params.response.as_ref(), &params.groups);
93        if already_reported(&self.dedup_cache, &params.distinct_id, &dedup_key) {
94            return;
95        }
96
97        if let Some(event) =
98            flag_called_event(params, self.options.disable_geoip, self.options.is_server)
99        {
100            self.enqueue(event);
101        }
102    }
103
104    fn log_warning(&self, message: &str) {
105        // Surface filter-helper misuse via tracing — users can silence these
106        // with their tracing-subscriber level filter (e.g. `posthog_rs=error`).
107        warn!("{message}");
108    }
109}
110
111/// Construct an async PostHog client from an API key or [`ClientOptions`].
112///
113/// # Parameters
114///
115/// - `options`: Either a project API key (for example `"phc_..."`) or a
116///   configured [`ClientOptions`] value.
117///
118/// # Returns
119///
120/// A [`Client`] that performs capture and feature flag requests asynchronously.
121///
122/// # Remarks
123///
124/// This constructor is available with the default `async-client` feature and
125/// must be awaited. Passing a blank API key creates a disabled client.
126pub async fn client<C: Into<ClientOptions>>(options: C) -> Client {
127    let options = options.into().sanitize();
128    let client = HttpClient::builder()
129        .timeout(Duration::from_secs(options.request_timeout_seconds))
130        .build()
131        .unwrap(); // Unwrap here is as safe as `HttpClient::new`
132
133    let (local_evaluator, flag_poller) = if options.enable_local_evaluation
134        && !options.is_disabled()
135    {
136        if let Some(ref personal_key) = options.personal_api_key {
137            let cache = FlagCache::new();
138
139            let config = LocalEvaluationConfig {
140                personal_api_key: personal_key.clone(),
141                project_api_key: options.api_key.clone(),
142                api_host: options.endpoints().api_host(),
143                poll_interval: Duration::from_secs(options.poll_interval_seconds),
144                request_timeout: Duration::from_secs(options.request_timeout_seconds),
145            };
146
147            let mut poller = AsyncFlagPoller::new(config, cache.clone());
148            poller.set_on_error(options.on_error.clone());
149            poller.start().await;
150
151            (Some(LocalEvaluator::new(cache)), Some(poller))
152        } else {
153            warn!("Local evaluation enabled but personal_api_key not set, falling back to API evaluation");
154            (None, None)
155        }
156    } else {
157        (None, None)
158    };
159
160    let transport = if options.is_disabled() {
161        None
162    } else {
163        Some(Arc::new(TransportHandle::spawn(options.clone())))
164    };
165
166    Client {
167        options,
168        client,
169        local_evaluator,
170        _flag_poller: flag_poller,
171        flag_event_host: OnceLock::new(),
172        transport,
173    }
174}
175
176impl Client {
177    /// Capture the provided event, sending it to PostHog.
178    ///
179    /// # Parameters
180    ///
181    /// - `event`: Event name, distinct ID, properties, timestamp, groups, and
182    ///   optional feature flag state to send.
183    ///
184    /// # Remarks
185    ///
186    /// Fire-and-forget: the event is handed to the background worker, which
187    /// batches, sends, and retries it. Returns once the event is queued — not
188    /// once it is delivered, and delivery failures are not surfaced to the
189    /// caller. Disabled clients and a full queue drop the event (the latter
190    /// with a single warning).
191    #[instrument(skip(self, event), level = "debug")]
192    pub fn capture(&self, event: Event) {
193        if let Some(transport) = &self.transport {
194            transport.enqueue(event);
195        }
196    }
197
198    /// Flush queued events, returning once the worker has attempted delivery of
199    /// everything queued before this call. Transient failures are kept for retry
200    /// (the call still returns without error). A no-op for disabled clients.
201    pub async fn flush(&self) {
202        let Some(transport) = &self.transport else {
203            return;
204        };
205        if transport.is_closed() {
206            return;
207        }
208        let (tx, rx) = tokio::sync::oneshot::channel();
209        if transport.send_control(Control::Flush(Completion::Async(tx))) {
210            let _ = rx.await;
211        }
212    }
213
214    /// Whether the client is disabled (no transport; capture is a no-op). Used
215    /// by the panic hook to skip building an event it could never send.
216    #[cfg(feature = "error-tracking")]
217    pub(crate) fn is_disabled(&self) -> bool {
218        self.options.is_disabled()
219    }
220
221    /// The client's Error Tracking options, used by the panic hook to build
222    /// panic exception events with the client's configured policy.
223    #[cfg(feature = "error-tracking")]
224    pub(crate) fn error_tracking_options(&self) -> &crate::error_tracking::ErrorTrackingOptions {
225        self.options.error_tracking()
226    }
227
228    /// Unbounded synchronous flush: blocks until the worker has attempted
229    /// delivery of everything queued. Test-only; the panic hook uses
230    /// `flush_blocking_timeout`.
231    #[cfg(test)]
232    pub(crate) fn flush_blocking(&self) {
233        if let Some(transport) = &self.transport {
234            transport.flush_blocking();
235        }
236    }
237
238    /// Synchronous, time-bounded flush for the panic hook: blocks (no runtime
239    /// needed) up to `timeout` for the worker to attempt delivery, then returns.
240    /// A no-op for disabled clients.
241    #[cfg(feature = "error-tracking")]
242    pub(crate) fn flush_blocking_timeout(&self, timeout: Duration) {
243        if let Some(transport) = &self.transport {
244            transport.flush_blocking_timeout(timeout);
245        }
246    }
247
248    /// True when the calling thread is this client's transport worker thread —
249    /// the panic hook skips capturing there.
250    #[cfg(feature = "error-tracking")]
251    pub(crate) fn on_transport_worker(&self) -> bool {
252        self.transport
253            .as_ref()
254            .is_some_and(|t| t.on_worker_thread())
255    }
256
257    /// Enqueue a panic `$exception` without the tracing `capture` performs:
258    /// `capture` is `#[instrument]` and its enqueue warns once on a full queue,
259    /// both of which run subscriber code — unsafe on the already-panicking
260    /// thread. The send still happens on the worker thread.
261    #[cfg(feature = "error-tracking")]
262    pub(crate) fn enqueue_panic_event(&self, event: Event) {
263        if let Some(transport) = &self.transport {
264            transport.enqueue_panic(event);
265        }
266    }
267
268    /// Flush, stop the background worker, and join it. Idempotent: subsequent
269    /// calls are no-ops. After shutdown, `capture` drops events. A no-op for
270    /// disabled clients.
271    pub async fn shutdown(&self) {
272        let Some(transport) = &self.transport else {
273            return;
274        };
275        if transport.begin_close() {
276            let (tx, rx) = tokio::sync::oneshot::channel();
277            if transport.send_control(Control::Shutdown(Completion::Async(tx))) {
278                let _ = rx.await;
279            }
280        }
281        // Always join — even if this caller lost the `begin_close` race or its
282        // shutdown wait was cancelled — so every shutdown/drop path waits for the
283        // worker and the flush stays durable. The winner has already sent the
284        // Shutdown (synchronously, before any await), so the worker will exit.
285        transport.join();
286    }
287
288    /// Capture a Rust error personlessly, sending it to PostHog Error Tracking.
289    ///
290    /// The error's type, message, and full `source()` chain are sent as
291    /// `$exception_list`, with a stacktrace of the capture site attached to
292    /// the first entry (see `ErrorTrackingOptions::capture_stacktrace`).
293    ///
294    /// Accepts any [`std::error::Error`], including `&dyn Error`. A
295    /// `Box<dyn Error>` does not implement `Error` itself, so pass the
296    /// dereferenced trait object: `capture_exception(&*boxed)`.
297    ///
298    /// To associate the exception with a person or attach custom properties,
299    /// groups, a fingerprint, or a severity level, use
300    /// [`Client::capture_exception_with`].
301    ///
302    /// # Examples
303    ///
304    /// ```no_run
305    /// # async fn example() -> Result<(), posthog_rs::Error> {
306    /// let client = posthog_rs::client("phc_project_api_key").await;
307    /// let error = std::io::Error::other("checkout failed");
308    ///
309    /// client.capture_exception(&error).await?;
310    /// # Ok(())
311    /// # }
312    /// ```
313    #[cfg(feature = "error-tracking")]
314    pub async fn capture_exception<E>(&self, error: &E) -> Result<(), Error>
315    where
316        E: StdError + ?Sized,
317    {
318        self.capture_exception_with(error, CaptureExceptionOptions::default())
319            .await
320    }
321
322    /// Capture a Rust error with optional context, sending it to PostHog
323    /// Error Tracking.
324    ///
325    /// Set [`CaptureExceptionOptions::distinct_id`] to associate the exception
326    /// with a person; without it the exception is captured personlessly.
327    ///
328    /// # Examples
329    ///
330    /// ```no_run
331    /// # async fn example() -> Result<(), posthog_rs::Error> {
332    /// use posthog_rs::CaptureExceptionOptions;
333    ///
334    /// let client = posthog_rs::client("phc_project_api_key").await;
335    /// let error = std::io::Error::other("checkout failed");
336    ///
337    /// client
338    ///     .capture_exception_with(
339    ///         &error,
340    ///         CaptureExceptionOptions::new()
341    ///             .distinct_id("user-123")
342    ///             .property("route", "/checkout")?,
343    ///     )
344    ///     .await?;
345    /// # Ok(())
346    /// # }
347    /// ```
348    #[cfg(feature = "error-tracking")]
349    pub async fn capture_exception_with<E>(
350        &self,
351        error: &E,
352        options: CaptureExceptionOptions,
353    ) -> Result<(), Error>
354    where
355        E: StdError + ?Sized,
356    {
357        if self.options.is_disabled() {
358            trace!("Client is disabled, skipping exception capture");
359            return Ok(());
360        }
361
362        self.capture(build_exception_event(
363            error,
364            options,
365            self.options.error_tracking(),
366        )?);
367        Ok(())
368    }
369
370    /// Capture a collection of events with a single request.
371    ///
372    /// Events are sent to the `/batch/` endpoint.
373    ///
374    /// # Parameters
375    ///
376    /// - `events`: Events to send in the batch.
377    /// - `historical_migration`: Set to `true` to route events to the
378    ///   historical ingestion topic, bypassing the main pipeline.
379    ///
380    /// # Remarks
381    ///
382    /// Fire-and-forget, like [`Client::capture`]. The batch is enqueued per event
383    /// rather than atomically, so if the bounded queue fills partway through, the
384    /// remaining events are dropped (with the usual single full-queue warning).
385    pub fn capture_batch(&self, events: Vec<Event>, historical_migration: bool) {
386        if let Some(transport) = &self.transport {
387            if historical_migration {
388                transport.enqueue_historical(events);
389            } else {
390                for event in events {
391                    transport.enqueue(event);
392                }
393            }
394        }
395    }
396
397    /// Number of events accepted but not yet delivered or dropped — those still
398    /// in the channel, in the worker's current batch, or held for retry. Returns
399    /// 0 for a disabled client.
400    ///
401    /// Gated behind the `test-harness` feature: it exposes internal queue depth
402    /// for the SDK compliance harness and is not part of the normal public API.
403    #[cfg(feature = "test-harness")]
404    pub fn pending_events(&self) -> usize {
405        self.transport.as_ref().map_or(0, |t| t.pending())
406    }
407
408    /// Get all remote feature flags and payloads for a user.
409    ///
410    /// For new code, prefer [`Client::evaluate_flags`] so flag reads are
411    /// deduplicated and can be attached to captured events with
412    /// [`Event::with_flags`](crate::Event::with_flags).
413    ///
414    /// # Parameters
415    ///
416    /// - `distinct_id`: User distinct ID.
417    /// - `groups`: Optional group keys for group-targeted flags.
418    /// - `person_properties`: Optional person properties for release
419    ///   conditions.
420    /// - `group_properties`: Optional group properties for group-targeted
421    ///   release conditions.
422    ///
423    /// # Returns
424    ///
425    /// A tuple of `(feature_flags, feature_flag_payloads)`, each keyed by flag
426    /// key. Disabled clients return two empty maps.
427    ///
428    /// # Errors
429    ///
430    /// Returns [`Error::Connection`] for request failures or non-success HTTP
431    /// statuses, and [`Error::Serialization`] when the response cannot be
432    /// parsed.
433    #[must_use = "feature flags result should be used"]
434    pub async fn get_feature_flags<S: Into<String>>(
435        &self,
436        distinct_id: S,
437        groups: Option<HashMap<String, String>>,
438        person_properties: Option<HashMap<String, serde_json::Value>>,
439        group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
440    ) -> Result<
441        (
442            HashMap<String, FlagValue>,
443            HashMap<String, serde_json::Value>,
444        ),
445        Error,
446    > {
447        if self.options.is_disabled() {
448            trace!("Client is disabled, skipping feature flags request");
449            return Ok((HashMap::new(), HashMap::new()));
450        }
451
452        let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
453
454        let mut payload = json!({
455            "api_key": self.options.api_key,
456            "distinct_id": distinct_id.into(),
457        });
458
459        if let Some(groups) = groups {
460            payload["groups"] = json!(groups);
461        }
462
463        if let Some(person_properties) = person_properties {
464            payload["person_properties"] = json!(person_properties);
465        }
466
467        if let Some(group_properties) = group_properties {
468            payload["group_properties"] = json!(group_properties);
469        }
470
471        // Add geoip disable parameter if configured
472        if self.options.disable_geoip {
473            payload["disable_geoip"] = json!(true);
474        }
475
476        let response = self
477            .send_feature_flags_request(&flags_endpoint, &payload)
478            .await?;
479
480        let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str());
481        if !response.status().is_success() {
482            let status = response.status();
483            let text = response
484                .text()
485                .await
486                .unwrap_or_else(|_| "Unknown error".to_string());
487            let err = Error::Connection(format!("API request failed with status {status}: {text}"));
488            report_flags_error(
489                &self.options.on_error,
490                &flags_endpoint,
491                distinct_id,
492                Some(status.as_u16()),
493                Some(&text),
494                &err,
495            );
496            return Err(err);
497        }
498
499        let status = response.status().as_u16();
500        let flags_response = match response.json::<FeatureFlagsResponse>().await {
501            Ok(r) => r,
502            Err(e) => {
503                let err =
504                    Error::Serialization(format!("Failed to parse feature flags response: {e}"));
505                report_flags_error(
506                    &self.options.on_error,
507                    &flags_endpoint,
508                    distinct_id,
509                    Some(status),
510                    None,
511                    &err,
512                );
513                return Err(err);
514            }
515        };
516
517        Ok(flags_response.normalize())
518    }
519
520    /// Get a specific feature flag value for a user.
521    ///
522    /// # Parameters
523    ///
524    /// - `key`: Feature flag key.
525    /// - `distinct_id`: User distinct ID.
526    /// - `groups`: Optional group keys for group-targeted flags.
527    /// - `person_properties`: Optional person properties for release
528    ///   conditions.
529    /// - `group_properties`: Optional group properties for group-targeted
530    ///   release conditions.
531    ///
532    /// # Returns
533    ///
534    /// `Ok(Some(value))` when the flag is returned, `Ok(None)` when it is not
535    /// returned or local-only evaluation cannot resolve it.
536    ///
537    /// # Errors
538    ///
539    /// Returns errors from remote `/flags` requests or response parsing.
540    #[must_use = "feature flag result should be used"]
541    #[instrument(skip_all, level = "debug")]
542    #[deprecated(
543        since = "0.6.0",
544        note = "Use Client::evaluate_flags() to fetch a snapshot, then call .get_flag(key) on it. \
545                The snapshot deduplicates $feature_flag_called events and supports attaching \
546                rich metadata to captured events via Event::with_flags()."
547    )]
548    pub async fn get_feature_flag<K: Into<String>, D: Into<String>>(
549        &self,
550        key: K,
551        distinct_id: D,
552        groups: Option<HashMap<String, String>>,
553        person_properties: Option<HashMap<String, serde_json::Value>>,
554        group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
555    ) -> Result<Option<FlagValue>, Error> {
556        let key_str = key.into();
557        let distinct_id_str = distinct_id.into();
558
559        // Try local evaluation first if available
560        if let Some(ref evaluator) = self.local_evaluator {
561            let empty_props = HashMap::new();
562            let empty_groups: HashMap<String, String> = HashMap::new();
563            let empty_group_props: HashMap<String, HashMap<String, serde_json::Value>> =
564                HashMap::new();
565            let mut local_props;
566            let props = if let Some(props) = person_properties.as_ref() {
567                local_props = props.clone();
568                local_props
569                    .entry("distinct_id".to_string())
570                    .or_insert_with(|| json!(distinct_id_str.clone()));
571                &local_props
572            } else {
573                local_props = empty_props;
574                local_props.insert("distinct_id".to_string(), json!(distinct_id_str.clone()));
575                &local_props
576            };
577            let groups_ref = groups.as_ref().unwrap_or(&empty_groups);
578            let group_props_ref = group_properties.as_ref().unwrap_or(&empty_group_props);
579            match evaluator.evaluate_flag(
580                &key_str,
581                &distinct_id_str,
582                props,
583                groups_ref,
584                group_props_ref,
585            ) {
586                Ok(Some(value)) => {
587                    debug!(flag = %key_str, ?value, "Flag evaluated locally");
588                    return Ok(Some(value));
589                }
590                Ok(None) => {
591                    if self.options.local_evaluation_only {
592                        debug!(flag = %key_str, "Flag not found locally, skipping remote fallback");
593                        return Ok(None);
594                    }
595                    debug!(flag = %key_str, "Flag not found locally, falling back to API");
596                }
597                Err(e) => {
598                    if self.options.local_evaluation_only {
599                        debug!(flag = %key_str, error = %e.message, "Inconclusive local evaluation, skipping remote fallback");
600                        return Ok(None);
601                    }
602                    debug!(flag = %key_str, error = %e.message, "Inconclusive local evaluation, falling back to API");
603                }
604            }
605        }
606
607        // Fall back to API
608        trace!(flag = %key_str, "Fetching flag from API");
609        let (feature_flags, _payloads) = self
610            .get_feature_flags(distinct_id_str, groups, person_properties, group_properties)
611            .await?;
612        Ok(feature_flags.get(&key_str).cloned())
613    }
614
615    /// Check if a feature flag is enabled for a user.
616    ///
617    /// # Returns
618    ///
619    /// `true` for `FlagValue::Boolean(true)` or any multivariate variant,
620    /// `false` for disabled or missing flags.
621    ///
622    /// # Errors
623    ///
624    /// Returns errors from [`Client::get_feature_flag`].
625    #[must_use = "feature flag enabled check result should be used"]
626    #[deprecated(
627        since = "0.6.0",
628        note = "Use Client::evaluate_flags() to fetch a snapshot, then call .is_enabled(key) \
629                on it. The snapshot deduplicates $feature_flag_called events and supports \
630                attaching rich metadata to captured events via Event::with_flags()."
631    )]
632    #[allow(deprecated)] // calls deprecated get_feature_flag internally
633    pub async fn is_feature_enabled<K: Into<String>, D: Into<String>>(
634        &self,
635        key: K,
636        distinct_id: D,
637        groups: Option<HashMap<String, String>>,
638        person_properties: Option<HashMap<String, serde_json::Value>>,
639        group_properties: Option<HashMap<String, HashMap<String, serde_json::Value>>>,
640    ) -> Result<bool, Error> {
641        let flag_value = self
642            .get_feature_flag(
643                key.into(),
644                distinct_id.into(),
645                groups,
646                person_properties,
647                group_properties,
648            )
649            .await?;
650        Ok(match flag_value {
651            Some(FlagValue::Boolean(b)) => b,
652            Some(FlagValue::String(_)) => true, // Variants are considered enabled
653            None => false,
654        })
655    }
656
657    /// Get a feature flag payload for a user.
658    ///
659    /// # Parameters
660    ///
661    /// - `key`: Feature flag key.
662    /// - `distinct_id`: User distinct ID.
663    ///
664    /// # Returns
665    ///
666    /// The JSON payload for the flag, if one was returned. This method does not
667    /// emit `$feature_flag_called` events.
668    ///
669    /// # Errors
670    ///
671    /// Returns [`Error::Connection`] for request failures and
672    /// [`Error::Serialization`] when the response cannot be parsed.
673    #[must_use = "feature flag payload result should be used"]
674    #[deprecated(
675        since = "0.6.0",
676        note = "Use Client::evaluate_flags() to fetch a snapshot, then call \
677                .get_flag_payload(key) on it. Reading the payload from a snapshot is \
678                event-free, matching this method's behavior, and avoids the per-call \
679                /flags request."
680    )]
681    pub async fn get_feature_flag_payload<K: Into<String>, D: Into<String>>(
682        &self,
683        key: K,
684        distinct_id: D,
685    ) -> Result<Option<serde_json::Value>, Error> {
686        if self.options.is_disabled() {
687            trace!("Client is disabled, skipping feature flag payload request");
688            return Ok(None);
689        }
690
691        let key_str = key.into();
692        let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
693
694        let mut payload = json!({
695            "api_key": self.options.api_key,
696            "distinct_id": distinct_id.into(),
697        });
698
699        // Add geoip disable parameter if configured
700        if self.options.disable_geoip {
701            payload["disable_geoip"] = json!(true);
702        }
703
704        let distinct_id = payload.get("distinct_id").and_then(|v| v.as_str());
705        let response = match self
706            .client
707            .post(&flags_endpoint)
708            .header(CONTENT_TYPE, "application/json")
709            .header(USER_AGENT, get_default_user_agent())
710            .json(&payload)
711            .timeout(Duration::from_secs(
712                self.options.feature_flags_request_timeout_seconds,
713            ))
714            .send()
715            .await
716        {
717            Ok(r) => r,
718            Err(e) => {
719                let err = Error::Connection(e.to_string());
720                report_flags_error(
721                    &self.options.on_error,
722                    &flags_endpoint,
723                    distinct_id,
724                    None,
725                    None,
726                    &err,
727                );
728                return Err(err);
729            }
730        };
731
732        if !response.status().is_success() {
733            return Ok(None);
734        }
735
736        let status = response.status().as_u16();
737        let flags_response: FeatureFlagsResponse = match response.json().await {
738            Ok(r) => r,
739            Err(e) => {
740                let err = Error::Serialization(format!("Failed to parse response: {e}"));
741                report_flags_error(
742                    &self.options.on_error,
743                    &flags_endpoint,
744                    distinct_id,
745                    Some(status),
746                    None,
747                    &err,
748                );
749                return Err(err);
750            }
751        };
752
753        let (_flags, payloads) = flags_response.normalize();
754        Ok(payloads.get(&key_str).cloned())
755    }
756
757    /// Evaluate a supplied feature flag definition locally.
758    ///
759    /// `groups` and `group_properties` are only consulted when the flag (or one
760    /// of its conditions) targets a group; pass empty maps for person flags.
761    ///
762    /// # Parameters
763    ///
764    /// - `flag`: Feature flag definition to evaluate.
765    /// - `distinct_id`: User distinct ID.
766    /// - `person_properties`: Person properties available to release
767    ///   conditions.
768    /// - `groups`: Group keys for group-targeted flags.
769    /// - `group_properties`: Group properties for group-targeted release
770    ///   conditions.
771    ///
772    /// # Errors
773    ///
774    /// Returns [`Error::InconclusiveMatch`] when the flag cannot be evaluated
775    /// locally with the supplied context.
776    #[allow(clippy::too_many_arguments)]
777    pub fn evaluate_feature_flag_locally(
778        &self,
779        flag: &FeatureFlag,
780        distinct_id: &str,
781        person_properties: &HashMap<String, serde_json::Value>,
782        groups: &HashMap<String, String>,
783        group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
784    ) -> Result<FlagValue, Error> {
785        let group_type_mapping = self
786            .local_evaluator
787            .as_ref()
788            .map(|ev| ev.cache().get_group_type_mapping())
789            .unwrap_or_default();
790        match_feature_flag(
791            flag,
792            distinct_id,
793            person_properties,
794            groups,
795            group_properties,
796            &group_type_mapping,
797        )
798        .map_err(|e| Error::InconclusiveMatch(e.message))
799    }
800
801    /// Evaluate feature flags for `distinct_id`, returning a
802    /// [`FeatureFlagEvaluations`] snapshot.
803    ///
804    /// Each `is_enabled` / `get_flag` call on the returned snapshot fires a
805    /// dedup-aware `$feature_flag_called` event with full metadata, and the
806    /// snapshot can be passed to [`Event::with_flags`] so a downstream
807    /// [`Client::capture`] inherits `$feature/<key>` and `$active_feature_flags`
808    /// without an extra `/flags` request.
809    ///
810    /// # Parameters
811    ///
812    /// - `distinct_id`: User distinct ID. Empty values return an empty snapshot.
813    /// - `options`: Optional groups, properties, GeoIP override, local-only
814    ///   mode, and flag-key filtering.
815    ///
816    /// # Errors
817    ///
818    /// Returns [`Error::Connection`] or [`Error::Serialization`] when remote
819    /// evaluation is required and the `/flags` request fails before any local
820    /// results are available.
821    ///
822    /// [`Event::with_flags`]: crate::Event::with_flags
823    pub async fn evaluate_flags<S: Into<String>>(
824        &self,
825        distinct_id: S,
826        options: EvaluateFlagsOptions,
827    ) -> Result<FeatureFlagEvaluations, Error> {
828        let distinct_id: String = distinct_id.into();
829        let host = self.flag_event_host();
830
831        if distinct_id.is_empty() || self.options.is_disabled() {
832            return Ok(FeatureFlagEvaluations::empty(host));
833        }
834
835        let mut options = options;
836        options.groups.get_or_insert_with(HashMap::new);
837        options.group_properties.get_or_insert_with(HashMap::new);
838
839        let mut records: HashMap<String, EvaluatedFlagRecord> = HashMap::new();
840        let mut locally_evaluated_keys: HashSet<String> = HashSet::new();
841
842        if let Some(evaluator) = &self.local_evaluator {
843            let mut person_props_owned = options.person_properties.clone().unwrap_or_default();
844            person_props_owned
845                .entry("distinct_id".to_string())
846                .or_insert_with(|| json!(distinct_id.clone()));
847            let groups_owned = options.groups.clone().unwrap_or_default();
848            let group_props_owned = options.group_properties.clone().unwrap_or_default();
849            let local_results = evaluator.evaluate_all_flags(
850                &distinct_id,
851                &person_props_owned,
852                &groups_owned,
853                &group_props_owned,
854            );
855            for (key, result) in local_results {
856                if let Some(filter) = &options.flag_keys {
857                    if !filter.iter().any(|k| k == &key) {
858                        continue;
859                    }
860                }
861                if let Ok(value) = result {
862                    records.insert(key.clone(), local_record(value));
863                    locally_evaluated_keys.insert(key);
864                }
865            }
866        }
867
868        let mut request_id: Option<String> = None;
869        let mut errors_while_computing = false;
870        let mut quota_limited = false;
871
872        // Skip the remote round-trip when local evaluation has already covered
873        // every requested flag. Without `flag_keys` we have to assume the caller
874        // wants every flag the project has and still hit `/flags` to discover
875        // any not loaded by the poller.
876        let local_covers_request = options
877            .flag_keys
878            .as_ref()
879            .is_some_and(|keys| keys.iter().all(|k| locally_evaluated_keys.contains(k)));
880
881        if !options.only_evaluate_locally && !local_covers_request {
882            // Don't lose successful local evaluations if `/flags` fails — degrade
883            // to a snapshot built from the local results we already have. The
884            // alternative (returning Err) wastes useful data and surprises
885            // callers who would otherwise get partial coverage.
886            match self.fetch_flag_details(&distinct_id, &options).await {
887                Ok(response) => {
888                    request_id = response.request_id;
889                    errors_while_computing = response.errors_while_computing_flags;
890                    quota_limited = response.quota_limited;
891                    for (key, detail) in response.flags {
892                        if locally_evaluated_keys.contains(&key) {
893                            continue;
894                        }
895                        records.insert(key, remote_record_from_detail(detail));
896                    }
897                }
898                Err(e) => {
899                    if records.is_empty() {
900                        return Err(e);
901                    }
902                    debug!(
903                        error = e.to_string(),
904                        local_count = records.len(),
905                        "/flags fetch failed; returning snapshot from local results only"
906                    );
907                    errors_while_computing = true;
908                }
909            }
910        }
911
912        Ok(FeatureFlagEvaluations::new(
913            host,
914            distinct_id,
915            records,
916            options.groups.unwrap_or_default(),
917            options.disable_geoip,
918            request_id,
919            None,
920            errors_while_computing,
921            quota_limited,
922        ))
923    }
924
925    fn flag_event_host(&self) -> Arc<dyn FeatureFlagEvaluationsHost> {
926        self.flag_event_host
927            .get_or_init(|| {
928                Arc::new(AsyncFlagEventHost::from_options(
929                    &self.options,
930                    self.transport.clone(),
931                )) as Arc<dyn FeatureFlagEvaluationsHost>
932            })
933            .clone()
934    }
935
936    async fn send_feature_flags_request(
937        &self,
938        flags_endpoint: &str,
939        payload: &serde_json::Value,
940    ) -> Result<reqwest::Response, Error> {
941        let mut attempt = 1;
942        loop {
943            let request = self
944                .client
945                .post(flags_endpoint)
946                .header(CONTENT_TYPE, "application/json")
947                .header(USER_AGENT, get_default_user_agent())
948                .json(payload)
949                .timeout(Duration::from_secs(
950                    self.options.feature_flags_request_timeout_seconds,
951                ));
952            #[cfg(feature = "test-harness")]
953            let request = {
954                let mut request = request;
955                if let Some(ref extra) = self.options.extra_capture_headers {
956                    for (k, v) in extra {
957                        request = request.header(k.as_str(), v.as_str());
958                    }
959                }
960                request
961            };
962            let result = request.send().await;
963
964            match result {
965                Ok(response) => match super::retry::feature_flags_after_response(
966                    &self.options,
967                    attempt,
968                    response.status().as_u16(),
969                ) {
970                    super::retry::FeatureFlagsResponseStep::Backoff(delay) => {
971                        tokio::time::sleep(delay).await;
972                        attempt += 1;
973                    }
974                    super::retry::FeatureFlagsResponseStep::Done => return Ok(response),
975                },
976                Err(e) => {
977                    let err_msg = e.to_string();
978                    match super::retry::feature_flags_after_transport_error(
979                        &self.options,
980                        attempt,
981                        is_retryable_feature_flags_error(&e),
982                        err_msg,
983                    ) {
984                        super::retry::FeatureFlagsTransportStep::Backoff(delay) => {
985                            tokio::time::sleep(delay).await;
986                            attempt += 1;
987                        }
988                        super::retry::FeatureFlagsTransportStep::Fail(err) => {
989                            report_flags_error(
990                                &self.options.on_error,
991                                flags_endpoint,
992                                payload.get("distinct_id").and_then(|v| v.as_str()),
993                                None,
994                                None,
995                                &err,
996                            );
997                            return Err(err);
998                        }
999                    }
1000                }
1001            }
1002        }
1003    }
1004
1005    async fn fetch_flag_details(
1006        &self,
1007        distinct_id: &str,
1008        options: &EvaluateFlagsOptions,
1009    ) -> Result<DetailedFlagsResponse, Error> {
1010        let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags);
1011
1012        let person_properties = options.person_properties.clone().unwrap_or_default();
1013        let groups = options.groups.clone().unwrap_or_default();
1014        let group_properties = options.group_properties.clone().unwrap_or_default();
1015        let effective_disable_geoip = options.disable_geoip.unwrap_or(self.options.disable_geoip);
1016
1017        let mut payload = json!({
1018            "api_key": self.options.api_key,
1019            "distinct_id": distinct_id,
1020            "groups": groups,
1021            "person_properties": person_properties,
1022            "group_properties": group_properties,
1023            "geoip_disable": effective_disable_geoip,
1024        });
1025        if let Some(flag_keys) = &options.flag_keys {
1026            payload["flag_keys_to_evaluate"] = json!(flag_keys);
1027        }
1028
1029        let response = self
1030            .send_feature_flags_request(&flags_endpoint, &payload)
1031            .await?;
1032
1033        if !response.status().is_success() {
1034            let status = response.status();
1035            let text = response
1036                .text()
1037                .await
1038                .unwrap_or_else(|_| "Unknown error".to_string());
1039            let err = Error::Connection(format!("API request failed with status {status}: {text}"));
1040            report_flags_error(
1041                &self.options.on_error,
1042                &flags_endpoint,
1043                Some(distinct_id),
1044                Some(status.as_u16()),
1045                Some(&text),
1046                &err,
1047            );
1048            return Err(err);
1049        }
1050
1051        let status = response.status().as_u16();
1052        let parsed = match response.json::<FeatureFlagsResponse>().await {
1053            Ok(p) => p,
1054            Err(e) => {
1055                let err =
1056                    Error::Serialization(format!("Failed to parse feature flags response: {e}"));
1057                report_flags_error(
1058                    &self.options.on_error,
1059                    &flags_endpoint,
1060                    Some(distinct_id),
1061                    Some(status),
1062                    None,
1063                    &err,
1064                );
1065                return Err(err);
1066            }
1067        };
1068        Ok(extract_flag_details(parsed))
1069    }
1070}
1071
1072impl Drop for Client {
1073    /// Best-effort flush and worker join on drop. A blocking drain (the async
1074    /// `shutdown` can't run in a destructor), so dropping a `Client` inside an
1075    /// async task blocks that executor thread until the drain completes (up to
1076    /// `shutdown_timeout_ms` plus any in-flight request) — prefer an explicit
1077    /// `shutdown().await` first, which makes this a no-op.
1078    fn drop(&mut self) {
1079        let Some(transport) = &self.transport else {
1080            return;
1081        };
1082        if transport.begin_close() {
1083            let (tx, rx) = std::sync::mpsc::channel();
1084            if transport.send_control(Control::Shutdown(Completion::Blocking(tx))) {
1085                let _ = rx.recv();
1086            }
1087        }
1088        // Always join — even if this caller lost the `begin_close` race or its
1089        // shutdown wait was cancelled — so every shutdown/drop path waits for the
1090        // worker and the flush stays durable. The winner has already sent the
1091        // Shutdown (synchronously, before any await), so the worker will exit.
1092        transport.join();
1093    }
1094}