1mod evaluation;
4mod simulation;
5pub use simulation::SimulationSession;
6mod privacy;
7pub use evaluation::{
8 Application, EvaluationContext, EvaluationOptions, EvaluationReport, EvaluationSummary,
9 SystemCase,
10};
11
12use serde::Serialize;
13use serde_json::{json, Map, Value};
14use std::{
15 collections::VecDeque,
16 future::Future,
17 sync::{Arc, Mutex},
18 time::Duration,
19};
20use uuid::Uuid;
21
22pub type Redactor = Arc<dyn Fn(Value) -> Result<Value, String> + Send + Sync>;
23pub struct Options {
24 pub api_key: String,
25 pub repository: String,
26 pub branch: String,
27 pub system_name: Option<String>,
28 pub environment: Option<String>,
29 pub endpoint: String,
30 pub capture_content: bool,
31 pub sample_rate: f64,
32 pub max_queue_size: usize,
33 pub timeout: Duration,
34 pub redact: Option<Redactor>,
35}
36impl Options {
37 pub fn new(
38 api_key: impl Into<String>,
39 repository: impl Into<String>,
40 branch: impl Into<String>,
41 ) -> Self {
42 Self {
43 api_key: api_key.into(),
44 repository: repository.into(),
45 branch: branch.into(),
46 system_name: None,
47 environment: None,
48 endpoint: "https://api.trybench.ai".into(),
49 capture_content: false,
50 sample_rate: 1.0,
51 max_queue_size: 200,
52 timeout: Duration::from_secs(5),
53 redact: None,
54 }
55 }
56}
57#[derive(Clone)]
58pub struct TraceContext {
59 client: String,
60 trace_id: String,
61 span_id: String,
62 sampled: bool,
63 evaluation: Option<Arc<Mutex<evaluation::Capture>>>,
64}
65#[derive(Clone, Default)]
66pub struct SpanInput {
67 pub name: String,
68 pub kind: String,
69 pub model: Option<String>,
70 pub component_id: Option<i64>,
71 pub input: Option<Value>,
72 pub attributes: Map<String, Value>,
73}
74impl SpanInput {
75 pub fn new(name: impl Into<String>) -> Self {
76 Self {
77 name: name.into(),
78 kind: "LLM".into(),
79 ..Self::default()
80 }
81 }
82 pub fn kind(mut self, kind: impl Into<String>) -> Self {
83 self.kind = kind.into();
84 self
85 }
86 pub fn input(mut self, input: Value) -> Self {
87 self.input = Some(input);
88 self
89 }
90}
91#[derive(Debug, Clone, Copy, Default)]
92pub struct Stats {
93 pub queued: usize,
94 pub dropped: usize,
95}
96struct Queue {
97 items: VecDeque<Value>,
98 dropped: usize,
99 closed: bool,
100}
101struct Inner {
102 id: String,
103 options: Options,
104 http: reqwest::Client,
105 queue: Mutex<Queue>,
106 sending: tokio::sync::Mutex<()>,
107}
108#[derive(Clone)]
109pub struct Bench {
110 inner: Arc<Inner>,
111}
112
113impl Bench {
114 pub fn new(mut options: Options) -> Result<Self, String> {
115 options.system_name = options.system_name.map(|name| {
116 privacy::redact(Value::String(name), 0)
117 .as_str()
118 .unwrap_or("[REDACTED]")
119 .to_owned()
120 });
121 if !options.api_key.starts_with("bench_sk_")
122 || options.repository.is_empty()
123 || options.branch.is_empty()
124 {
125 return Err("A Bench key, repository and branch are required.".into());
126 }
127 let parts: Vec<_> = options.repository.split('/').collect();
128 if parts.len() != 2
129 || parts.iter().any(|part| {
130 part.is_empty()
131 || !part
132 .bytes()
133 .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
134 })
135 {
136 return Err("Repository must have the form owner/repo.".into());
137 }
138 for value in [&options.repository, &options.branch] {
139 if value.len() > 200
140 || value.chars().any(char::is_control)
141 || privacy::redact(Value::String(value.to_string()), 0)
142 != Value::String(value.to_string())
143 {
144 return Err(
145 "Repository and branch must not contain personal information or secrets."
146 .into(),
147 );
148 }
149 }
150 let url = reqwest::Url::parse(&options.endpoint).map_err(|_| "Invalid endpoint.")?;
151 if url.host_str().is_none()
152 || !url.username().is_empty()
153 || url.password().is_some()
154 || url.query().is_some()
155 || url.fragment().is_some()
156 || !(url.scheme() == "https"
157 || (url.scheme() == "http"
158 && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))))
159 {
160 return Err("Use HTTPS or a loopback HTTP endpoint.".into());
161 }
162 if let Some(env) = &options.environment {
163 if env.is_empty()
164 || env.len() > 64
165 || !env
166 .bytes()
167 .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
168 {
169 return Err("Invalid environment.".into());
170 }
171 }
172 if !options.sample_rate.is_finite()
173 || !(0.0..=1.0).contains(&options.sample_rate)
174 || !(1..=2000).contains(&options.max_queue_size)
175 || options.timeout < Duration::from_millis(100)
176 || options.timeout > Duration::from_secs(30)
177 {
178 return Err("Sampling, queue size or timeout is out of range.".into());
179 }
180 let http = reqwest::Client::builder()
181 .redirect(reqwest::redirect::Policy::none())
182 .timeout(options.timeout)
183 .build()
184 .map_err(|_| "Could not create HTTP client.")?;
185 Ok(Self {
186 inner: Arc::new(Inner {
187 id: Uuid::new_v4().simple().to_string(),
188 options,
189 http,
190 queue: Mutex::new(Queue {
191 items: VecDeque::new(),
192 dropped: 0,
193 closed: false,
194 }),
195 sending: tokio::sync::Mutex::new(()),
196 }),
197 })
198 }
199 pub fn start_span(&self, parent: Option<&TraceContext>, input: SpanInput) -> Span {
200 let parent = parent.filter(|p| p.client == self.inner.id);
201 let random = Uuid::new_v4();
202 let bits = u64::from_be_bytes(random.as_bytes()[..8].try_into().unwrap());
203 let context = TraceContext {
204 client: self.inner.id.clone(),
205 trace_id: parent
206 .map(|p| p.trace_id.clone())
207 .unwrap_or_else(|| random.simple().to_string()),
208 span_id: Uuid::new_v4().simple().to_string()[..16].to_owned(),
209 evaluation: parent.and_then(|p| p.evaluation.clone()),
210 sampled: parent.map(|p| p.sampled).unwrap_or(
211 self.inner.options.sample_rate >= 1.0
212 || (bits as f64) / (u64::MAX as f64) < self.inner.options.sample_rate,
213 ),
214 };
215 if let Some(capture) = &context.evaluation {
216 capture.lock().unwrap_or_else(|e| e.into_inner()).start();
217 }
218 Span {
219 bench: self.clone(),
220 input,
221 context,
222 parent: parent
223 .filter(|p| !p.span_id.is_empty())
224 .map(|p| p.span_id.clone()),
225 started: timestamp(),
226 duration_start: std::time::Instant::now(),
227 output: None,
228 status: "error",
229 ended: false,
230 }
231 }
232 pub async fn trace<T, E, F, Fut>(
233 &self,
234 parent: Option<&TraceContext>,
235 input: SpanInput,
236 run: F,
237 ) -> Result<T, E>
238 where
239 T: Serialize,
240 F: FnOnce(TraceContext) -> Fut,
241 Fut: Future<Output = Result<T, E>>,
242 {
243 let mut span = self.start_span(parent, input);
244 let result = run(span.context());
245 match result.await {
246 Ok(value) => {
247 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
248 serde_json::to_value(&value)
249 })) {
250 Ok(Ok(output)) => span.set_output(output),
251 _ => {
252 if let Some(capture) = &span.context.evaluation {
255 capture
256 .lock()
257 .unwrap_or_else(|e| e.into_inner())
258 .finish(None);
259 }
260 span.ended = true;
261 self.drop_events(1)
262 }
263 };
264 Ok(value)
265 }
266 Err(error) => Err(error),
267 }
268 }
269 fn safe(&self, value: Value) -> Result<Value, String> {
270 let value = if let Some(redact) = &self.inner.options.redact {
271 redact(value)?
272 } else {
273 value
274 };
275 Ok(privacy::redact(value, 0))
276 }
277 fn drop_events(&self, n: usize) {
278 self.inner
279 .queue
280 .lock()
281 .unwrap_or_else(|e| e.into_inner())
282 .dropped += n
283 }
284 fn capture(&self, span: &Span) -> Result<(), String> {
285 if !span.context.sampled {
286 return Ok(());
287 }
288 if !matches!(
289 span.input.kind.as_str(),
290 "LLM" | "TOOL" | "CHAIN" | "AGENT" | "RETRIEVER" | "EMBEDDING"
291 ) {
292 return Err("Invalid span kind.".into());
293 }
294 let mut attrs: Map<String, Value> = span
295 .input
296 .attributes
297 .iter()
298 .filter(|(k, _)| self.inner.options.capture_content || privacy::metadata(k))
299 .map(|(k, v)| (k.clone(), v.clone()))
300 .collect();
301 attrs.insert(
302 "bench.duration_ms".into(),
303 json!(span.duration_start.elapsed().as_secs_f64() * 1000.0),
304 );
305 if let Some(env) = &self.inner.options.environment {
306 attrs.insert("bench.environment".into(), json!(env));
307 }
308 if let Some(id) = span.input.component_id {
309 attrs.insert("bench.component_id".into(), json!(id));
310 }
311 let name = self.safe(json!(span.input.name))?;
312 let name = name
313 .as_str()
314 .filter(|s| !s.is_empty())
315 .ok_or("Invalid name.")?;
316 let mut row = json!({"span_id":span.context.span_id,"name":name.chars().take(200).collect::<String>(),"kind":span.input.kind,"started_at":span.started,"ended_at":timestamp(),"status":span.status,"attributes":self.safe(Value::Object(attrs))?});
317 if let Some(parent) = &span.parent {
318 row["parent_span_id"] = json!(parent)
319 }
320 if let Some(model) = &span.input.model {
321 row["model_name"] = self.safe(json!(model))?
322 }
323 if self.inner.options.capture_content || span.context.evaluation.is_some() {
324 row["input_value"] = json!(self
325 .safe(span.input.input.clone().unwrap_or(Value::Null))?
326 .to_string());
327 row["output_value"] = json!(self
328 .safe(span.output.clone().unwrap_or(Value::Null))?
329 .to_string())
330 }
331 if let Some(capture) = &span.context.evaluation {
332 if row.to_string().len() > 200000 {
333 return Err("Trace too large.".into());
334 }
335 capture
336 .lock()
337 .unwrap_or_else(|e| e.into_inner())
338 .finish(Some(row));
339 return Ok(());
340 }
341 let trace = json!({"trace_id":span.context.trace_id,"source":"bench_sdk","spans":[row]});
342 if trace.to_string().len() > 200000 {
343 return Err("Trace too large.".into());
344 }
345 let mut queue = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
346 if queue.closed || queue.items.len() >= self.inner.options.max_queue_size {
347 queue.dropped += 1
348 } else {
349 queue.items.push_back(trace)
350 };
351 Ok(())
352 }
353 pub fn stats(&self) -> Stats {
354 let q = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
355 Stats {
356 queued: q.items.len(),
357 dropped: q.dropped,
358 }
359 }
360 pub async fn flush(&self) {
361 let _sending = self.inner.sending.lock().await;
362 let mut pending = {
363 let mut q = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
364 std::mem::take(&mut q.items)
365 };
366 while !pending.is_empty() {
367 let mut batch = Vec::new();
368 let mut size = 0;
369 while let Some(next) = pending.front() {
370 let bytes = next.to_string().len();
371 if batch.len() >= 20 || size + bytes > 800000 {
372 break;
373 }
374 size += bytes;
375 batch.push(pending.pop_front().unwrap());
376 }
377 let body=json!({"repo_full_name":self.inner.options.repository,"branch":self.inner.options.branch,"system_name":self.inner.options.system_name.as_deref().unwrap_or_else(||self.inner.options.repository.rsplit('/').next().unwrap_or("app")),"capture_content":self.inner.options.capture_content,"traces":batch}).to_string();
378 let mut delivered = false;
379 for attempt in 0..2 {
380 let result = self
381 .inner
382 .http
383 .post(format!(
384 "{}/api/traces",
385 self.inner.options.endpoint.trim_end_matches('/')
386 ))
387 .bearer_auth(&self.inner.options.api_key)
388 .header("Content-Type", "application/json")
389 .body(body.clone())
390 .send()
391 .await;
392 if let Ok(response) = result {
393 let status = response.status();
394 if status.is_success() {
395 delivered = true;
396 break;
397 }
398 if status.as_u16() != 429 && !status.is_server_error() {
399 break;
400 }
401 }
402 if attempt == 0 {
403 tokio::time::sleep(Duration::from_millis(250)).await
404 }
405 }
406 if !delivered {
407 self.drop_events(batch.len())
408 }
409 }
410 }
411 pub async fn shutdown(&self) {
412 self.inner
413 .queue
414 .lock()
415 .unwrap_or_else(|e| e.into_inner())
416 .closed = true;
417 self.flush().await
418 }
419}
420
421pub struct Span {
423 bench: Bench,
424 input: SpanInput,
425 context: TraceContext,
426 parent: Option<String>,
427 started: String,
428 duration_start: std::time::Instant,
429 output: Option<Value>,
430 status: &'static str,
431 ended: bool,
432}
433impl Span {
434 pub fn context(&self) -> TraceContext {
435 self.context.clone()
436 }
437 pub fn set_output(&mut self, value: Value) {
438 self.output = Some(value);
439 self.status = "ok"
440 }
441 pub fn set_error(&mut self) {
442 self.status = "error"
443 }
444 pub fn end(&mut self) {
445 if !self.ended {
446 self.ended = true;
447 if !matches!(
448 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.bench.capture(self))),
449 Ok(Ok(()))
450 ) {
451 if let Some(capture) = &self.context.evaluation {
452 capture
453 .lock()
454 .unwrap_or_else(|e| e.into_inner())
455 .finish(None);
456 }
457 self.bench.drop_events(1)
458 }
459 }
460 }
461}
462impl Drop for Span {
463 fn drop(&mut self) {
464 self.end()
465 }
466}
467
468fn timestamp() -> String {
469 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
470}