Skip to main content

posthog_rs/client/
on_error.rs

1//! The `on_error` observability hook and the failure types it surfaces.
2//!
3//! Registering a hook via [`ClientOptionsBuilder::on_error`] lets a caller
4//! observe terminal failures across the SDK's network surfaces — capture batch
5//! delivery, remote `/flags` requests, and the local-evaluation definitions
6//! poller — without reverting to a blocking API. The hook receives a
7//! [`PostHogError`], a `#[non_exhaustive]` enum with one variant per surface so
8//! more can be added without breaking callers.
9//!
10//! # The hook is observability-only — never emit from it
11//!
12//! A hook MUST NOT call back into the SDK: do not `capture`/`capture_batch`/
13//! `capture_exception`, and do not `flush`/`shutdown`. Emitting an event from
14//! the hook while handling a *capture* failure forms an amplification loop — a
15//! transport incident that drops one batch would have the hook enqueue more
16//! events, which fail, which fire the hook again. Because the hook is `Fn +
17//! Send + Sync` and invoked without holding any SDK lock, it may run
18//! concurrently on multiple threads and must be internally thread-safe. Filter
19//! for the signal you care about and surface it (log, counter, channel) —
20//! nothing more.
21//!
22//! [`ClientOptionsBuilder::on_error`]: crate::ClientOptionsBuilder::on_error
23
24use std::sync::Arc;
25
26use crate::error::Error;
27
28#[cfg(feature = "capture-v1")]
29use std::collections::HashMap;
30#[cfg(feature = "capture-v1")]
31use uuid::Uuid;
32
33#[cfg(feature = "capture-v1")]
34use crate::event_v1::{EventResult, V1ErrorResponse};
35
36type OnErrorFn = dyn Fn(&PostHogError<'_>) + Send + Sync + 'static;
37type SharedOnErrorHook = Arc<OnErrorFn>;
38
39/// A registered `on_error` hook.
40///
41/// Crate-internal: callers register hooks through
42/// [`ClientOptionsBuilder::on_error`](crate::ClientOptionsBuilder::on_error)
43/// and never name this type. Cloning shares the same underlying closure (it is
44/// `Arc`-backed), so a hook can be invoked from whichever thread reaches a
45/// failure (the transport worker, a flags request, or the poller).
46#[derive(Clone)]
47pub(crate) struct OnErrorHook(SharedOnErrorHook);
48
49impl OnErrorHook {
50    pub(crate) fn new<F>(hook: F) -> Self
51    where
52        F: Fn(&PostHogError<'_>) + Send + Sync + 'static,
53    {
54        Self(Arc::new(hook))
55    }
56
57    /// Invoke the hook.
58    pub(crate) fn apply(&self, failure: &PostHogError<'_>) {
59        (self.0)(failure)
60    }
61}
62
63/// A terminal failure on one of the SDK's network surfaces, passed by reference
64/// to each registered `on_error` hook.
65///
66/// `#[non_exhaustive]`: new variants may be added as more surfaces gain hook
67/// coverage, so a `match` must include a wildcard arm.
68#[derive(Debug)]
69#[non_exhaustive]
70pub enum PostHogError<'a> {
71    /// A capture batch the SDK gave up delivering (permanent reject, exhausted
72    /// retries, serialization failure) or — on the V1 pipeline — a `2xx` whose
73    /// per-event verdicts left events unpersisted after the retry budget.
74    Capture(CaptureFailure<'a>),
75    /// A remote `/flags` request that failed (transport error, non-success
76    /// status, or an unparseable response body).
77    FeatureFlags(FlagsFailure<'a>),
78    /// A background local-evaluation definitions poll that failed (transport
79    /// error, non-success status, or an unparseable response body). The SDK
80    /// keeps serving the previously cached definitions.
81    LocalEvaluation(LocalEvaluationFailure<'a>),
82}
83
84/// Details of a terminal capture batch failure.
85///
86/// Fields are read through accessors; the struct is `#[non_exhaustive]`.
87///
88/// Does not fire for shutdown-timeout, queue-full, or `before_send` drops —
89/// those are not delivery failures.
90#[derive(Debug)]
91#[non_exhaustive]
92pub struct CaptureFailure<'a> {
93    pub(crate) error: Option<&'a Error>,
94    pub(crate) status: Option<u16>,
95    pub(crate) attempt: u32,
96    pub(crate) event_count: usize,
97    pub(crate) historical_migration: bool,
98    #[cfg(feature = "capture-v1")]
99    pub(crate) request_id: Option<&'a Uuid>,
100    #[cfg(feature = "capture-v1")]
101    pub(crate) results: &'a HashMap<Uuid, EventResult>,
102    #[cfg(feature = "capture-v1")]
103    pub(crate) error_response: Option<&'a V1ErrorResponse>,
104}
105
106impl<'a> CaptureFailure<'a> {
107    /// The batch-level cause: a permanent reject, exhausted transport/HTTP
108    /// retries, or a serialization failure.
109    #[cfg_attr(
110        not(feature = "capture-v1"),
111        doc = "\nAlways present: every capture failure surfaced to the hook carries a cause."
112    )]
113    #[cfg_attr(
114        feature = "capture-v1",
115        doc = "\n`None` only when the request itself succeeded (`2xx`) but some events were not\npersisted after the retry budget — inspect [`event_results`](Self::event_results)."
116    )]
117    pub fn error(&self) -> Option<&Error> {
118        self.error
119    }
120
121    /// The HTTP status of the final attempt, or `None` when no response was
122    /// received (a transport error or a serialization failure before sending).
123    pub fn status(&self) -> Option<u16> {
124        self.status
125    }
126
127    /// The failing attempt number (equals the configured maximum on exhaustion).
128    pub fn attempt(&self) -> u32 {
129        self.attempt
130    }
131
132    /// Number of events this failure dropped (lost).
133    #[cfg_attr(
134        feature = "capture-v1",
135        doc = "\nCounts only undelivered events (`retry`/`drop`), including any finalized on\nearlier attempts. This can be smaller than [`event_results`](Self::event_results)`.len()`,\nwhich also reports persisted `ok`/`warning` verdicts — filter by status before\ntreating an entry as lost."
136    )]
137    pub fn event_count(&self) -> usize {
138        self.event_count
139    }
140
141    /// Whether the batch was a historical-migration batch.
142    pub fn historical_migration(&self) -> bool {
143        self.historical_migration
144    }
145
146    /// The V1 capture `posthog-request-id` of the final attempt, when one was
147    /// sent. `None` for a serialization failure (no request reached the wire)
148    /// and on the v0 pipeline (which has no request id).
149    #[cfg(feature = "capture-v1")]
150    pub fn request_id(&self) -> Option<&Uuid> {
151        self.request_id
152    }
153
154    /// Per-event server verdicts for the batch (V1 capture pipeline only).
155    ///
156    /// Maps event UUID to its [`EventResult`]. Includes **all** verdicts the
157    /// batch collected — persisted (`ok`/`warning`) as well as lost
158    /// (`retry`/`drop`) — so this map can be larger than
159    /// [`event_count`](Self::event_count); filter by
160    /// [`EventStatus`](crate::EventStatus) to isolate the failures. Complete
161    /// when [`error`](Self::error) is `None` (a `2xx` where events weren't
162    /// persisted after retries); possibly partial on a batch-level failure
163    /// (only verdicts collected from earlier attempts).
164    #[cfg(feature = "capture-v1")]
165    pub fn event_results(&self) -> &HashMap<Uuid, EventResult> {
166        self.results
167    }
168
169    /// The structured error body returned by the V1 capture backend on a
170    /// non-`2xx` response (`error`, `error_description`, `error_uri`), when the
171    /// body parsed as one. `None` for a transport error, a `2xx`, or an
172    /// unrecognizable body — the raw body remains available via
173    /// [`error`](Self::error).
174    #[cfg(feature = "capture-v1")]
175    pub fn error_response(&self) -> Option<&V1ErrorResponse> {
176        self.error_response
177    }
178}
179
180/// Details of a failed remote `/flags` request.
181///
182/// Fields are read through accessors; the struct is `#[non_exhaustive]`.
183#[derive(Debug)]
184#[non_exhaustive]
185pub struct FlagsFailure<'a> {
186    pub(crate) error: &'a Error,
187    pub(crate) endpoint: &'a str,
188    pub(crate) distinct_id: Option<&'a str>,
189    pub(crate) status: Option<u16>,
190    pub(crate) body: Option<&'a str>,
191}
192
193impl<'a> FlagsFailure<'a> {
194    /// The cause of the failure ([`Error::Connection`] for transport or
195    /// non-success status, [`Error::Serialization`] for an unparseable body).
196    pub fn error(&self) -> &Error {
197        self.error
198    }
199
200    /// The `/flags` endpoint URL the request targeted.
201    pub fn endpoint(&self) -> &str {
202        self.endpoint
203    }
204
205    /// The `distinct_id` the request was evaluating flags for, when known.
206    pub fn distinct_id(&self) -> Option<&str> {
207        self.distinct_id
208    }
209
210    /// The HTTP status, or `None` when no response was received (a transport
211    /// error or an exhausted transient-retry budget).
212    pub fn status(&self) -> Option<u16> {
213        self.status
214    }
215
216    /// The response body, when one was read (present on a non-success status).
217    pub fn body(&self) -> Option<&str> {
218        self.body
219    }
220}
221
222/// Details of a failed local-evaluation definitions poll.
223///
224/// Fields are read through accessors; the struct is `#[non_exhaustive]`.
225/// Credentials (the personal API key) are never surfaced here.
226#[derive(Debug)]
227#[non_exhaustive]
228pub struct LocalEvaluationFailure<'a> {
229    pub(crate) error: &'a Error,
230    pub(crate) status: Option<u16>,
231}
232
233impl<'a> LocalEvaluationFailure<'a> {
234    /// The cause of the failure ([`Error::Connection`] for transport or
235    /// non-success status, [`Error::Serialization`] for an unparseable body).
236    pub fn error(&self) -> &Error {
237        self.error
238    }
239
240    /// The HTTP status, or `None` when no response was received (a transport
241    /// error).
242    pub fn status(&self) -> Option<u16> {
243        self.status
244    }
245}