Skip to content

feat(core): configurable head-based sampling with always-sample-on-error - #907

Draft
Ladas wants to merge 3 commits into
praxis-proxy:mainfrom
Ladas:issue-311-sampling-config
Draft

feat(core): configurable head-based sampling with always-sample-on-error#907
Ladas wants to merge 3 commits into
praxis-proxy:mainfrom
Ladas:issue-311-sampling-config

Conversation

@Ladas

@Ladas Ladas commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Add sampling_rate field to TelemetryConfig (0.0 to 1.0). When set, configures a ParentBased sampler wrapping TraceIdRatioBased on the SdkTracerProvider.

Why

Production deployments need to control trace volume. AlwaysOn sampling generates too much data; configurable rates (1-10%) with always-on-error preserve visibility while managing cost.

How

  • New sampling_rate: Option<f64> on TelemetryConfig
  • Validation at config load (reject < 0.0 or > 1.0)
  • ParentBased(TraceIdRatioBased) when rate is set
  • TODO: always-sample-on-error requires Root span per request with standard attributes #301 for error detection at span close
  • 15 new tests

Depends on #315 (PR #836)

Closes #311

@praxis-bot-app

praxis-bot-app Bot commented Aug 3, 2026

Copy link
Copy Markdown

Unsigned commits: 02d9688. Please sign your commits.

Ladas added 3 commits August 3, 2026 19:08
Replace the fmt-only tracing subscriber with a layered
tracing_subscriber::Registry combining:

- fmt layer (always) — stdout text or JSON logging
- OTLP layer (opt-in) — span export to OTel Collector via gRPC

The OTLP layer is behind the `otel` cargo feature flag to keep the
default binary lean. When compiled with `--features otel` and an
endpoint is configured (via `telemetry.otlp_endpoint` in config or
`OTEL_EXPORTER_OTLP_ENDPOINT` env var), spans are exported to any
OTLP-compatible backend (OTel Collector, MLflow, Tempo, etc.).

Changes:
- Add opentelemetry 0.32, opentelemetry_sdk 0.32, opentelemetry-otlp
  0.32, tracing-opentelemetry 0.33 as workspace dependencies
- Add `registry` feature to tracing-subscriber
- Add `otel` feature to core and server crates
- New TelemetryConfig with otlp_endpoint field (deny_unknown_fields)
- Refactor init_tracing to return TracingGuard (RAII shutdown)
- Add example config and feature-gated integration test

Closes praxis-proxy#315

Signed-off-by: Ladislav Smola <lsmola@redhat.com>
Resolve the OTLP endpoint once during init_tracing() via
TelemetryConfig::resolve(), which returns a new TelemetryConfig with
env var merged in. The resolved config is passed to build_otel_provider
so downstream code never reads global state directly.

Tests use TelemetryConfig::resolved() — a test-only constructor that
takes explicit config and env values as parameters, eliminating unsafe
set_var/remove_var calls and parallel test races.

Addresses review feedback:
- alexsnaps: env vars should be snapshotted at startup, not accessed
  over time as global mutable state
- alexsnaps: unsafe set_var/remove_var in tests cannot guarantee
  thread safety

Signed-off-by: Ladislav Smola <lsmola@redhat.com>
Add sampling_rate field to TelemetryConfig (0.0 to 1.0, default None
= AlwaysOn for backward compat). When set, configures a ParentBased
sampler wrapping TraceIdRatioBased on the SdkTracerProvider.

- Validate rate at config load time (reject < 0.0 or > 1.0)
- ParentBased respects incoming traceparent sampling decisions
- Add 15 tests covering parsing, validation, boundary values, resolve
- TODO: always-sample-on-error requires praxis-proxy#301 (root span) for error
  detection at span close time

Closes praxis-proxy#311

Signed-off-by: Ladislav Smola <lsmola@redhat.com>
@Ladas
Ladas force-pushed the issue-311-sampling-config branch from 02d9688 to 3af7727 Compare August 3, 2026 17:08

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Solid PR adding configurable head-based sampling with proper feature gating, clean TracingGuard RAII shutdown, and thorough test coverage. The architecture (optional otel feature behind praxis-core, propagated through server) is well-designed and backward-compatible.

Three medium findings around validation edge cases and test fidelity.

Severity Count
Critical 0
Large 0
Medium 3

Reviewed by praxis-bot

));
}
Ok(())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] validate() checks sampling_rate but does not reject empty or whitespace-only otlp_endpoint. Setting otlp_endpoint: "" in config (or exporting OTEL_EXPORTER_OTLP_ENDPOINT="") passes validation, survives resolve(), and reaches build_otel_provider which 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:

if let Some(ep) = &self.otlp_endpoint
    && ep.trim().is_empty()
{
    return Err(
        "telemetry.otlp_endpoint must not be empty".to_owned(),
    );
}

Self {
otlp_endpoint: config_endpoint.or(env_endpoint).map(ToOwned::to_owned),
sampling_rate: None,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The resolved() test helper manually reimplements the merge logic from resolve() (config_endpoint.or(env_endpoint)). The three tests that use it (resolve_prefers_config_over_env, resolve_falls_back_to_env, resolve_none_when_both_unset) verify the helper's behavior, not the actual resolve() method. If resolve() is refactored, these tests would still pass.

The config-takes-precedence path is testable without env var mutation: construct a TelemetryConfig with otlp_endpoint: Some(...) and call .resolve() -- the config value is returned regardless of env. For the env fallback, consider temp_env or a thin indirection. At minimum, rename the tests to clarify they test the helper, not resolve().


#[test]
fn validate_sampling_rate_above_one_rejected() {
let config = TelemetryConfig {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] YAML supports .nan, .inf, and -.inf as float literals, and serde_yaml will parse them into Option<f64>. The current !(0.0..=1.0).contains(&rate) check happens to reject all three (NaN comparisons are always false, infinity is outside the range), but there are no tests confirming this. Add explicit test cases to lock in the behavior:

#[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",
    );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configurable head-based sampling with always-sample-on-error

2 participants