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;
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 event kind is deliberately not part
82/// of the bucket hash, paired emissions (e.g. `tool.invoked` and
83/// `tool.completed` sharing an internal call id) use the same bucket and are
84/// either both kept or both dropped when configured with the same rate.
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 bucket = (FixedHasher.hash_one(correlator) as u32) as f64 / (u32::MAX as f64 + 1.0);
147        bucket < rate
148    }
149}
150
151fn clamp_unit(rate: f64) -> f64 {
152    if rate.is_nan() {
153        return 0.0;
154    }
155    rate.clamp(0.0, 1.0)
156}
157
158/// Fixed-seed `BuildHasher` so sampling decisions are reproducible
159/// across processes. We deliberately do not use `RandomState` here.
160#[derive(Debug, Default, Clone, Copy)]
161struct FixedHasher;
162
163impl BuildHasher for FixedHasher {
164    type Hasher = std::collections::hash_map::DefaultHasher;
165
166    fn build_hasher(&self) -> Self::Hasher {
167        std::collections::hash_map::DefaultHasher::new()
168    }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn always_sample_keeps_every_event() {
178        let policy = AlwaysSample;
179        assert!(policy.should_sample("tool.invoked", "call-1"));
180        assert!(policy.should_sample("prompt.completed", "conv-1"));
181        assert!(policy.should_sample("anything", ""));
182    }
183
184    #[test]
185    fn rate_zero_drops_everything_rate_one_keeps_everything() {
186        let policy = RatePolicy::new()
187            .with_rate("tool.invoked", 0.0)
188            .with_rate("tool.completed", 1.0);
189        for i in 0..100 {
190            let id = format!("call-{i}");
191            assert!(!policy.should_sample("tool.invoked", &id));
192            assert!(policy.should_sample("tool.completed", &id));
193        }
194    }
195
196    #[test]
197    fn rate_decisions_are_deterministic_and_pair_coherent() {
198        let policy = RatePolicy::new()
199            .with_rate("tool.invoked", 0.5)
200            .with_rate("tool.completed", 0.5);
201        for i in 0..50 {
202            let id = format!("call-{i}");
203            let invoked = policy.should_sample("tool.invoked", &id);
204            assert_eq!(invoked, policy.should_sample("tool.invoked", &id));
205            let completed = policy.should_sample("tool.completed", &id);
206            assert_eq!(
207                invoked, completed,
208                "tool.invoked/tool.completed must share the same bucket for {id}"
209            );
210        }
211    }
212
213    #[test]
214    fn rate_default_rate_is_used_for_unspecified_kinds() {
215        let policy = RatePolicy::new().with_default_rate(0.0);
216        assert!(!policy.should_sample("memory.frame_written", "conv-1"));
217        // Configured kinds still win.
218        let policy = policy.with_rate("memory.frame_written", 1.0);
219        assert!(policy.should_sample("memory.frame_written", "conv-1"));
220    }
221
222    #[test]
223    fn rate_clamps_out_of_range_inputs() {
224        let policy = RatePolicy::new()
225            .with_rate("a", -0.5)
226            .with_rate("b", 1.5)
227            .with_rate("c", f64::NAN);
228        assert!(!policy.should_sample("a", "x"));
229        assert!(policy.should_sample("b", "x"));
230        assert!(!policy.should_sample("c", "x"));
231    }
232
233    #[test]
234    fn rate_approximates_configured_rate_over_a_population() {
235        let policy = RatePolicy::new().with_rate("tool.invoked", 0.30);
236        let mut kept = 0;
237        let total = 5_000;
238        for i in 0..total {
239            let id = format!("call-{i}");
240            if policy.should_sample("tool.invoked", &id) {
241                kept += 1;
242            }
243        }
244        let observed = kept as f64 / total as f64;
245        // Wide tolerance — this is a smoke test, not a statistical proof.
246        assert!(
247            (observed - 0.30).abs() < 0.05,
248            "observed rate {observed} drifted from configured 0.30"
249        );
250    }
251}