Skip to main content

bloop_sdk/
client.rs

1use std::sync::Mutex;
2use crate::event::{Event, IngestEvent};
3use crate::signing;
4use crate::trace::Trace;
5use crate::types::TraceData;
6
7/// Builder for `BloopClient`.
8#[derive(Debug, Clone)]
9pub struct BloopClientBuilder {
10    endpoint: Option<String>,
11    project_key: Option<String>,
12    environment: String,
13    release: String,
14    source: String,
15    max_buffer_size: usize,
16}
17
18impl BloopClientBuilder {
19    pub fn new() -> Self {
20        Self {
21            endpoint: None,
22            project_key: None,
23            environment: "production".into(),
24            release: String::new(),
25            source: "rust".into(),
26            max_buffer_size: 20,
27        }
28    }
29
30    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
31        self.endpoint = Some(endpoint.into());
32        self
33    }
34
35    pub fn project_key(mut self, key: impl Into<String>) -> Self {
36        self.project_key = Some(key.into());
37        self
38    }
39
40    pub fn environment(mut self, env: impl Into<String>) -> Self {
41        self.environment = env.into();
42        self
43    }
44
45    pub fn release(mut self, release: impl Into<String>) -> Self {
46        self.release = release.into();
47        self
48    }
49
50    pub fn source(mut self, source: impl Into<String>) -> Self {
51        self.source = source.into();
52        self
53    }
54
55    pub fn max_buffer_size(mut self, size: usize) -> Self {
56        self.max_buffer_size = size;
57        self
58    }
59
60    pub fn build(self) -> Result<BloopClient, String> {
61        let endpoint = self.endpoint.ok_or("endpoint is required")?;
62        let project_key = self.project_key.ok_or("project_key is required")?;
63
64        Ok(BloopClient {
65            endpoint: endpoint.trim_end_matches('/').to_string(),
66            project_key,
67            environment: self.environment,
68            release: self.release,
69            source: self.source,
70            max_buffer_size: self.max_buffer_size,
71            error_buffer: Mutex::new(Vec::new()),
72            trace_buffer: Mutex::new(Vec::new()),
73        })
74    }
75}
76
77impl Default for BloopClientBuilder {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83/// Bloop error reporting and LLM tracing client.
84///
85/// Uses `ureq` (blocking HTTP) by default. Enable the `async` feature
86/// for `reqwest`-based async transport.
87pub struct BloopClient {
88    endpoint: String,
89    project_key: String,
90    environment: String,
91    release: String,
92    source: String,
93    max_buffer_size: usize,
94    error_buffer: Mutex<Vec<IngestEvent>>,
95    trace_buffer: Mutex<Vec<TraceData>>,
96}
97
98impl BloopClient {
99    pub fn builder() -> BloopClientBuilder {
100        BloopClientBuilder::new()
101    }
102
103    /// Capture a structured error event.
104    pub fn capture(&self, event: Event) {
105        let now = std::time::SystemTime::now()
106            .duration_since(std::time::UNIX_EPOCH)
107            .unwrap()
108            .as_secs() as i64;
109
110        let ingest = IngestEvent {
111            timestamp: now,
112            source: event.source.unwrap_or_else(|| self.source.clone()),
113            environment: self.environment.clone(),
114            release: self.release.clone(),
115            error_type: event.error_type,
116            message: event.message,
117            route_or_procedure: event.route_or_procedure,
118            screen: event.screen,
119            stack: event.stack,
120            http_status: event.http_status,
121            request_id: event.request_id,
122            user_id_hash: event.user_id_hash,
123            metadata: event.metadata,
124        };
125
126        let mut buf = self.error_buffer.lock().unwrap();
127        buf.push(ingest);
128        if buf.len() >= self.max_buffer_size {
129            let batch = std::mem::take(&mut *buf);
130            drop(buf);
131            self.send_error_batch(batch);
132        }
133    }
134
135    /// Convenience: capture just an error type and message.
136    pub fn capture_error(&self, error_type: impl Into<String>, message: impl Into<String>) {
137        self.capture(Event {
138            error_type: error_type.into(),
139            message: message.into(),
140            ..Default::default()
141        });
142    }
143
144    /// Start a new LLM trace.
145    pub fn start_trace(&self, name: impl Into<String>) -> Trace {
146        Trace::new(name)
147    }
148
149    /// Buffer a completed trace for sending.
150    pub fn send_trace(&self, trace: Trace) {
151        let data = trace.to_data();
152        let mut buf = self.trace_buffer.lock().unwrap();
153        buf.push(data);
154        if buf.len() >= self.max_buffer_size {
155            let batch = std::mem::take(&mut *buf);
156            drop(buf);
157            self.send_trace_batch(batch);
158        }
159    }
160
161    /// Flush all buffered events and traces.
162    pub fn flush(&self) {
163        // Flush errors
164        let errors = {
165            let mut buf = self.error_buffer.lock().unwrap();
166            std::mem::take(&mut *buf)
167        };
168        if !errors.is_empty() {
169            self.send_error_batch(errors);
170        }
171
172        // Flush traces
173        let traces = {
174            let mut buf = self.trace_buffer.lock().unwrap();
175            std::mem::take(&mut *buf)
176        };
177        if !traces.is_empty() {
178            self.send_trace_batch(traces);
179        }
180    }
181
182    /// Flush and shutdown.
183    pub fn close(&self) {
184        self.flush();
185    }
186
187    // ── HTTP Transport ──
188
189    fn post(&self, path: &str, body: &[u8]) {
190        let url = format!("{}{}", self.endpoint, path);
191        let signature = signing::sign(&self.project_key, body);
192
193        #[cfg(feature = "blocking")]
194        {
195            let _ = ureq::post(&url)
196                .set("Content-Type", "application/json")
197                .set("X-Signature", &signature)
198                .set("X-Project-Key", &self.project_key)
199                .send_bytes(body);
200        }
201
202        #[cfg(all(feature = "async", not(feature = "blocking")))]
203        {
204            // For async feature, we'd need a runtime. For now, silently drop.
205            // In practice, users would use the async API.
206            let _ = (url, signature, body);
207        }
208
209        #[cfg(not(any(feature = "blocking", feature = "async")))]
210        {
211            let _ = (url, signature, body);
212        }
213    }
214
215    fn send_error_batch(&self, events: Vec<IngestEvent>) {
216        if let Ok(body) = serde_json::to_vec(&serde_json::json!({ "events": events })) {
217            self.post("/v1/ingest/batch", &body);
218        }
219    }
220
221    fn send_trace_batch(&self, traces: Vec<TraceData>) {
222        if let Ok(body) = serde_json::to_vec(&serde_json::json!({ "traces": traces })) {
223            self.post("/v1/traces/batch", &body);
224        }
225    }
226}
227
228impl std::fmt::Debug for BloopClient {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.debug_struct("BloopClient")
231            .field("endpoint", &self.endpoint)
232            .field("environment", &self.environment)
233            .field("source", &self.source)
234            .finish()
235    }
236}