Skip to main content

trybench_sdk/
lib.rs

1//! Bounded server tracing for Bench. Capture never runs paid evaluations.
2//! Call `flush().await` at a request or shutdown boundary to deliver events.
3mod 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/// A finished span produced by another tracer: an OpenTelemetry exporter or a
92/// framework callback. IDs, timing and parent links come from that tracer, so
93/// Bench rebuilds the agent/model/tool tree the framework actually executed.
94/// This is the framework-agnostic path to SDK-first discovery.
95#[derive(Clone, Default)]
96pub struct ExternalSpan {
97    /// 32 lowercase hex characters.
98    pub trace_id: String,
99    /// 16 lowercase hex characters.
100    pub span_id: String,
101    pub parent_span_id: Option<String>,
102    pub name: String,
103    /// LLM | TOOL | CHAIN | RETRIEVER | AGENT | EMBEDDING; inferred from GenAI
104    /// attributes when `None`.
105    pub kind: Option<String>,
106    pub model: Option<String>,
107    /// RFC 3339 timestamps from the original tracer.
108    pub started_at: String,
109    pub ended_at: String,
110    /// "ok" (default) or "error".
111    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
126/// Map OpenTelemetry GenAI semantic conventions to a Bench span kind. Every
127/// supported framework that emits GenAI spans uses these attributes, so
128/// discovery does not depend on the framework or language.
129pub fn infer_span_kind(attributes: &Map<String, Value>) -> &'static str {
130    // The operation name is authoritative: frameworks such as Pydantic AI stamp
131    // gen_ai.agent.name on every span of a run, including model calls and tools.
132    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                        // The application succeeded, but its evidence is missing.
312                        // Do not mislabel this as a tool error or a complete evaluation.
313                        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    /// Queue a finished span from another tracer. Metadata-only capture and
413    /// redaction apply exactly as for `trace`. Invalid input is counted as
414    /// dropped and returned as an error; it never panics into the application.
415    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
560/// An unfinished or canceled span records an error when dropped. No error text is captured.
561pub 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};