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.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                        // The application succeeded, but its evidence is missing.
253                        // Do not mislabel this as a tool error or a complete evaluation.
254                        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
421/// An unfinished or canceled span records an error when dropped. No error text is captured.
422pub 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}