use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, SyncSender};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crate::config::Config;
use crate::event::Event;
use crate::transport::{SendOutcome, Transport, UreqTransport};
const MAX_PAYLOAD_BYTES: usize = 490 * 1024;
const MAX_BATCH_EVENTS: usize = 100;
enum Msg {
Event(Box<Event>),
Flush(SyncSender<bool>),
Shutdown(SyncSender<()>),
}
pub struct Client {
config: Arc<Config>,
tx: Sender<Msg>,
queue_len: Arc<AtomicUsize>,
worker: Mutex<Option<JoinHandle<()>>>,
closed: AtomicBool,
}
impl Client {
pub fn new(config: Config) -> Arc<Client> {
let config = Arc::new(config);
let transport: Arc<dyn Transport> = match &config.transport {
Some(t) => t.clone(),
None => {
if config.is_insecure_remote_host() && !config.allow_insecure_transport {
eprintln!(
"[errsight] WARNING: sending events (including your API key) over \
cleartext HTTP to {} — use https, or call \
allow_insecure_transport(true) to silence this warning.",
config.host
);
}
Arc::new(UreqTransport::new(
config.events_endpoint(),
config.api_key.clone().unwrap_or_default(),
config.timeout,
))
}
};
let (tx, rx) = mpsc::channel::<Msg>();
let queue_len = Arc::new(AtomicUsize::new(0));
let worker = {
let config = config.clone();
let queue_len = queue_len.clone();
std::thread::Builder::new()
.name("errsight-worker".to_string())
.spawn(move || worker_loop(rx, config, transport, queue_len))
.ok()
};
if worker.is_none() && config.debug {
eprintln!("[errsight] failed to spawn worker thread; events will not be delivered");
}
Arc::new(Client {
config,
tx,
queue_len,
worker: Mutex::new(worker),
closed: AtomicBool::new(false),
})
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn capture(&self, event: Event) -> Option<String> {
if self.closed.load(Ordering::Acquire) {
return None;
}
let event = match &self.config.before_send {
None => event,
Some(cb) => {
let cb = cb.clone();
let probe = event.clone();
match std::panic::catch_unwind(AssertUnwindSafe(move || cb(probe))) {
Ok(Some(filtered)) => filtered,
Ok(None) => return None, Err(_) => {
if self.config.before_send_fail_open {
self.debug_log(
"before_send panicked; sending event unmodified (fail-open)",
);
event
} else {
self.debug_log("before_send panicked; dropping event (fail-closed)");
return None;
}
}
}
}
};
let id = event.ingestion_id.clone();
self.enqueue(event);
Some(id)
}
fn enqueue(&self, event: Event) {
if self.queue_len.load(Ordering::Relaxed) >= self.config.max_queue_size {
self.debug_log(&format!(
"queue full ({}), dropping event",
self.config.max_queue_size
));
return;
}
self.queue_len.fetch_add(1, Ordering::Relaxed);
if self.tx.send(Msg::Event(Box::new(event))).is_err() {
self.queue_len.fetch_sub(1, Ordering::Relaxed);
}
}
pub fn flush(&self, timeout: Option<Duration>) -> bool {
if self.closed.load(Ordering::Acquire) {
return false;
}
let (ack_tx, ack_rx) = mpsc::sync_channel::<bool>(0);
if self.tx.send(Msg::Flush(ack_tx)).is_err() {
return false;
}
let to = timeout.unwrap_or(self.config.shutdown_timeout);
ack_rx.recv_timeout(to).unwrap_or(false)
}
pub fn close(&self, timeout: Option<Duration>) -> bool {
if self.closed.swap(true, Ordering::AcqRel) {
return true; }
let (ack_tx, ack_rx) = mpsc::sync_channel::<()>(0);
let sent = self.tx.send(Msg::Shutdown(ack_tx)).is_ok();
let to = timeout.unwrap_or(self.config.shutdown_timeout + Duration::from_secs(1));
let acked = sent && ack_rx.recv_timeout(to).is_ok();
if let Ok(mut guard) = self.worker.lock() {
if let Some(handle) = guard.take() {
if acked {
let _ = handle.join();
}
}
}
acked
}
fn debug_log(&self, msg: &str) {
if self.config.debug {
eprintln!("[errsight] {msg}");
}
}
}
impl Drop for Client {
fn drop(&mut self) {
if !self.closed.load(Ordering::Acquire) {
self.close(None);
}
}
}
fn worker_loop(
rx: Receiver<Msg>,
config: Arc<Config>,
transport: Arc<dyn Transport>,
queue_len: Arc<AtomicUsize>,
) {
let mut buffer: Vec<Event> = Vec::new();
let mut rate_limited_until: Option<Instant> = None;
let mut oldest_at: Option<Instant> = None;
loop {
match rx.recv_timeout(config.flush_interval) {
Ok(Msg::Event(event)) => {
queue_len.fetch_sub(1, Ordering::Relaxed);
buffer.push(*event);
while buffer.len() > config.max_queue_size {
buffer.remove(0);
if config.debug {
eprintln!("[errsight] buffer at capacity; dropping oldest event");
}
}
if oldest_at.is_none() {
oldest_at = Some(Instant::now());
}
let batch_full = buffer.len() >= config.batch_size;
let interval_elapsed = oldest_at
.map(|t| t.elapsed() >= config.flush_interval)
.unwrap_or(false);
if batch_full || interval_elapsed {
flush_buffer(
&mut buffer,
&transport,
&config,
&mut rate_limited_until,
None,
);
if buffer.is_empty() {
oldest_at = None;
}
}
}
Ok(Msg::Flush(ack)) => {
flush_buffer(
&mut buffer,
&transport,
&config,
&mut rate_limited_until,
None,
);
if buffer.is_empty() {
oldest_at = None;
}
let _ = ack.send(buffer.is_empty());
}
Ok(Msg::Shutdown(ack)) => {
shutdown_drain(
&rx,
&mut buffer,
&queue_len,
&transport,
&config,
&mut rate_limited_until,
);
let _ = ack.send(());
return;
}
Err(mpsc::RecvTimeoutError::Timeout) => {
flush_buffer(
&mut buffer,
&transport,
&config,
&mut rate_limited_until,
None,
);
if buffer.is_empty() {
oldest_at = None;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
flush_buffer(
&mut buffer,
&transport,
&config,
&mut rate_limited_until,
None,
);
return;
}
}
}
}
fn shutdown_drain(
rx: &Receiver<Msg>,
buffer: &mut Vec<Event>,
queue_len: &AtomicUsize,
transport: &Arc<dyn Transport>,
config: &Config,
rate_limited_until: &mut Option<Instant>,
) {
loop {
match rx.try_recv() {
Ok(Msg::Event(event)) => {
queue_len.fetch_sub(1, Ordering::Relaxed);
buffer.push(*event);
}
Ok(Msg::Flush(ack)) => {
let _ = ack.send(false);
}
Ok(Msg::Shutdown(ack)) => {
let _ = ack.send(());
}
Err(_) => break, }
}
let deadline = Instant::now() + config.shutdown_timeout;
while !buffer.is_empty() {
let now = Instant::now();
if now >= deadline {
break;
}
if let Some(until) = *rate_limited_until {
if now < until {
std::thread::sleep((until - now).min(deadline - now));
}
}
flush_buffer(
buffer,
transport,
config,
rate_limited_until,
Some(deadline),
);
if rate_limited_until.is_none() && !buffer.is_empty() {
break;
}
}
if !buffer.is_empty() && config.debug {
eprintln!(
"[errsight] dropping {} undelivered event(s) on shutdown",
buffer.len()
);
}
}
fn flush_buffer(
buffer: &mut Vec<Event>,
transport: &Arc<dyn Transport>,
config: &Config,
rate_limited_until: &mut Option<Instant>,
deadline: Option<Instant>,
) {
if let Some(until) = *rate_limited_until {
if Instant::now() < until {
return;
}
*rate_limited_until = None;
}
while !buffer.is_empty() {
if let Some(dl) = deadline {
if Instant::now() > dl {
break;
}
}
let mut take = buffer.len().min(config.batch_size).min(MAX_BATCH_EVENTS);
let body = loop {
let body = serde_json::to_vec(&buffer[0..take]).unwrap_or_else(|_| b"[]".to_vec());
if body.len() <= MAX_PAYLOAD_BYTES || take == 1 {
break body;
}
take /= 2;
};
let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| transport.send(&body)))
.unwrap_or(SendOutcome::Error("transport panicked".to_string()));
match outcome {
SendOutcome::Success => {
buffer.drain(0..take);
}
SendOutcome::RateLimited(d) => {
*rate_limited_until = Some(Instant::now() + d);
if config.debug {
eprintln!(
"[errsight] rate limited; pausing sends for {}s ({} event(s) re-queued)",
d.as_secs(),
buffer.len()
);
}
break; }
SendOutcome::Error(reason) => {
if config.debug {
eprintln!("[errsight] dropping {take} event(s): {reason}");
}
buffer.drain(0..take);
}
}
}
while buffer.len() > config.max_queue_size {
buffer.remove(0);
if config.debug {
eprintln!("[errsight] queue over capacity during backoff; dropping oldest event");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::level::Level;
use std::sync::Mutex as StdMutex;
type SentBodies = Arc<StdMutex<Vec<Vec<u8>>>>;
struct RecordingTransport {
bodies: SentBodies,
rate_limit_first: AtomicUsize,
}
impl RecordingTransport {
fn new() -> (Arc<Self>, SentBodies) {
let bodies = Arc::new(StdMutex::new(Vec::new()));
let t = Arc::new(RecordingTransport {
bodies: bodies.clone(),
rate_limit_first: AtomicUsize::new(0),
});
(t, bodies)
}
}
impl Transport for RecordingTransport {
fn send(&self, body: &[u8]) -> SendOutcome {
if self
.rate_limit_first
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
if n > 0 { Some(n - 1) } else { None }
})
.is_ok()
{
return SendOutcome::RateLimited(Duration::from_millis(50));
}
self.bodies.lock().unwrap().push(body.to_vec());
SendOutcome::Success
}
}
fn test_config(transport: Arc<dyn Transport>) -> Config {
ConfigBuilder::from_default()
.api_key("elp_test")
.min_level(Level::Debug)
.batch_size(5)
.flush_interval(Duration::from_millis(20))
.transport(transport)
.build()
}
fn count_events(bodies: &[Vec<u8>]) -> usize {
bodies
.iter()
.map(|b| serde_json::from_slice::<serde_json::Value>(b).unwrap())
.map(|v| v.as_array().map(|a| a.len()).unwrap_or(0))
.sum()
}
#[test]
fn flushes_all_events_on_close() {
let (transport, bodies) = RecordingTransport::new();
let client = Client::new(test_config(transport));
for i in 0..23 {
client.capture(Event::new(Level::Error, format!("e{i}")));
}
assert!(client.close(Some(Duration::from_secs(5))));
let bodies = bodies.lock().unwrap();
assert_eq!(count_events(&bodies), 23, "every event must be delivered");
}
#[test]
fn flush_waits_for_delivery() {
let (transport, bodies) = RecordingTransport::new();
let client = Client::new(test_config(transport));
for i in 0..3 {
client.capture(Event::new(Level::Warning, format!("e{i}")));
}
assert!(client.flush(Some(Duration::from_secs(5))));
assert_eq!(count_events(&bodies.lock().unwrap()), 3);
client.close(None);
}
#[test]
fn rate_limit_requeues_then_delivers() {
let (transport, bodies) = RecordingTransport::new();
transport.rate_limit_first.store(2, Ordering::SeqCst);
let client = Client::new(test_config(transport));
for i in 0..4 {
client.capture(Event::new(Level::Error, format!("e{i}")));
}
assert!(client.close(Some(Duration::from_secs(5))));
assert_eq!(
count_events(&bodies.lock().unwrap()),
4,
"events held during 429 must still be delivered"
);
}
#[test]
fn queue_full_drops_without_blocking() {
let (transport, bodies) = RecordingTransport::new();
transport
.rate_limit_first
.store(usize::MAX, Ordering::SeqCst);
let cfg = ConfigBuilder::from_default()
.api_key("elp_test")
.min_level(Level::Debug)
.max_queue_size(10)
.flush_interval(Duration::from_millis(10))
.transport(transport.clone())
.build();
let client = Client::new(cfg);
for i in 0..1000 {
client.capture(Event::new(Level::Error, format!("e{i}")));
}
transport.rate_limit_first.store(0, Ordering::SeqCst);
client.close(Some(Duration::from_secs(5)));
let delivered = count_events(&bodies.lock().unwrap());
assert!(
delivered <= 60,
"queue cap should bound retained events, got {delivered}"
);
}
fn max_body_events(bodies: &[Vec<u8>]) -> usize {
bodies
.iter()
.map(|b| serde_json::from_slice::<serde_json::Value>(b).unwrap())
.map(|v| v.as_array().map(|a| a.len()).unwrap_or(0))
.max()
.unwrap_or(0)
}
#[test]
fn batch_never_exceeds_backend_limit_even_when_misconfigured() {
let (transport, bodies) = RecordingTransport::new();
let mut cfg = test_config(transport);
cfg.batch_size = 500;
cfg.max_queue_size = 10_000;
let client = Client::new(cfg);
for i in 0..250 {
client.capture(Event::new(Level::Error, format!("e{i}")));
}
assert!(client.close(Some(Duration::from_secs(5))));
let bodies = bodies.lock().unwrap();
assert_eq!(count_events(&bodies), 250, "all delivered");
assert!(
max_body_events(&bodies) <= 100,
"no single request may carry more than 100 events, got {}",
max_body_events(&bodies)
);
}
#[test]
fn flush_returns_false_while_rate_limited() {
let (transport, _bodies) = RecordingTransport::new();
transport
.rate_limit_first
.store(usize::MAX, Ordering::SeqCst); let client = Client::new(test_config(transport));
for i in 0..3 {
client.capture(Event::new(Level::Error, format!("e{i}")));
}
assert!(
!client.flush(Some(Duration::from_secs(2))),
"flush must not claim success while events remain buffered"
);
client.close(Some(Duration::from_secs(1)));
}
struct PanicTransport {
panic_first: AtomicUsize,
bodies: SentBodies,
}
impl Transport for PanicTransport {
fn send(&self, body: &[u8]) -> SendOutcome {
if self
.panic_first
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
if n > 0 { Some(n - 1) } else { None }
})
.is_ok()
{
panic!("simulated transport panic");
}
self.bodies.lock().unwrap().push(body.to_vec());
SendOutcome::Success
}
}
#[test]
fn panicking_transport_does_not_kill_worker() {
let bodies: SentBodies = Arc::new(StdMutex::new(Vec::new()));
let transport = Arc::new(PanicTransport {
panic_first: AtomicUsize::new(1), bodies: bodies.clone(),
});
let client = Client::new(test_config(transport));
for i in 0..5 {
client.capture(Event::new(Level::Error, format!("a{i}")));
}
client.flush(Some(Duration::from_secs(2)));
for i in 0..5 {
client.capture(Event::new(Level::Error, format!("b{i}")));
}
assert!(client.close(Some(Duration::from_secs(5))));
assert!(
count_events(&bodies.lock().unwrap()) >= 5,
"worker should keep delivering after a transport panic"
);
}
#[test]
fn before_send_panic_fails_closed_by_default() {
let (transport, bodies) = RecordingTransport::new();
let cfg = ConfigBuilder::from_default()
.api_key("elp_test")
.min_level(Level::Debug)
.flush_interval(Duration::from_millis(10))
.transport(transport)
.before_send(|_e| panic!("scrubber bug"))
.build();
let client = Client::new(cfg);
assert!(
client
.capture(Event::new(Level::Error, "has-pii"))
.is_none(),
"a panicking scrubber must drop the event (fail-closed)"
);
client.flush(Some(Duration::from_secs(2)));
assert_eq!(
count_events(&bodies.lock().unwrap()),
0,
"no unscrubbed event may be delivered"
);
client.close(None);
}
#[test]
fn before_send_panic_sends_when_fail_open() {
let (transport, bodies) = RecordingTransport::new();
let cfg = ConfigBuilder::from_default()
.api_key("elp_test")
.min_level(Level::Debug)
.flush_interval(Duration::from_millis(10))
.transport(transport)
.before_send(|_e| panic!("scrubber bug"))
.before_send_fail_open(true)
.build();
let client = Client::new(cfg);
assert!(client.capture(Event::new(Level::Error, "boom")).is_some());
client.flush(Some(Duration::from_secs(2)));
assert_eq!(count_events(&bodies.lock().unwrap()), 1);
client.close(None);
}
}