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