use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use tracing::warn;
use super::common::apply_on_error_hooks;
use super::{CaptureFailure, ClientOptions, PostHogError};
use crate::error::Error;
use crate::Event;
pub(crate) enum Control {
Capture {
event: Box<Event>,
},
HistoricalBatch {
events: Vec<Event>,
},
Flush(Completion),
FlushCaptures(Completion),
Shutdown(Completion),
#[cfg(test)]
Tick(Completion),
}
pub(crate) enum Completion {
Blocking(mpsc::Sender<()>),
#[cfg(feature = "async-client")]
Async(tokio::sync::oneshot::Sender<()>),
}
impl Completion {
fn signal(self) {
match self {
Completion::Blocking(tx) => {
let _ = tx.send(());
}
#[cfg(feature = "async-client")]
Completion::Async(tx) => {
let _ = tx.send(());
}
}
}
}
pub(crate) trait Clock: Send + Sync + 'static {
fn now(&self) -> Instant;
fn now_utc(&self) -> DateTime<Utc>;
}
struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> Instant {
Instant::now()
}
fn now_utc(&self) -> DateTime<Utc> {
Utc::now()
}
}
pub(crate) struct TransportHandle {
tx: mpsc::Sender<Control>,
len: Arc<AtomicUsize>,
closed: AtomicBool,
worker: Mutex<Option<JoinHandle<()>>>,
full_warned: AtomicBool,
max_queue_size: usize,
clock: Arc<dyn Clock>,
#[cfg_attr(not(feature = "error-tracking"), allow(dead_code))]
worker_id: Option<std::thread::ThreadId>,
}
const WORKER_THREAD_NAME: &str = "posthog-transport";
const MAX_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(86_400);
impl TransportHandle {
pub(crate) fn spawn(options: ClientOptions) -> Self {
Self::spawn_with_clock(options, Arc::new(SystemClock))
}
fn spawn_with_clock(options: ClientOptions, clock: Arc<dyn Clock>) -> Self {
let (tx, rx) = mpsc::channel::<Control>();
let len = Arc::new(AtomicUsize::new(0));
let max_queue_size = options.max_queue_size;
let worker_len = len.clone();
let worker_clock = Arc::clone(&clock);
let worker = thread::Builder::new()
.name(WORKER_THREAD_NAME.to_string())
.spawn(move || run_worker(options, rx, worker_len, worker_clock))
.ok();
let worker_id = worker.as_ref().map(|handle| handle.thread().id());
Self {
tx,
len,
closed: AtomicBool::new(false),
worker: Mutex::new(worker),
full_warned: AtomicBool::new(false),
max_queue_size,
clock,
worker_id,
}
}
pub(crate) fn enqueue(&self, event: Event) {
if self.closed.load(Ordering::Acquire) {
return;
}
if try_reserve(&self.len, self.max_queue_size, &self.full_warned) {
self.send_reserved(event);
}
}
#[cfg(feature = "error-tracking")]
pub(crate) fn enqueue_panic(&self, event: Event) {
if self.closed.load(Ordering::Acquire) {
return;
}
if reserve_slot(&self.len, self.max_queue_size).is_some() {
self.send_reserved(event);
}
}
fn send_reserved(&self, mut event: Event) {
event.ensure_timestamp(self.clock.now_utc());
if self
.tx
.send(Control::Capture {
event: Box::new(event),
})
.is_err()
{
dec_len(&self.len, 1);
}
}
pub(crate) fn enqueue_historical(&self, mut events: Vec<Event>) {
if self.closed.load(Ordering::Acquire) {
return;
}
let mut fitted = 0;
while fitted < events.len()
&& try_reserve(&self.len, self.max_queue_size, &self.full_warned)
{
events[fitted].ensure_timestamp(self.clock.now_utc());
fitted += 1;
}
events.truncate(fitted);
if events.is_empty() {
return;
}
if self.tx.send(Control::HistoricalBatch { events }).is_err() {
dec_len(&self.len, fitted);
}
}
pub(crate) fn send_control(&self, control: Control) -> bool {
self.tx.send(control).is_ok()
}
pub(crate) fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
#[cfg(any(test, feature = "test-harness"))]
pub(crate) fn pending(&self) -> usize {
self.len.load(Ordering::Acquire)
}
pub(crate) fn begin_close(&self) -> bool {
!self.closed.swap(true, Ordering::AcqRel)
}
pub(crate) fn join(&self) {
if let Some(handle) = self.worker.lock().unwrap_or_else(|p| p.into_inner()).take() {
let _ = handle.join();
}
}
#[cfg(test)]
fn tick(&self) {
let (tx, rx) = mpsc::channel();
if self.send_control(Control::Tick(Completion::Blocking(tx))) {
let _ = rx.recv();
}
}
#[cfg(test)]
pub(crate) fn flush_blocking(&self) {
let (tx, rx) = mpsc::channel();
if self.send_control(Control::Flush(Completion::Blocking(tx))) {
let _ = rx.recv();
}
}
#[cfg(feature = "error-tracking")]
pub(crate) fn flush_blocking_timeout(&self, timeout: Duration) {
let (tx, rx) = mpsc::channel();
if self.send_control(Control::FlushCaptures(Completion::Blocking(tx))) {
let _ = rx.recv_timeout(timeout.min(MAX_SHUTDOWN_TIMEOUT));
}
}
#[cfg(feature = "error-tracking")]
pub(crate) fn on_worker_thread(&self) -> bool {
self.worker_id == Some(std::thread::current().id())
}
#[cfg(test)]
fn shutdown_blocking(&self) {
if !self.begin_close() {
return;
}
let (tx, rx) = mpsc::channel();
if self.send_control(Control::Shutdown(Completion::Blocking(tx))) {
let _ = rx.recv();
}
self.join();
}
}
fn reserve_slot(len: &AtomicUsize, max: usize) -> Option<usize> {
loop {
let current = len.load(Ordering::Acquire);
if current >= max {
return None;
}
if len
.compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
return Some(current);
}
}
}
fn try_reserve(len: &AtomicUsize, max: usize, warned: &AtomicBool) -> bool {
match reserve_slot(len, max) {
Some(current) => {
if current == 0 {
warned.store(false, Ordering::Release);
}
true
}
None => {
if !warned.swap(true, Ordering::AcqRel) {
warn!("posthog-rs: event queue full (capacity {max}); dropping events");
}
false
}
}
}
fn dec_len(len: &AtomicUsize, n: usize) {
if n == 0 {
return;
}
let _ = len.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
debug_assert!(
current >= n,
"posthog-rs: in-flight counter underflow ({} - {})",
current,
n
);
Some(current.saturating_sub(n))
});
}
fn bound_request(
request: reqwest::blocking::RequestBuilder,
deadline: Option<Instant>,
now: Instant,
request_timeout_seconds: u64,
) -> reqwest::blocking::RequestBuilder {
match deadline {
Some(d) => request.timeout(
d.saturating_duration_since(now)
.min(Duration::from_secs(request_timeout_seconds)),
),
None => request,
}
}
fn compute_wait(
now: Instant,
buffer_since: Option<Instant>,
flush_interval: Duration,
earliest_retry: Option<Instant>,
) -> Option<Duration> {
let deadline = match (buffer_since, earliest_retry) {
(Some(since), Some(retry)) => Some((since + flush_interval).min(retry)),
(Some(since), None) => Some(since + flush_interval),
(None, Some(retry)) => Some(retry),
(None, None) => None,
};
deadline.map(|d| d.saturating_duration_since(now))
}
enum Wake {
Msg(Control),
Timeout,
Disconnected,
}
fn run_worker(
options: ClientOptions,
rx: mpsc::Receiver<Control>,
len: Arc<AtomicUsize>,
clock: Arc<dyn Clock>,
) {
let flush_at = options.flush_at.max(1);
let max_batch_size = options.max_batch_size.max(1);
let flush_interval = Duration::from_millis(options.flush_interval_ms);
let shutdown_timeout =
Duration::from_millis(options.shutdown_timeout_ms).min(MAX_SHUTDOWN_TIMEOUT);
let mut pipeline = Pipeline::new(&options, Arc::clone(&clock), len);
let mut buffer: Vec<Event> = Vec::new();
let mut buffer_since: Option<Instant> = None;
let mut historical: VecDeque<Vec<Event>> = VecDeque::new();
let mut historical_since: Option<Instant> = None;
loop {
#[cfg(test)]
let mut tick_completion: Option<Completion> = None;
let wait = {
let base = compute_wait(
clock.now(),
buffer_since,
flush_interval,
pipeline.earliest_retry(),
);
match historical_since {
Some(since) => {
let hwait = (since + flush_interval).saturating_duration_since(clock.now());
Some(base.map_or(hwait, |w| w.min(hwait)))
}
None => base,
}
};
let wake = match wait {
None => match rx.recv() {
Ok(msg) => Wake::Msg(msg),
Err(_) => Wake::Disconnected,
},
Some(timeout) => match rx.recv_timeout(timeout) {
Ok(msg) => Wake::Msg(msg),
Err(mpsc::RecvTimeoutError::Timeout) => Wake::Timeout,
Err(mpsc::RecvTimeoutError::Disconnected) => Wake::Disconnected,
},
};
match wake {
Wake::Msg(Control::Capture { event }) => {
if buffer.is_empty() {
buffer_since = Some(clock.now());
}
buffer.push(*event);
if buffer.len() >= flush_at {
send_buffer(&mut pipeline, &mut buffer, max_batch_size, None);
buffer_since = None;
}
}
Wake::Msg(Control::HistoricalBatch { mut events }) => {
while !events.is_empty() {
let take = events.len().min(max_batch_size);
historical.push_back(events.drain(..take).collect());
}
if historical_since.is_none() {
historical_since = Some(clock.now());
}
if historical.iter().map(Vec::len).sum::<usize>() >= flush_at {
drain_historical(&mut pipeline, &mut historical, None);
historical_since = None;
}
}
Wake::Msg(Control::Flush(completion)) => {
pipeline.flush_retries(None);
drain_historical(&mut pipeline, &mut historical, None);
historical_since = None;
send_buffer(&mut pipeline, &mut buffer, max_batch_size, None);
buffer_since = None;
completion.signal();
}
Wake::Msg(Control::FlushCaptures(completion)) => {
send_buffer(&mut pipeline, &mut buffer, max_batch_size, None);
buffer_since = None;
pipeline.flush_retries(None);
drain_historical(&mut pipeline, &mut historical, None);
historical_since = None;
completion.signal();
}
Wake::Msg(Control::Shutdown(completion)) => {
let deadline = clock.now() + shutdown_timeout;
pipeline.flush_retries(Some(deadline));
drain_historical(&mut pipeline, &mut historical, Some(deadline));
send_buffer(&mut pipeline, &mut buffer, max_batch_size, Some(deadline));
completion.signal();
drain_pending_completions(&rx, &pipeline.len);
return;
}
#[cfg(test)]
Wake::Msg(Control::Tick(completion)) => {
tick_completion = Some(completion);
}
Wake::Timeout => {}
Wake::Disconnected => {
let deadline = clock.now() + shutdown_timeout;
drain_historical(&mut pipeline, &mut historical, Some(deadline));
send_buffer(&mut pipeline, &mut buffer, max_batch_size, Some(deadline));
pipeline.flush_retries(Some(deadline));
return;
}
}
if buffer_since.is_some_and(|since| clock.now().duration_since(since) >= flush_interval) {
send_buffer(&mut pipeline, &mut buffer, max_batch_size, None);
buffer_since = None;
}
if historical_since.is_some_and(|since| clock.now().duration_since(since) >= flush_interval)
{
drain_historical(&mut pipeline, &mut historical, None);
historical_since = None;
}
pipeline.attempt_due();
#[cfg(test)]
if let Some(completion) = tick_completion {
completion.signal();
}
}
}
fn drain_pending_completions(rx: &mpsc::Receiver<Control>, len: &AtomicUsize) {
while let Ok(control) = rx.try_recv() {
match control {
Control::Flush(c) | Control::FlushCaptures(c) | Control::Shutdown(c) => c.signal(),
#[cfg(test)]
Control::Tick(c) => c.signal(),
Control::Capture { .. } => dec_len(len, 1),
Control::HistoricalBatch { events } => dec_len(len, events.len()),
}
}
}
fn send_buffer(
pipeline: &mut Pipeline,
buffer: &mut Vec<Event>,
max_batch_size: usize,
deadline: Option<Instant>,
) {
while !buffer.is_empty() {
if deadline.is_some_and(|d| pipeline.clock.now() >= d) {
warn!(
"posthog-rs: shutdown timeout reached; dropping {} buffered event(s)",
buffer.len()
);
dec_len(&pipeline.len, buffer.len());
buffer.clear();
return;
}
let take = buffer.len().min(max_batch_size);
let chunk: Vec<Event> = buffer.drain(..take).collect();
pipeline.send_batch(chunk, false, deadline);
}
}
fn drain_historical(
pipeline: &mut Pipeline,
historical: &mut VecDeque<Vec<Event>>,
deadline: Option<Instant>,
) {
while let Some(chunk) = historical.pop_front() {
if deadline.is_some_and(|d| pipeline.clock.now() >= d) {
let dropped = chunk.len() + historical.iter().map(Vec::len).sum::<usize>();
warn!("posthog-rs: shutdown timeout reached; dropping {dropped} historical event(s)");
dec_len(&pipeline.len, chunk.len());
for rest in historical.drain(..) {
dec_len(&pipeline.len, rest.len());
}
return;
}
pipeline.send_batch(chunk, true, deadline);
}
}
#[cfg(feature = "capture-v1")]
use std::collections::HashMap;
#[cfg(feature = "capture-v1")]
use uuid::Uuid;
#[cfg(feature = "capture-v1")]
struct RetryBatch {
pending: Vec<crate::event_v1::V1Event>,
request_id: Uuid,
created_at: String,
final_results: HashMap<Uuid, crate::event_v1::EventResult>,
historical_migration: bool,
attempt: u32,
next_at: Instant,
}
#[cfg(feature = "capture-v1")]
struct Pipeline {
http: reqwest::blocking::Client,
options: ClientOptions,
url: String,
clock: Arc<dyn Clock>,
len: Arc<AtomicUsize>,
retries: VecDeque<RetryBatch>,
}
#[cfg(feature = "capture-v1")]
impl Pipeline {
fn new(options: &ClientOptions, clock: Arc<dyn Clock>, len: Arc<AtomicUsize>) -> Self {
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(options.request_timeout_seconds))
.build()
.unwrap_or_default();
let url = options
.endpoints()
.build_custom_url(super::v1_capture::V1_CAPTURE_PATH);
Self {
http,
options: options.clone(),
url,
clock,
len,
retries: VecDeque::new(),
}
}
fn send_batch(
&mut self,
events: Vec<Event>,
historical_migration: bool,
deadline: Option<Instant>,
) {
use super::common::{apply_before_send_hooks, apply_capture_defaults};
let defaults = self.options.capture_defaults();
let original = events.len();
let processed: Vec<Event> = events
.into_iter()
.filter_map(|mut event| {
apply_capture_defaults(&mut event, &defaults);
apply_before_send_hooks(&self.options.before_send, event)
})
.collect();
dec_len(&self.len, original - processed.len());
if processed.is_empty() {
return;
}
let now = self.clock.now();
let pending =
super::v1_capture::build_events_at(&processed, &defaults, self.clock.now_utc());
let batch = RetryBatch {
pending,
request_id: Uuid::now_v7(),
created_at: self.clock.now_utc().to_rfc3339(),
final_results: HashMap::new(),
historical_migration,
attempt: 1,
next_at: now,
};
self.attempt(batch, deadline);
}
fn attempt(&mut self, mut batch: RetryBatch, deadline: Option<Instant>) {
use super::v1_capture::{self, Step};
use crate::event_v1::{V1BatchRequestRef, V1ErrorResponse};
let req = V1BatchRequestRef {
created_at: &batch.created_at,
historical_migration: batch.historical_migration.then_some(true),
batch: &batch.pending,
};
let payload = match serde_json::to_vec(&req) {
Ok(p) => p,
Err(e) => {
let count = batch.pending.len();
if self.options.on_error.is_empty() {
warn!("posthog-rs: dropping {count} event(s), serialization failed: {e}");
} else {
let err = Error::Serialization(e.to_string());
let lost = count + undelivered_results(&batch.final_results);
self.fire_capture(&batch, None, Some(&err), None, None, lost);
}
dec_len(&self.len, count);
return;
}
};
let mut headers = v1_capture::build_headers_at(
&self.options,
&batch.request_id,
batch.attempt,
self.clock.now_utc(),
);
let body =
v1_capture::maybe_compress(self.options.capture_compression, &mut headers, payload);
let count = batch.pending.len();
let request = bound_request(
self.http.post(&self.url).headers(headers).body(body),
deadline,
self.clock.now(),
self.options.request_timeout_seconds,
);
let mut http_status: Option<u16> = None;
let mut response_body: Option<String> = None;
let step = match request.send() {
Err(e) => v1_capture::after_transport_error(
&self.options,
&batch.request_id,
batch.attempt,
e.to_string(),
),
Ok(resp) => {
let status = resp.status().as_u16();
http_status = Some(status);
let retry_after = v1_capture::parse_retry_after(resp.headers());
let text = resp.text().unwrap_or_else(|_| "Unknown error".to_string());
let step = v1_capture::after_response(
&self.options,
&batch.request_id,
batch.attempt,
status,
retry_after,
&text,
&mut batch.pending,
&mut batch.final_results,
);
if !self.options.on_error.is_empty() && !(200..=299).contains(&status) {
response_body = Some(text);
}
step
}
};
dec_len(&self.len, count - batch.pending.len());
match step {
Step::Done => {
if !self.options.on_error.is_empty() {
let lost = batch.pending.len() + undelivered_results(&batch.final_results);
if lost > 0 {
self.fire_capture(
&batch,
Some(&batch.request_id),
None,
http_status,
None,
lost,
);
}
}
}
Step::Fail(e) => {
if self.options.on_error.is_empty() {
warn!("posthog-rs: dropping {} event(s): {e}", batch.pending.len());
} else {
let error_response = response_body
.as_deref()
.and_then(|b| serde_json::from_str::<V1ErrorResponse>(b).ok());
let lost = batch.pending.len() + undelivered_results(&batch.final_results);
self.fire_capture(
&batch,
Some(&batch.request_id),
Some(&e),
http_status,
error_response.as_ref(),
lost,
);
}
dec_len(&self.len, batch.pending.len());
}
Step::Backoff(delay) => {
if deadline.is_some() {
warn!(
"posthog-rs: dropping {} undelivered event(s) on shutdown",
batch.pending.len()
);
dec_len(&self.len, batch.pending.len());
} else {
batch.attempt += 1;
batch.next_at = self.clock.now() + delay;
self.retries.push_back(batch);
}
}
}
}
fn earliest_retry(&self) -> Option<Instant> {
self.retries.iter().map(|b| b.next_at).min()
}
fn attempt_due(&mut self) {
let now = self.clock.now();
for batch in std::mem::take(&mut self.retries) {
if now >= batch.next_at {
self.attempt(batch, None);
} else {
self.retries.push_back(batch);
}
}
}
fn flush_retries(&mut self, deadline: Option<Instant>) {
for batch in std::mem::take(&mut self.retries) {
if deadline.is_some_and(|d| self.clock.now() >= d) {
warn!(
"posthog-rs: shutdown timeout reached; dropping {} undelivered event(s)",
batch.pending.len()
);
dec_len(&self.len, batch.pending.len());
} else {
self.attempt(batch, deadline);
}
}
}
fn fire_capture(
&self,
batch: &RetryBatch,
request_id: Option<&Uuid>,
error: Option<&Error>,
status: Option<u16>,
error_response: Option<&crate::event_v1::V1ErrorResponse>,
event_count: usize,
) {
let failure = PostHogError::Capture(CaptureFailure {
error,
status,
attempt: batch.attempt,
event_count,
historical_migration: batch.historical_migration,
request_id,
results: &batch.final_results,
error_response,
});
apply_on_error_hooks(&self.options.on_error, &failure);
}
}
#[cfg(feature = "capture-v1")]
fn undelivered_results(results: &HashMap<Uuid, crate::event_v1::EventResult>) -> usize {
use crate::event_v1::EventStatus;
results
.values()
.filter(|r| matches!(r.result, EventStatus::Retry | EventStatus::Drop))
.count()
}
#[cfg(not(feature = "capture-v1"))]
struct RetryBatch {
body: Vec<u8>,
encoding: Option<&'static str>,
count: usize,
historical_migration: bool,
attempt: u32,
next_at: Instant,
}
#[cfg(not(feature = "capture-v1"))]
struct Pipeline {
http: reqwest::blocking::Client,
options: ClientOptions,
url_base: String,
clock: Arc<dyn Clock>,
len: Arc<AtomicUsize>,
retries: VecDeque<RetryBatch>,
}
#[cfg(not(feature = "capture-v1"))]
impl Pipeline {
fn new(options: &ClientOptions, clock: Arc<dyn Clock>, len: Arc<AtomicUsize>) -> Self {
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(options.request_timeout_seconds))
.build()
.unwrap_or_default();
let url_base = options
.endpoints()
.build_url(crate::endpoints::Endpoint::Batch);
Self {
http,
options: options.clone(),
url_base,
clock,
len,
retries: VecDeque::new(),
}
}
fn send_batch(
&mut self,
events: Vec<Event>,
historical_migration: bool,
deadline: Option<Instant>,
) {
let defaults = self.options.capture_defaults();
let count = events.len();
let (payload, kept) = match super::v0_capture::build_batch_payload(
events,
self.options.api_key.clone(),
historical_migration,
self.clock.now_utc(),
&defaults,
&self.options.before_send,
) {
Ok(Some(pair)) => pair,
Ok(None) => {
dec_len(&self.len, count);
return;
}
Err(e) => {
if self.options.on_error.is_empty() {
warn!("posthog-rs: dropping {count} event(s), serialization failed: {e}");
} else {
let err = Error::Serialization(e.to_string());
self.fire_capture(Some(&err), None, 1, historical_migration, count);
}
dec_len(&self.len, count);
return;
}
};
dec_len(&self.len, count - kept);
let (body, encoding) = super::v0_capture::encode_body(&self.options, payload);
let batch = RetryBatch {
body,
encoding,
count: kept,
historical_migration,
attempt: 1,
next_at: self.clock.now(),
};
self.attempt(batch, deadline);
}
fn attempt(&mut self, mut batch: RetryBatch, deadline: Option<Instant>) {
use super::get_default_user_agent;
use super::retry::{v0_after_response, v0_after_transport_error, Step};
use reqwest::header::{CONTENT_TYPE, USER_AGENT};
let url = match batch.encoding {
Some(token) => format!("{}?compression={token}", self.url_base),
None => self.url_base.clone(),
};
let mut request = self
.http
.post(&url)
.header(CONTENT_TYPE, "application/json")
.header(USER_AGENT, get_default_user_agent())
.body(batch.body.clone());
if let Some(token) = batch.encoding {
request = request.header(reqwest::header::CONTENT_ENCODING, token);
}
let request = super::v0_capture::apply_extra_headers(&self.options, request);
let request = bound_request(
request,
deadline,
self.clock.now(),
self.options.request_timeout_seconds,
);
let mut http_status: Option<u16> = None;
let step = match request.send() {
Err(e) => v0_after_transport_error(&self.options, batch.attempt, e.to_string()),
Ok(response) => {
let status = response.status().as_u16();
http_status = Some(status);
let retry_after = super::retry::parse_retry_after(response.headers());
let body = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
v0_after_response(&self.options, batch.attempt, status, retry_after, &body)
}
};
match step {
Step::Done => dec_len(&self.len, batch.count),
Step::Fail(e) => {
if self.options.on_error.is_empty() {
warn!("posthog-rs: dropping {} event(s): {e}", batch.count);
} else {
self.fire_capture(
Some(&e),
http_status,
batch.attempt,
batch.historical_migration,
batch.count,
);
}
dec_len(&self.len, batch.count);
}
Step::Backoff(delay) => {
if deadline.is_some() {
warn!(
"posthog-rs: dropping {} undelivered event(s) on shutdown",
batch.count
);
dec_len(&self.len, batch.count);
} else {
batch.attempt += 1;
batch.next_at = self.clock.now() + delay;
self.retries.push_back(batch);
}
}
}
}
fn earliest_retry(&self) -> Option<Instant> {
self.retries.iter().map(|b| b.next_at).min()
}
fn attempt_due(&mut self) {
let now = self.clock.now();
for batch in std::mem::take(&mut self.retries) {
if now >= batch.next_at {
self.attempt(batch, None);
} else {
self.retries.push_back(batch);
}
}
}
fn flush_retries(&mut self, deadline: Option<Instant>) {
for batch in std::mem::take(&mut self.retries) {
if deadline.is_some_and(|d| self.clock.now() >= d) {
warn!(
"posthog-rs: shutdown timeout reached; dropping {} undelivered event(s)",
batch.count
);
dec_len(&self.len, batch.count);
} else {
self.attempt(batch, deadline);
}
}
}
fn fire_capture(
&self,
error: Option<&Error>,
status: Option<u16>,
attempt: u32,
historical_migration: bool,
event_count: usize,
) {
let failure = PostHogError::Capture(CaptureFailure {
error,
status,
attempt,
event_count,
historical_migration,
});
apply_on_error_hooks(&self.options.on_error, &failure);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::ClientOptionsBuilder;
use httpmock::prelude::*;
#[derive(Clone)]
struct ManualClock {
inner: Arc<Mutex<(Instant, DateTime<Utc>)>>,
}
impl ManualClock {
fn new() -> Self {
Self {
inner: Arc::new(Mutex::new((Instant::now(), Utc::now()))),
}
}
fn advance(&self, by: Duration) {
let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
g.0 += by;
g.1 += chrono::Duration::from_std(by).expect("test duration fits chrono");
}
}
impl Clock for ManualClock {
fn now(&self) -> Instant {
self.inner.lock().unwrap_or_else(|p| p.into_inner()).0
}
fn now_utc(&self) -> DateTime<Utc> {
self.inner.lock().unwrap_or_else(|p| p.into_inner()).1
}
}
fn ok_mock(server: &MockServer) -> httpmock::Mock<'_> {
server.mock(|when, then| {
when.method(POST);
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({ "results": {} }));
})
}
fn options(base_url: String) -> ClientOptionsBuilder {
let mut builder = ClientOptionsBuilder::default();
builder
.api_key("phc_test".to_string())
.host(base_url)
.flush_at(100usize)
.flush_interval_ms(10_000u64);
builder
}
#[test]
fn try_reserve_bounds_at_capacity_and_warns_once() {
let len = AtomicUsize::new(0);
let warned = AtomicBool::new(false);
assert!(try_reserve(&len, 2, &warned));
assert!(try_reserve(&len, 2, &warned));
assert!(!try_reserve(&len, 2, &warned)); assert_eq!(len.load(Ordering::Acquire), 2);
assert!(warned.load(Ordering::Acquire));
assert!(!try_reserve(&len, 2, &warned));
}
#[test]
fn try_reserve_rearms_warning_after_full_drain() {
let len = AtomicUsize::new(0);
let warned = AtomicBool::new(false);
assert!(try_reserve(&len, 1, &warned));
assert!(!try_reserve(&len, 1, &warned)); assert!(warned.load(Ordering::Acquire));
len.fetch_sub(1, Ordering::AcqRel); assert!(try_reserve(&len, 1, &warned)); assert!(!warned.load(Ordering::Acquire));
assert!(!try_reserve(&len, 1, &warned));
assert!(warned.load(Ordering::Acquire));
}
#[test]
fn compute_wait_picks_interval_then_zero_when_elapsed() {
let base = Instant::now();
let interval = Duration::from_secs(10);
assert_eq!(
compute_wait(base, Some(base), interval, None),
Some(interval)
);
assert_eq!(
compute_wait(base + Duration::from_secs(10), Some(base), interval, None),
Some(Duration::ZERO)
);
assert_eq!(
compute_wait(base + Duration::from_secs(9), Some(base), interval, None),
Some(Duration::from_secs(1))
);
}
#[test]
fn compute_wait_blocks_when_idle_and_prefers_earliest() {
let base = Instant::now();
let interval = Duration::from_secs(10);
assert_eq!(compute_wait(base, None, interval, None), None);
let retry_at = base + Duration::from_secs(2);
assert_eq!(
compute_wait(base, Some(base), interval, Some(retry_at)),
Some(Duration::from_secs(2))
);
assert_eq!(
compute_wait(base, None, interval, Some(retry_at)),
Some(Duration::from_secs(2))
);
}
#[test]
fn drain_pending_completions_signals_and_releases_queued_controls() {
let (tx, rx) = mpsc::channel::<Control>();
let (ftx, frx) = mpsc::channel::<()>();
let (stx, srx) = mpsc::channel::<()>();
let len = AtomicUsize::new(3); tx.send(Control::Flush(Completion::Blocking(ftx))).unwrap();
tx.send(Control::Shutdown(Completion::Blocking(stx)))
.unwrap();
tx.send(Control::Capture {
event: Box::new(Event::new("dropped", "user-1")),
})
.unwrap();
tx.send(Control::HistoricalBatch {
events: vec![Event::new("h1", "user-1"), Event::new("h2", "user-1")],
})
.unwrap();
drop(tx);
drain_pending_completions(&rx, &len);
assert!(frx.recv().is_ok(), "flush completion was not signaled");
assert!(srx.recv().is_ok(), "shutdown completion was not signaled");
assert_eq!(
len.load(Ordering::Acquire),
0,
"dropped events left counted as pending"
);
}
#[test]
fn interval_flush_fires_on_clock_advance() {
let server = MockServer::start();
let mock = ok_mock(&server);
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url()).build().unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("Delayed", "user-1"));
handle.tick(); mock.assert_hits(0);
assert_eq!(
handle.pending(),
1,
"buffered-but-undelivered event stays in flight"
);
clock.advance(Duration::from_secs(10));
handle.tick(); mock.assert_hits(1);
assert_eq!(
handle.pending(),
0,
"delivered event is decremented from in flight"
);
handle.shutdown_blocking();
}
#[test]
fn retry_backoff_is_honored_against_the_clock() {
let server = MockServer::start();
let mut fail = server.mock(|when, then| {
when.method(POST);
then.status(503);
});
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.max_capture_attempts(5u32)
.retry_initial_backoff_ms(1_000u64)
.retry_max_backoff_ms(60_000u64)
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("Save", "user-1"));
handle.flush_blocking(); fail.assert_hits(1);
handle.tick(); fail.assert_hits(1);
fail.delete();
let ok = ok_mock(&server);
clock.advance(Duration::from_secs(1)); handle.tick(); ok.assert_hits(1);
handle.shutdown_blocking();
}
#[test]
fn before_send_dropped_events_are_not_counted_in_flight() {
let server = MockServer::start();
let fail = server.mock(|when, then| {
when.method(POST);
then.status(503);
});
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.before_send(|event| {
if event.properties().get("__drop").is_some() {
None
} else {
Some(event)
}
})
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("keep", "user-1"));
let mut dropped = Event::new("drop", "user-1");
dropped.insert_prop("__drop", true).unwrap();
handle.enqueue(dropped);
assert_eq!(handle.pending(), 2);
handle.flush_blocking();
fail.assert_hits(1);
assert_eq!(handle.pending(), 1);
handle.shutdown_blocking();
}
#[test]
fn shutdown_timeout_bounds_a_stalled_in_flight_send() {
let server = MockServer::start();
let _stall = server.mock(|when, then| {
when.method(POST);
then.status(200).delay(Duration::from_secs(5)).body("{}");
});
let handle = TransportHandle::spawn(
options(server.base_url())
.shutdown_timeout_ms(200u64)
.build()
.unwrap(),
);
handle.enqueue(Event::new("e", "user-1"));
let start = Instant::now();
handle.shutdown_blocking();
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"shutdown blocked for {:?}; in-flight send was not bounded by shutdown_timeout_ms",
elapsed
);
}
fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&Utc))
.ok()
.or_else(|| {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f")
.map(|n| n.and_utc())
.ok()
})
}
#[test]
fn event_timestamp_is_capture_time_not_publish_time() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST).matches(|req| {
let Some(bytes) = req.body.as_deref() else {
return false;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return false;
};
let event_ts = json["batch"][0]["timestamp"].as_str().and_then(parse_ts);
let publish_ts = json
.get("created_at")
.or_else(|| json.get("sent_at"))
.and_then(|v| v.as_str())
.and_then(parse_ts);
match (event_ts, publish_ts) {
(Some(e), Some(p)) => (9_900..=10_100).contains(&(p - e).num_milliseconds()),
_ => false,
}
});
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({ "results": {} }));
});
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url()).build().unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("Captured", "user-1")); clock.advance(Duration::from_secs(10)); handle.flush_blocking();
mock.assert_hits(1);
handle.shutdown_blocking();
}
#[test]
fn shutdown_timeout_drops_undelivered_without_blocking() {
let server = MockServer::start();
let mock = ok_mock(&server);
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.shutdown_timeout_ms(0u64)
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("Dropped", "user-1"));
handle.shutdown_blocking();
mock.assert_hits(0);
assert_eq!(
handle.pending(),
0,
"dropped events leave nothing in flight"
);
}
#[test]
fn historical_batch_sends_chunked() {
let server = MockServer::start();
let mock = ok_mock(&server);
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.max_batch_size(2usize)
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue_historical(vec![
Event::new("H1", "user-1"),
Event::new("H2", "user-1"),
Event::new("H3", "user-1"),
]);
handle.flush_blocking();
mock.assert_hits(2); assert_eq!(handle.pending(), 0, "all historical events delivered");
handle.shutdown_blocking();
}
#[test]
fn historical_batch_respects_shutdown_timeout() {
let server = MockServer::start();
let mock = ok_mock(&server);
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.shutdown_timeout_ms(0u64)
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue_historical(vec![Event::new("H", "user-1")]);
handle.shutdown_blocking();
mock.assert_hits(0);
assert_eq!(
handle.pending(),
0,
"historical events abandoned under a zero shutdown timeout"
);
}
#[test]
fn historical_batch_flushes_at_size_threshold() {
let server = MockServer::start();
let mock = ok_mock(&server);
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url()).flush_at(2usize).build().unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue_historical(vec![Event::new("H1", "user-1"), Event::new("H2", "user-1")]);
handle.tick();
mock.assert_hits(1);
assert_eq!(
handle.pending(),
0,
"threshold-sized historical batch delivered"
);
handle.shutdown_blocking();
}
#[test]
fn capture_failure_hook_fires_on_terminal_reject() {
let server = MockServer::start();
let _bad = server.mock(|when, then| {
when.method(POST);
then.status(400).body("bad request");
});
let recorded = Arc::new(Mutex::new(Vec::<(Option<u16>, usize, bool, bool)>::new()));
let sink = recorded.clone();
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.on_error(move |failure| {
if let PostHogError::Capture(c) = failure {
sink.lock().unwrap_or_else(|p| p.into_inner()).push((
c.status(),
c.event_count(),
c.historical_migration(),
c.error().is_some(),
));
}
})
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("e", "user-1"));
handle.flush_blocking();
handle.shutdown_blocking();
let recorded = recorded.lock().unwrap_or_else(|p| p.into_inner());
assert_eq!(recorded.len(), 1, "exactly one capture failure expected");
let (status, count, historical, has_error) = recorded[0];
assert_eq!(status, Some(400));
assert_eq!(count, 1);
assert!(!historical);
assert!(has_error, "a terminal reject carries the underlying error");
}
#[cfg(feature = "capture-v1")]
#[test]
fn undelivered_results_counts_retry_and_drop_only() {
use crate::event_v1::{EventResult, EventStatus};
let mk = |result| EventResult {
result,
details: None,
};
let results = HashMap::from([
(Uuid::now_v7(), mk(EventStatus::Ok)),
(Uuid::now_v7(), mk(EventStatus::Warning)),
(Uuid::now_v7(), mk(EventStatus::Retry)),
(Uuid::now_v7(), mk(EventStatus::Drop)),
]);
assert_eq!(undelivered_results(&results), 2);
}
#[cfg(feature = "capture-v1")]
#[test]
fn capture_failure_v1_surfaces_request_id_and_error_response() {
let server = MockServer::start();
let _bad = server.mock(|when, then| {
when.method(POST);
then.status(400)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"error": "invalid_payload",
"error_description": "malformed batch"
}));
});
let recorded = Arc::new(Mutex::new(Vec::<(bool, Option<String>, bool)>::new()));
let sink = recorded.clone();
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.on_error(move |failure| {
if let PostHogError::Capture(c) = failure {
sink.lock().unwrap_or_else(|p| p.into_inner()).push((
c.request_id().is_some(),
c.error_response().map(|er| er.error.clone()),
c.error().is_some(),
));
}
})
.build()
.unwrap(),
Arc::new(clock.clone()),
);
handle.enqueue(Event::new("e", "user-1"));
handle.flush_blocking();
handle.shutdown_blocking();
let recorded = recorded.lock().unwrap_or_else(|p| p.into_inner());
assert_eq!(recorded.len(), 1);
let (has_request_id, error_kind, has_error) = &recorded[0];
assert!(has_request_id, "v1 failures carry the attempt request id");
assert_eq!(error_kind.as_deref(), Some("invalid_payload"));
assert!(has_error);
}
#[cfg(feature = "capture-v1")]
#[test]
fn capture_failure_v1_2xx_counts_dropped_and_final_retry() {
let u1 = Uuid::now_v7();
let u2 = Uuid::now_v7();
let u3 = Uuid::now_v7();
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST);
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"results": {
u1.to_string(): { "result": "ok" },
u2.to_string(): { "result": "drop", "details": "not_persisted" },
u3.to_string(): { "result": "retry", "details": "not_persisted" }
}
}));
});
let recorded = Arc::new(Mutex::new(Vec::<(Option<u16>, bool, usize, usize)>::new()));
let sink = recorded.clone();
let clock = ManualClock::new();
let handle = TransportHandle::spawn_with_clock(
options(server.base_url())
.max_capture_attempts(1u32)
.on_error(move |failure| {
if let PostHogError::Capture(c) = failure {
sink.lock().unwrap_or_else(|p| p.into_inner()).push((
c.status(),
c.error().is_some(),
c.event_count(),
c.event_results().len(),
));
}
})
.build()
.unwrap(),
Arc::new(clock.clone()),
);
let mut e1 = Event::new("e1", "user-1");
e1.set_uuid(u1);
let mut e2 = Event::new("e2", "user-1");
e2.set_uuid(u2);
let mut e3 = Event::new("e3", "user-1");
e3.set_uuid(u3);
handle.enqueue(e1);
handle.enqueue(e2);
handle.enqueue(e3);
handle.flush_blocking();
handle.shutdown_blocking();
let recorded = recorded.lock().unwrap_or_else(|p| p.into_inner());
assert_eq!(recorded.len(), 1, "fires exactly once for the lost batch");
let (status, has_error, lost, results) = recorded[0];
assert_eq!(status, Some(200));
assert!(!has_error, "a 2xx is not an error");
assert_eq!(lost, 2, "counts the dropped event and the final retry");
assert_eq!(results, 3, "all verdicts reported, including the ok");
}
}