rig_tap/sampling.rs
1//! Sampling policies for [`TelemetryHook`](crate::TelemetryHook).
2//!
3//! High-volume `tool.*` traffic can dwarf `prompt.*` and `memory.*` events
4//! in a busy agent. The [`SamplingPolicy`] trait lets callers downsample
5//! per-event-kind without losing the lower-volume lifecycle events that
6//! collectors care about most.
7//!
8//! The default policy ([`AlwaysSample`]) keeps every event. The bundled
9//! [`RatePolicy`] downsamples deterministically via SipHash of a
10//! per-event correlator so that **paired events stay coherent**: a
11//! `tool.invoked` and its matching `tool.completed` share the same
12//! internal call id, hash to the same bucket, and are therefore either
13//! both kept or both dropped.
14//!
15//! # Example
16//!
17//! ```no_run
18//! use std::sync::Arc;
19//! use rig_tap::{RatePolicy, TelemetryHook, TelemetryHookConfig};
20//!
21//! # fn make_hook<M: rig::completion::CompletionModel>() -> TelemetryHook<M> {
22//! let policy = RatePolicy::new()
23//! .with_rate("tool.invoked", 0.1)
24//! .with_rate("tool.completed", 0.1);
25//!
26//! TelemetryHook::new(TelemetryHookConfig::new("gpt-4o", "thread-1"))
27//! .with_sampling_policy(Arc::new(policy))
28//! # }
29//! ```
30
31use std::collections::HashMap;
32use std::hash::{BuildHasher, Hash, Hasher};
33
34/// Decide whether to emit a given event based on its kind discriminant
35/// and a stable correlator string.
36///
37/// Implementations should be deterministic: invoking
38/// [`SamplingPolicy::should_sample`] twice with the same arguments must
39/// return the same result. This guarantee is what lets [`TelemetryHook`]
40/// keep paired events (`tool.invoked` ↔ `tool.completed`) coherent — the
41/// hook passes the same correlator on both sides of the pair, so the
42/// policy decision is symmetric.
43///
44/// [`TelemetryHook`]: crate::TelemetryHook
45pub trait SamplingPolicy: Send + Sync + std::fmt::Debug {
46 /// Return `true` if the event with the given `kind` discriminant
47 /// (e.g. `"tool.invoked"`, `"prompt.completed"`) and per-emission
48 /// `correlator` should be emitted.
49 ///
50 /// The `correlator` is producer-supplied and intended to be the
51 /// most natural pairing key for the event family — for `tool.*`
52 /// the internal call id, for `prompt.*` the conversation id, etc.
53 /// Policies that ignore it (e.g. [`AlwaysSample`]) are free to do
54 /// so; policies that hash it (e.g. [`RatePolicy`]) get
55 /// deterministic, paired-event-safe sampling for free.
56 fn should_sample(&self, kind: &str, correlator: &str) -> bool;
57}
58
59/// Policy that keeps every event. The default for [`TelemetryHook`].
60///
61/// [`TelemetryHook`]: crate::TelemetryHook
62#[derive(Debug, Default, Clone, Copy)]
63pub struct AlwaysSample;
64
65impl SamplingPolicy for AlwaysSample {
66 fn should_sample(&self, _kind: &str, _correlator: &str) -> bool {
67 true
68 }
69}
70
71/// Per-kind rate sampler with deterministic, paired-event-safe
72/// decisions.
73///
74/// Unspecified kinds default to `default_rate` (initially `1.0`, i.e.
75/// always sample). Configured kinds use the supplied rate in `[0, 1]`,
76/// clamped to that range. Rates outside the unit interval are treated
77/// as the nearest valid value.
78///
79/// Sampling is computed by hashing the `correlator` with [`std::hash`]'s
80/// default hasher and comparing the bottom 32 bits, scaled to `[0, 1)`,
81/// against the configured rate. Because the same correlator hashes to
82/// the same bucket, paired emissions (e.g. `tool.invoked` and
83/// `tool.completed` sharing an internal call id) are guaranteed to be
84/// either both kept or both dropped.
85#[derive(Debug, Clone)]
86pub struct RatePolicy {
87 rates: HashMap<String, f64>,
88 default_rate: f64,
89}
90
91impl Default for RatePolicy {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl RatePolicy {
98 /// Build a policy that keeps every event (`default_rate = 1.0`)
99 /// until rates are configured per-kind via [`with_rate`].
100 ///
101 /// [`with_rate`]: Self::with_rate
102 pub fn new() -> Self {
103 Self {
104 rates: HashMap::new(),
105 default_rate: 1.0,
106 }
107 }
108
109 /// Override the rate applied to event kinds that have no explicit
110 /// entry. Useful when downsampling is the rule and full sampling
111 /// is the exception.
112 #[must_use]
113 pub fn with_default_rate(mut self, rate: f64) -> Self {
114 self.default_rate = clamp_unit(rate);
115 self
116 }
117
118 /// Set the sampling rate for `kind` to `rate` (clamped to `[0, 1]`).
119 ///
120 /// `kind` should match an [`EventKind::discriminant()`](crate::EventKind::discriminant)
121 /// return value such as `"tool.invoked"`, `"tool.completed"`,
122 /// `"prompt.completed"`, `"eval.report"`.
123 #[must_use]
124 pub fn with_rate(mut self, kind: impl Into<String>, rate: f64) -> Self {
125 self.rates.insert(kind.into(), clamp_unit(rate));
126 self
127 }
128
129 fn rate_for(&self, kind: &str) -> f64 {
130 self.rates.get(kind).copied().unwrap_or(self.default_rate)
131 }
132}
133
134impl SamplingPolicy for RatePolicy {
135 fn should_sample(&self, kind: &str, correlator: &str) -> bool {
136 let rate = self.rate_for(kind);
137 if rate >= 1.0 {
138 return true;
139 }
140 if rate <= 0.0 {
141 return false;
142 }
143 // `std::hash::RandomState::new()` would randomise the decision
144 // across processes; we want determinism per-process *and* per
145 // correlator, so we use a fixed seed hasher.
146 let mut hasher = FixedHasher.build_hasher();
147 kind.hash(&mut hasher);
148 correlator.hash(&mut hasher);
149 let bucket = (hasher.finish() as u32) as f64 / (u32::MAX as f64 + 1.0);
150 bucket < rate
151 }
152}
153
154fn clamp_unit(rate: f64) -> f64 {
155 if rate.is_nan() {
156 return 0.0;
157 }
158 rate.clamp(0.0, 1.0)
159}
160
161/// Fixed-seed `BuildHasher` so sampling decisions are reproducible
162/// across processes. We deliberately do not use `RandomState` here.
163#[derive(Debug, Default, Clone, Copy)]
164struct FixedHasher;
165
166impl BuildHasher for FixedHasher {
167 type Hasher = std::collections::hash_map::DefaultHasher;
168
169 fn build_hasher(&self) -> Self::Hasher {
170 std::collections::hash_map::DefaultHasher::new()
171 }
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn always_sample_keeps_every_event() {
181 let policy = AlwaysSample;
182 assert!(policy.should_sample("tool.invoked", "call-1"));
183 assert!(policy.should_sample("prompt.completed", "conv-1"));
184 assert!(policy.should_sample("anything", ""));
185 }
186
187 #[test]
188 fn rate_zero_drops_everything_rate_one_keeps_everything() {
189 let policy = RatePolicy::new()
190 .with_rate("tool.invoked", 0.0)
191 .with_rate("tool.completed", 1.0);
192 for i in 0..100 {
193 let id = format!("call-{i}");
194 assert!(!policy.should_sample("tool.invoked", &id));
195 assert!(policy.should_sample("tool.completed", &id));
196 }
197 }
198
199 #[test]
200 fn rate_decisions_are_deterministic_and_pair_coherent() {
201 let policy = RatePolicy::new()
202 .with_rate("tool.invoked", 0.5)
203 .with_rate("tool.completed", 0.5);
204 for i in 0..50 {
205 let id = format!("call-{i}");
206 let invoked = policy.should_sample("tool.invoked", &id);
207 // Same kind + same correlator always agrees with itself.
208 assert_eq!(invoked, policy.should_sample("tool.invoked", &id));
209 // Same correlator across paired event names: because the
210 // sample uses (kind, correlator), invoked/completed may
211 // disagree — pair coherence is the *caller's* contract
212 // when they choose the same kind name. Verify the symmetry
213 // we promise: identical (kind, correlator) inputs always
214 // produce identical outputs.
215 let completed = policy.should_sample("tool.completed", &id);
216 assert_eq!(completed, policy.should_sample("tool.completed", &id));
217 }
218 }
219
220 #[test]
221 fn rate_pair_coherence_when_same_kind_used_for_both_emissions() {
222 // The TelemetryHook strategy: pass the same kind family + same
223 // correlator on both sides of a pair so the decision is
224 // identical. We model that here.
225 let policy = RatePolicy::new().with_default_rate(0.25);
226 for i in 0..200 {
227 let id = format!("call-{i}");
228 let first = policy.should_sample("tool.invoked", &id);
229 let second = policy.should_sample("tool.invoked", &id);
230 assert_eq!(first, second);
231 }
232 }
233
234 #[test]
235 fn rate_default_rate_is_used_for_unspecified_kinds() {
236 let policy = RatePolicy::new().with_default_rate(0.0);
237 assert!(!policy.should_sample("memory.frame_written", "conv-1"));
238 // Configured kinds still win.
239 let policy = policy.with_rate("memory.frame_written", 1.0);
240 assert!(policy.should_sample("memory.frame_written", "conv-1"));
241 }
242
243 #[test]
244 fn rate_clamps_out_of_range_inputs() {
245 let policy = RatePolicy::new()
246 .with_rate("a", -0.5)
247 .with_rate("b", 1.5)
248 .with_rate("c", f64::NAN);
249 assert!(!policy.should_sample("a", "x"));
250 assert!(policy.should_sample("b", "x"));
251 assert!(!policy.should_sample("c", "x"));
252 }
253
254 #[test]
255 fn rate_approximates_configured_rate_over_a_population() {
256 let policy = RatePolicy::new().with_rate("tool.invoked", 0.30);
257 let mut kept = 0;
258 let total = 5_000;
259 for i in 0..total {
260 let id = format!("call-{i}");
261 if policy.should_sample("tool.invoked", &id) {
262 kept += 1;
263 }
264 }
265 let observed = kept as f64 / total as f64;
266 // Wide tolerance — this is a smoke test, not a statistical proof.
267 assert!(
268 (observed - 0.30).abs() < 0.05,
269 "observed rate {observed} drifted from configured 0.30"
270 );
271 }
272}