-
Notifications
You must be signed in to change notification settings - Fork 62
feat(core): configurable head-based sampling with always-sample-on-error #907
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ const OTLP_ENDPOINT_ENV_VAR: &str = "OTEL_EXPORTER_OTLP_ENDPOINT"; | |
| /// | ||
| /// let telemetry = TelemetryConfig::default(); | ||
| /// assert!(telemetry.otlp_endpoint.is_none()); | ||
| /// assert!(telemetry.sampling_rate.is_none()); | ||
| /// | ||
| /// let telemetry: TelemetryConfig = | ||
| /// serde_yaml::from_str("otlp_endpoint: \"http://localhost:4317\"").unwrap(); | ||
|
|
@@ -36,7 +37,7 @@ const OTLP_ENDPOINT_ENV_VAR: &str = "OTEL_EXPORTER_OTLP_ENDPOINT"; | |
| /// Some("http://localhost:4317") | ||
| /// ); | ||
| /// ``` | ||
| #[derive(Debug, Clone, Default, Deserialize, Serialize)] | ||
| #[derive(Clone, Debug, Default, Deserialize, Serialize)] | ||
| #[serde(default, deny_unknown_fields)] | ||
| pub struct TelemetryConfig { | ||
| /// OTLP collector endpoint (e.g. `http://localhost:4317`). | ||
|
|
@@ -45,6 +46,19 @@ pub struct TelemetryConfig { | |
| /// feature). Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT` env var | ||
| /// if not set in config. | ||
| pub otlp_endpoint: Option<String>, | ||
|
|
||
| /// Head-based trace sampling rate between `0.0` (drop all) and `1.0` | ||
| /// (sample all). | ||
| /// | ||
| /// When set, configures a `ParentBased(TraceIdRatioBased(rate))` | ||
| /// sampler: root spans are sampled at the given rate while child | ||
| /// spans respect the parent's sampling decision (W3C `traceparent`). | ||
| /// | ||
| /// When `None` (the default), the `OTel` default sampler is used | ||
| /// (`ParentBased(AlwaysOn)`), preserving backward compatibility. | ||
| // TODO(#311): always-sample-on-error requires #301 (root span) to | ||
| // detect errors at span close time and override the sampling decision. | ||
| pub sampling_rate: Option<f64>, | ||
| } | ||
|
|
||
| impl TelemetryConfig { | ||
|
|
@@ -59,14 +73,41 @@ impl TelemetryConfig { | |
| .otlp_endpoint | ||
| .clone() | ||
| .or_else(|| std::env::var(OTLP_ENDPOINT_ENV_VAR).ok()), | ||
| sampling_rate: self.sampling_rate, | ||
| } | ||
| } | ||
|
|
||
| /// Validate telemetry configuration values. | ||
| /// | ||
| /// Returns an error if `otlp_endpoint` is empty/whitespace-only or | ||
| /// `sampling_rate` is outside the `0.0..=1.0` range (including NaN/Inf). | ||
| pub(crate) fn validate(&self) -> Result<(), String> { | ||
| if let Some(endpoint) = &self.otlp_endpoint | ||
| && endpoint.trim().is_empty() | ||
| { | ||
| return Err("telemetry.otlp_endpoint must not be empty or whitespace-only".to_owned()); | ||
| } | ||
| if let Some(rate) = self.sampling_rate | ||
| && (!rate.is_finite() || !(0.0..=1.0).contains(&rate)) | ||
| { | ||
| return Err(format!( | ||
| "telemetry.sampling_rate must be between 0.0 and 1.0, got {rate}" | ||
| )); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Build from explicit values (for testing without env var mutation). | ||
| /// | ||
| /// This deliberately bypasses [`resolve()`](Self::resolve) to avoid | ||
| /// mutating process-wide environment variables in tests. It tests | ||
| /// the *merge precedence* logic (config > env) in isolation. The | ||
| /// real `resolve()` path is exercised by `resolve_preserves_sampling_rate`. | ||
| #[cfg(test)] | ||
| fn resolved(config_endpoint: Option<&str>, env_endpoint: Option<&str>) -> Self { | ||
| Self { | ||
| otlp_endpoint: config_endpoint.or(env_endpoint).map(ToOwned::to_owned), | ||
| sampling_rate: None, | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] The The config-takes-precedence path is testable without env var mutation: construct a |
||
| } | ||
| } | ||
|
|
@@ -95,13 +136,26 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn defaults_to_no_sampling_rate() { | ||
| let telemetry = TelemetryConfig::default(); | ||
| assert!( | ||
| telemetry.sampling_rate.is_none(), | ||
| "sampling_rate should default to None" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_empty_yields_defaults() { | ||
| let telemetry: TelemetryConfig = serde_yaml::from_str("{}").unwrap(); | ||
| assert!( | ||
| telemetry.otlp_endpoint.is_none(), | ||
| "empty yaml should default otlp_endpoint to None" | ||
| ); | ||
| assert!( | ||
| telemetry.sampling_rate.is_none(), | ||
| "empty yaml should default sampling_rate to None" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
|
|
@@ -114,6 +168,38 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_explicit_sampling_rate() { | ||
| let telemetry: TelemetryConfig = serde_yaml::from_str("sampling_rate: 0.5").unwrap(); | ||
| assert_eq!( | ||
| telemetry.sampling_rate, | ||
| Some(0.5), | ||
| "explicit sampling_rate should be parsed" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_sampling_rate_zero() { | ||
| let telemetry: TelemetryConfig = serde_yaml::from_str("sampling_rate: 0.0").unwrap(); | ||
| assert_eq!(telemetry.sampling_rate, Some(0.0), "sampling_rate 0.0 should be parsed"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_sampling_rate_one() { | ||
| let telemetry: TelemetryConfig = serde_yaml::from_str("sampling_rate: 1.0").unwrap(); | ||
| assert_eq!(telemetry.sampling_rate, Some(1.0), "sampling_rate 1.0 should be parsed"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn parse_sampling_rate_one_percent() { | ||
| let telemetry: TelemetryConfig = serde_yaml::from_str("sampling_rate: 0.01").unwrap(); | ||
| assert_eq!( | ||
| telemetry.sampling_rate, | ||
| Some(0.01), | ||
| "sampling_rate 0.01 (1%) should be parsed" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn reject_unknown_field() { | ||
| let result = serde_yaml::from_str::<TelemetryConfig>("bogus_field: true"); | ||
|
|
@@ -156,4 +242,146 @@ mod tests { | |
| "should return None when both are unset" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn resolve_preserves_sampling_rate() { | ||
| let config = TelemetryConfig { | ||
| otlp_endpoint: None, | ||
| sampling_rate: Some(0.5), | ||
| }; | ||
| let resolved = config.resolve(); | ||
| assert_eq!( | ||
| resolved.sampling_rate, | ||
| Some(0.5), | ||
| "resolve should preserve sampling_rate" | ||
| ); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------------- | ||
| // Validation | ||
| // ------------------------------------------------------------------------- | ||
|
|
||
| #[test] | ||
| fn validate_none_sampling_rate_ok() { | ||
| let config = TelemetryConfig::default(); | ||
| assert!(config.validate().is_ok(), "None sampling_rate should pass validation"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_zero_ok() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(0.0), | ||
| ..Default::default() | ||
| }; | ||
| assert!(config.validate().is_ok(), "sampling_rate 0.0 should pass validation"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_one_ok() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(1.0), | ||
| ..Default::default() | ||
| }; | ||
| assert!(config.validate().is_ok(), "sampling_rate 1.0 should pass validation"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_mid_range_ok() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(0.01), | ||
| ..Default::default() | ||
| }; | ||
| assert!(config.validate().is_ok(), "sampling_rate 0.01 should pass validation"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_negative_rejected() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(-0.1), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("between 0.0 and 1.0"), | ||
| "negative rate should be rejected: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_above_one_rejected() { | ||
| let config = TelemetryConfig { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] YAML supports #[test]
fn validate_sampling_rate_nan_rejected() {
let config = TelemetryConfig {
sampling_rate: Some(f64::NAN),
..Default::default()
};
assert!(
config.validate().is_err(),
"NaN sampling_rate should be rejected",
);
}
#[test]
fn validate_sampling_rate_infinity_rejected() {
let config = TelemetryConfig {
sampling_rate: Some(f64::INFINITY),
..Default::default()
};
assert!(
config.validate().is_err(),
"infinite sampling_rate should be rejected",
);
} |
||
| sampling_rate: Some(1.5), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("between 0.0 and 1.0"), | ||
| "rate above 1.0 should be rejected: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_nan_rejected() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(f64::NAN), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("between 0.0 and 1.0"), | ||
| "NaN sampling_rate should be rejected: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_infinity_rejected() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(f64::INFINITY), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("between 0.0 and 1.0"), | ||
| "Inf sampling_rate should be rejected: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_sampling_rate_neg_infinity_rejected() { | ||
| let config = TelemetryConfig { | ||
| sampling_rate: Some(f64::NEG_INFINITY), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("between 0.0 and 1.0"), | ||
| "-Inf sampling_rate should be rejected: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_empty_otlp_endpoint_rejected() { | ||
| let config = TelemetryConfig { | ||
| otlp_endpoint: Some(String::new()), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("must not be empty"), | ||
| "empty otlp_endpoint should be rejected: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_whitespace_otlp_endpoint_rejected() { | ||
| let config = TelemetryConfig { | ||
| otlp_endpoint: Some(" ".to_owned()), | ||
| ..Default::default() | ||
| }; | ||
| let err = config.validate().unwrap_err(); | ||
| assert!( | ||
| err.contains("must not be empty"), | ||
| "whitespace-only otlp_endpoint should be rejected: {err}" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Medium]
validate()checkssampling_ratebut does not reject empty or whitespace-onlyotlp_endpoint. Settingotlp_endpoint: ""in config (or exportingOTEL_EXPORTER_OTLP_ENDPOINT="") passes validation, survivesresolve(), and reachesbuild_otel_providerwhich will attempt to build a tonic exporter with an empty URL -- failing at runtime rather than at config load.Add empty-endpoint validation before the sampling_rate check: