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.usebench.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(Clone, Default)]
96pub struct ExternalSpan {
97 pub trace_id: String,
99 pub span_id: String,
101 pub parent_span_id: Option<String>,
102 pub name: String,
103 pub kind: Option<String>,
106 pub model: Option<String>,
107 pub started_at: String,
109 pub ended_at: String,
110 pub status: String,
112 pub attributes: Map<String, Value>,
113 pub input: Option<Value>,
114 pub output: Option<Value>,
115}
116
117fn attribute_text(attributes: &Map<String, Value>, keys: &[&str]) -> String {
118 keys.iter()
119 .filter_map(|key| attributes.get(*key).and_then(Value::as_str))
120 .map(str::trim)
121 .find(|value| !value.is_empty())
122 .map(str::to_lowercase)
123 .unwrap_or_default()
124}
125
126pub fn infer_span_kind(attributes: &Map<String, Value>) -> &'static str {
130 match attribute_text(attributes, &["gen_ai.operation.name"]).as_str() {
133 "chat" | "text_completion" | "generate_content" => return "LLM",
134 "execute_tool" => return "TOOL",
135 "embeddings" => return "EMBEDDING",
136 "invoke_agent" | "create_agent" => return "AGENT",
137 _ => {}
138 }
139 if !attribute_text(attributes, &["gen_ai.tool.name"]).is_empty() {
140 "TOOL"
141 } else if !attribute_text(attributes, &["gen_ai.request.model"]).is_empty() {
142 "LLM"
143 } else if !attribute_text(attributes, &["gen_ai.agent.name"]).is_empty() {
144 "AGENT"
145 } else {
146 "UNKNOWN"
147 }
148}
149
150#[derive(Debug, Clone, Copy, Default)]
151pub struct Stats {
152 pub queued: usize,
153 pub dropped: usize,
154}
155struct Queue {
156 items: VecDeque<Value>,
157 dropped: usize,
158 closed: bool,
159}
160struct Inner {
161 id: String,
162 options: Options,
163 http: reqwest::Client,
164 queue: Mutex<Queue>,
165 sending: tokio::sync::Mutex<()>,
166}
167#[derive(Clone)]
168pub struct Bench {
169 inner: Arc<Inner>,
170}
171
172impl Bench {
173 pub fn new(mut options: Options) -> Result<Self, String> {
174 options.system_name = options.system_name.map(|name| {
175 privacy::redact(Value::String(name), 0)
176 .as_str()
177 .unwrap_or("[REDACTED]")
178 .to_owned()
179 });
180 if !options.api_key.starts_with("bench_sk_")
181 || options.repository.is_empty()
182 || options.branch.is_empty()
183 {
184 return Err("A Bench key, repository and branch are required.".into());
185 }
186 let parts: Vec<_> = options.repository.split('/').collect();
187 if parts.len() != 2
188 || parts.iter().any(|part| {
189 part.is_empty()
190 || !part
191 .bytes()
192 .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
193 })
194 {
195 return Err("Repository must have the form owner/repo.".into());
196 }
197 for value in [&options.repository, &options.branch] {
198 if value.len() > 200
199 || value.chars().any(char::is_control)
200 || privacy::redact(Value::String(value.to_string()), 0)
201 != Value::String(value.to_string())
202 {
203 return Err(
204 "Repository and branch must not contain personal information or secrets."
205 .into(),
206 );
207 }
208 }
209 let url = reqwest::Url::parse(&options.endpoint).map_err(|_| "Invalid endpoint.")?;
210 if url.host_str().is_none()
211 || !url.username().is_empty()
212 || url.password().is_some()
213 || url.query().is_some()
214 || url.fragment().is_some()
215 || !(url.scheme() == "https"
216 || (url.scheme() == "http"
217 && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))))
218 {
219 return Err("Use HTTPS or a loopback HTTP endpoint.".into());
220 }
221 if let Some(env) = &options.environment {
222 if env.is_empty()
223 || env.len() > 64
224 || !env
225 .bytes()
226 .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
227 {
228 return Err("Invalid environment.".into());
229 }
230 }
231 if !options.sample_rate.is_finite()
232 || !(0.0..=1.0).contains(&options.sample_rate)
233 || !(1..=2000).contains(&options.max_queue_size)
234 || options.timeout < Duration::from_millis(100)
235 || options.timeout > Duration::from_secs(30)
236 {
237 return Err("Sampling, queue size or timeout is out of range.".into());
238 }
239 let http = reqwest::Client::builder()
240 .redirect(reqwest::redirect::Policy::none())
241 .timeout(options.timeout)
242 .build()
243 .map_err(|_| "Could not create HTTP client.")?;
244 Ok(Self {
245 inner: Arc::new(Inner {
246 id: Uuid::new_v4().simple().to_string(),
247 options,
248 http,
249 queue: Mutex::new(Queue {
250 items: VecDeque::new(),
251 dropped: 0,
252 closed: false,
253 }),
254 sending: tokio::sync::Mutex::new(()),
255 }),
256 })
257 }
258 pub fn start_span(&self, parent: Option<&TraceContext>, input: SpanInput) -> Span {
259 let parent = parent.filter(|p| p.client == self.inner.id);
260 let random = Uuid::new_v4();
261 let bits = u64::from_be_bytes(random.as_bytes()[..8].try_into().unwrap());
262 let context = TraceContext {
263 client: self.inner.id.clone(),
264 trace_id: parent
265 .map(|p| p.trace_id.clone())
266 .unwrap_or_else(|| random.simple().to_string()),
267 span_id: Uuid::new_v4().simple().to_string()[..16].to_owned(),
268 evaluation: parent.and_then(|p| p.evaluation.clone()),
269 sampled: parent.map(|p| p.sampled).unwrap_or(
270 self.inner.options.sample_rate >= 1.0
271 || (bits as f64) / (u64::MAX as f64) < self.inner.options.sample_rate,
272 ),
273 };
274 if let Some(capture) = &context.evaluation {
275 capture.lock().unwrap_or_else(|e| e.into_inner()).start();
276 }
277 Span {
278 bench: self.clone(),
279 input,
280 context,
281 parent: parent
282 .filter(|p| !p.span_id.is_empty())
283 .map(|p| p.span_id.clone()),
284 started: timestamp(),
285 duration_start: std::time::Instant::now(),
286 output: None,
287 status: "error",
288 ended: false,
289 }
290 }
291 pub async fn trace<T, E, F, Fut>(
292 &self,
293 parent: Option<&TraceContext>,
294 input: SpanInput,
295 run: F,
296 ) -> Result<T, E>
297 where
298 T: Serialize,
299 F: FnOnce(TraceContext) -> Fut,
300 Fut: Future<Output = Result<T, E>>,
301 {
302 let mut span = self.start_span(parent, input);
303 let result = run(span.context());
304 match result.await {
305 Ok(value) => {
306 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
307 serde_json::to_value(&value)
308 })) {
309 Ok(Ok(output)) => span.set_output(output),
310 _ => {
311 if let Some(capture) = &span.context.evaluation {
314 capture
315 .lock()
316 .unwrap_or_else(|e| e.into_inner())
317 .finish(None);
318 }
319 span.ended = true;
320 self.drop_events(1)
321 }
322 };
323 Ok(value)
324 }
325 Err(error) => Err(error),
326 }
327 }
328 fn safe(&self, value: Value) -> Result<Value, String> {
329 let value = if let Some(redact) = &self.inner.options.redact {
330 redact(value)?
331 } else {
332 value
333 };
334 Ok(privacy::redact(value, 0))
335 }
336 fn drop_events(&self, n: usize) {
337 self.inner
338 .queue
339 .lock()
340 .unwrap_or_else(|e| e.into_inner())
341 .dropped += n
342 }
343 fn capture(&self, span: &Span) -> Result<(), String> {
344 if !span.context.sampled {
345 return Ok(());
346 }
347 if !matches!(
348 span.input.kind.as_str(),
349 "LLM" | "TOOL" | "CHAIN" | "AGENT" | "RETRIEVER" | "EMBEDDING"
350 ) {
351 return Err("Invalid span kind.".into());
352 }
353 let mut attrs: Map<String, Value> = span
354 .input
355 .attributes
356 .iter()
357 .filter(|(k, _)| self.inner.options.capture_content || privacy::metadata(k))
358 .map(|(k, v)| (k.clone(), v.clone()))
359 .collect();
360 attrs.insert(
361 "bench.duration_ms".into(),
362 json!(span.duration_start.elapsed().as_secs_f64() * 1000.0),
363 );
364 if let Some(env) = &self.inner.options.environment {
365 attrs.insert("bench.environment".into(), json!(env));
366 }
367 if let Some(id) = span.input.component_id {
368 attrs.insert("bench.component_id".into(), json!(id));
369 }
370 let name = self.safe(json!(span.input.name))?;
371 let name = name
372 .as_str()
373 .filter(|s| !s.is_empty())
374 .ok_or("Invalid name.")?;
375 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))?});
376 if let Some(parent) = &span.parent {
377 row["parent_span_id"] = json!(parent)
378 }
379 if let Some(model) = &span.input.model {
380 row["model_name"] = self.safe(json!(model))?
381 }
382 if self.inner.options.capture_content || span.context.evaluation.is_some() {
383 row["input_value"] = json!(self
384 .safe(span.input.input.clone().unwrap_or(Value::Null))?
385 .to_string());
386 row["output_value"] = json!(self
387 .safe(span.output.clone().unwrap_or(Value::Null))?
388 .to_string())
389 }
390 if let Some(capture) = &span.context.evaluation {
391 if row.to_string().len() > 200000 {
392 return Err("Trace too large.".into());
393 }
394 capture
395 .lock()
396 .unwrap_or_else(|e| e.into_inner())
397 .finish(Some(row));
398 return Ok(());
399 }
400 let trace = json!({"trace_id":span.context.trace_id,"source":"bench_sdk","spans":[row]});
401 if trace.to_string().len() > 200000 {
402 return Err("Trace too large.".into());
403 }
404 let mut queue = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
405 if queue.closed || queue.items.len() >= self.inner.options.max_queue_size {
406 queue.dropped += 1
407 } else {
408 queue.items.push_back(trace)
409 };
410 Ok(())
411 }
412 pub fn record_external_span(&self, span: ExternalSpan) -> Result<(), String> {
416 let result = self.build_external(span);
417 if result.is_err() {
418 self.drop_events(1)
419 }
420 result
421 }
422 fn build_external(&self, span: ExternalSpan) -> Result<(), String> {
423 let trace_id = span.trace_id.to_lowercase();
424 let span_id = span.span_id.to_lowercase();
425 let parent = span.parent_span_id.map(|p| p.to_lowercase());
426 let hex = |value: &str, len: usize| {
427 value.len() == len
428 && value
429 .bytes()
430 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
431 };
432 if !hex(&trace_id, 32)
433 || !hex(&span_id, 16)
434 || parent.as_deref().is_some_and(|p| !hex(p, 16))
435 {
436 return Err("External spans need 32-hex trace and 16-hex span identifiers.".into());
437 }
438 if self.inner.options.sample_rate < 1.0 {
439 let prefix = u64::from_str_radix(&trace_id[..8], 16).unwrap_or(0) as f64;
440 if prefix / 0xFFFF_FFFFu32 as f64 >= self.inner.options.sample_rate {
441 return Ok(());
442 }
443 }
444 let kind = match span.kind.as_deref() {
445 Some(k @ ("LLM" | "TOOL" | "CHAIN" | "AGENT" | "RETRIEVER" | "EMBEDDING")) => {
446 k.to_owned()
447 }
448 _ => infer_span_kind(&span.attributes).to_owned(),
449 };
450 let mut attrs: Map<String, Value> = span
451 .attributes
452 .iter()
453 .filter(|(k, _)| self.inner.options.capture_content || privacy::metadata(k))
454 .map(|(k, v)| (k.clone(), v.clone()))
455 .collect();
456 if let Some(env) = &self.inner.options.environment {
457 attrs.insert("bench.environment".into(), json!(env));
458 }
459 let name = self.safe(json!(span.name))?;
460 let name = name
461 .as_str()
462 .filter(|s| !s.is_empty())
463 .ok_or("Invalid name.")?;
464 let status = if span.status == "error" {
465 "error"
466 } else {
467 "ok"
468 };
469 let mut row = json!({"span_id":span_id,"name":name.chars().take(200).collect::<String>(),"kind":kind,"started_at":span.started_at,"ended_at":span.ended_at,"status":status,"attributes":self.safe(Value::Object(attrs))?});
470 if let Some(parent) = parent {
471 row["parent_span_id"] = json!(parent)
472 }
473 if let Some(model) = &span.model {
474 row["model_name"] = self.safe(json!(model))?
475 }
476 if self.inner.options.capture_content {
477 row["input_value"] = json!(self.safe(span.input.unwrap_or(Value::Null))?.to_string());
478 row["output_value"] = json!(self.safe(span.output.unwrap_or(Value::Null))?.to_string());
479 }
480 let trace = json!({"trace_id":trace_id,"source":"bench_sdk","spans":[row]});
481 if trace.to_string().len() > 200000 {
482 return Err("Trace too large.".into());
483 }
484 let mut queue = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
485 if queue.closed || queue.items.len() >= self.inner.options.max_queue_size {
486 queue.dropped += 1
487 } else {
488 queue.items.push_back(trace)
489 };
490 Ok(())
491 }
492 pub fn stats(&self) -> Stats {
493 let q = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
494 Stats {
495 queued: q.items.len(),
496 dropped: q.dropped,
497 }
498 }
499 pub async fn flush(&self) {
500 let _sending = self.inner.sending.lock().await;
501 let mut pending = {
502 let mut q = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
503 std::mem::take(&mut q.items)
504 };
505 while !pending.is_empty() {
506 let mut batch = Vec::new();
507 let mut size = 0;
508 while let Some(next) = pending.front() {
509 let bytes = next.to_string().len();
510 if batch.len() >= 20 || size + bytes > 800000 {
511 break;
512 }
513 size += bytes;
514 batch.push(pending.pop_front().unwrap());
515 }
516 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();
517 let mut delivered = false;
518 for attempt in 0..2 {
519 let result = self
520 .inner
521 .http
522 .post(format!(
523 "{}/api/traces",
524 self.inner.options.endpoint.trim_end_matches('/')
525 ))
526 .bearer_auth(&self.inner.options.api_key)
527 .header("Content-Type", "application/json")
528 .body(body.clone())
529 .send()
530 .await;
531 if let Ok(response) = result {
532 let status = response.status();
533 if status.is_success() {
534 delivered = true;
535 break;
536 }
537 if status.as_u16() != 429 && !status.is_server_error() {
538 break;
539 }
540 }
541 if attempt == 0 {
542 tokio::time::sleep(Duration::from_millis(250)).await
543 }
544 }
545 if !delivered {
546 self.drop_events(batch.len())
547 }
548 }
549 }
550 pub async fn shutdown(&self) {
551 self.inner
552 .queue
553 .lock()
554 .unwrap_or_else(|e| e.into_inner())
555 .closed = true;
556 self.flush().await
557 }
558}
559
560pub struct Span {
562 bench: Bench,
563 input: SpanInput,
564 context: TraceContext,
565 parent: Option<String>,
566 started: String,
567 duration_start: std::time::Instant,
568 output: Option<Value>,
569 status: &'static str,
570 ended: bool,
571}
572impl Span {
573 pub fn context(&self) -> TraceContext {
574 self.context.clone()
575 }
576 pub fn set_output(&mut self, value: Value) {
577 self.output = Some(value);
578 self.status = "ok"
579 }
580 pub fn set_error(&mut self) {
581 self.status = "error"
582 }
583 pub fn end(&mut self) {
584 if !self.ended {
585 self.ended = true;
586 if !matches!(
587 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.bench.capture(self))),
588 Ok(Ok(()))
589 ) {
590 if let Some(capture) = &self.context.evaluation {
591 capture
592 .lock()
593 .unwrap_or_else(|e| e.into_inner())
594 .finish(None);
595 }
596 self.bench.drop_events(1)
597 }
598 }
599 }
600}
601impl Drop for Span {
602 fn drop(&mut self) {
603 self.end()
604 }
605}
606
607fn timestamp() -> String {
608 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
609}
610
611mod platform;
612pub use platform::{BenchPlatform, PlatformError, PlatformFile, PlatformRequest};