Skip to main content

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}