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}