From f286125da27315bdad2c6c1d12df23ae9de0809a Mon Sep 17 00:00:00 2001 From: "Daniel Szoke (via Pi Coding Agent)" Date: Fri, 4 Sep 2026 16:15:54 +0200 Subject: [PATCH] ref(types): Store SampleRand as integer ticks `SampleRand` was added a while ago ([#867](https://github.com/getsentry/sentry-rust/pull/867), [#868](https://github.com/getsentry/sentry-rust/pull/868)) but has barely been used in the SDK since. Upcoming work will use it as the sampling seed, per the propagated random value spec. Reinterpret the value as an integer number of ticks (one tick = `0.000001`), aligning the representation with the fixed six-decimal string propagated on the wire: `Display` always emits exactly six digits, parsing avoids floats, and 1.0 becomes unrepresentable. References [#735](https://github.com/getsentry/sentry-rust/issues/735) References [RUST-1](https://linear.app/getsentry/issue/RUST-1) --- sentry-types/src/sampling.rs | 74 +++++++++++++++++++---- sentry-types/tests/test_sampling_types.rs | 35 +++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 sentry-types/tests/test_sampling_types.rs diff --git a/sentry-types/src/sampling.rs b/sentry-types/src/sampling.rs index 1c71080d9..fdd209614 100644 --- a/sentry-types/src/sampling.rs +++ b/sentry-types/src/sampling.rs @@ -5,9 +5,22 @@ use std::fmt::{Display, Formatter, Result as FmtResult}; use std::str::FromStr; use thiserror::Error; +/// The number of decimal places in [`SampleRand`]. +const SAMPLE_RAND_DECIMALS: usize = 6; + /// A random number generated at the start of a trace by the head of trace SDK. +/// +/// The value is a number in the half-open interval [0.0, 1.0). Currently, at most six decimal +/// places can be represented, though this is subject to change. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] -pub struct SampleRand(f64); +pub struct SampleRand { + /// The value of the sample rand, in ticks, where one tick corresponds to `0.000001`. + /// + /// The wire representation is `0.` followed by the tick count padded to six digits (e.g. + /// `0.5` normalizes to `"0.500000"`). The tick count is strictly less than 1,000,000, so + /// the invalid value 1.0 is unrepresentable. + ticks: u32, +} /// An error that indicates failure to construct a SampleRand. #[derive(Debug, Error)] @@ -29,7 +42,10 @@ impl TryFrom for SampleRand { if !(0.0..1.0).contains(&value) { return Err(InvalidSampleRandError::OutOfRange); } - Ok(Self(value)) + // Always rounds down; since `value < 1.0`, the result can never reach 1,000,000 ticks. + Ok(Self { + ticks: (value * 1_000_000.0).floor() as u32, + }) } } @@ -37,20 +53,56 @@ impl FromStr for SampleRand { type Err = InvalidSampleRandError; fn from_str(s: &str) -> Result { - let x: f64 = s.parse().map_err(InvalidSampleRandError::InvalidFloat)?; - Self::try_from(x) + match Self::try_from_spec_string(s) { + Some(sample_rand) => Ok(sample_rand), + // Fall back to f64 parsing for backwards-compatibility + None => Self::try_from( + s.parse::() + .map_err(InvalidSampleRandError::InvalidFloat)?, + ), + } } } impl Display for SampleRand { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - // Special case: "{:.6}" would round values greater than or equal to 0.9999995 to 1.0, - // as Rust uses [rounding half-to-even](https://doc.rust-lang.org/std/fmt/#precision). - // Round to 0.999999 instead to comply with spec. - if self.0 >= 0.9999995 { - write!(f, "0.999999") - } else { - write!(f, "{:.6}", self.0) + // Always exactly six digits; the maximum output is `0.999999`. + write!(f, "0.{:06}", self.ticks) + } +} + +impl SampleRand { + /// Tries to parse a sample_rand from a (nearly) spec-compliant string. + /// + /// This will parse sample_rand that is formatted like `0.` without relying on float + /// parsing. We can parse any number of digits, not just exactly six as per spec. + /// + /// Returns `None` if the string is not spec-compliant. + fn try_from_spec_string(s: &str) -> Option { + let digits = s.strip_prefix("0.")?; + + if digits.is_empty() || digits.bytes().any(|b| !b.is_ascii_digit()) { + return None; } + + // The difference between the number of digits in the string and the number of + // digits in a sample_rand value. After parsing the digits as a u32, we need to + // multiply by 10 to this power in order to pad the value with enough zeroes. + // If the incoming sample_rand value complies with the spec, missing_digits will + // be zero because the incoming value has exactly six digits. + let missing_digits: u32 = SAMPLE_RAND_DECIMALS + .saturating_sub(digits.len()) + .try_into() + .expect("this is at most six, which is representable as u32"); + + // Truncate digits to at most six digits, parse as u32, and pad with missing + // digits, if any. + let ticks = digits[..SAMPLE_RAND_DECIMALS.min(digits.len())] + .parse::() + .ok()? // Should not currently error due to is_ascii_digit check + .checked_mul(10_u32.pow(missing_digits)) + .expect("resulting value at most 999_999 which does not overflow"); + + Some(Self { ticks }) } } diff --git a/sentry-types/tests/test_sampling_types.rs b/sentry-types/tests/test_sampling_types.rs new file mode 100644 index 000000000..10ac483d8 --- /dev/null +++ b/sentry-types/tests/test_sampling_types.rs @@ -0,0 +1,35 @@ +use sentry_types::protocol::v7::SampleRand; + +#[test] +fn sample_rand_lower_bound() { + assert_eq!( + "0.000000".parse::().unwrap().to_string(), + "0.000000" + ); +} + +#[test] +fn sample_rand_upper_bound() { + assert_eq!( + "0.999999".parse::().unwrap().to_string(), + "0.999999" + ); +} + +#[test] +fn sample_rand_1_rejected() { + assert!("1.000000".parse::().is_err()); +} + +#[test] +fn sample_rand_few_digits() { + assert_eq!("0.5".parse::().unwrap().to_string(), "0.500000"); +} + +#[test] +fn sample_rand_truncates_too_many_digits() { + assert_eq!( + "0.1234567".parse::().unwrap().to_string(), + "0.123456" + ); +}