diff --git a/CHANGELOG.md b/CHANGELOG.md index b863c47c3a8..d5abc3d6053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- Add `AlwaysRecord` sampler in `go.opentelemetry.io/otel/sdk/trace`. (#7724) + ### Changed - `Exporter` in `go.opentelemetry.io/otel/exporter/prometheus` ignores metrics with the scope `go.opentelemetry.io/contrib/bridges/prometheus`. diff --git a/sdk/trace/sampling.go b/sdk/trace/sampling.go index 689663d48b2..81c5060ad66 100644 --- a/sdk/trace/sampling.go +++ b/sdk/trace/sampling.go @@ -280,3 +280,31 @@ func (pb parentBased) Description() string { pb.config.localParentNotSampled.Description(), ) } + +// AlwaysRecord returns a sampler decorator which ensures that every span +// is passed to the SpanProcessor, even those that would be normally dropped. +// It converts `Drop` decisions from the root sampler into `RecordOnly` decisions, +// allowing processors to see all spans without sending them to exporters. This is +// typically used to enable accurate span-to-metrics processing. +func AlwaysRecord(root Sampler) Sampler { + return alwaysRecord{root} +} + +type alwaysRecord struct { + root Sampler +} + +func (ar alwaysRecord) ShouldSample(p SamplingParameters) SamplingResult { + rootSamplerSamplingResult := ar.root.ShouldSample(p) + if rootSamplerSamplingResult.Decision == Drop { + return SamplingResult{ + Decision: RecordOnly, + Tracestate: trace.SpanContextFromContext(p.ParentContext).TraceState(), + } + } + return rootSamplerSamplingResult +} + +func (ar alwaysRecord) Description() string { + return "AlwaysRecord{root:" + ar.root.Description() + "}" +} diff --git a/sdk/trace/sampling_test.go b/sdk/trace/sampling_test.go index f8020c1645c..a4525f1b394 100644 --- a/sdk/trace/sampling_test.go +++ b/sdk/trace/sampling_test.go @@ -245,3 +245,64 @@ func TestTracestateIsPassed(t *testing.T) { }) } } + +func TestAlwaysRecordSamplingDecision(t *testing.T) { + traceID, _ := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + + testCases := []struct { + name string + rootSampler Sampler + expectedDecision SamplingDecision + }{ + { + name: "when root sampler decision is RecordAndSample, AlwaysRecord returns RecordAndSample", + rootSampler: AlwaysSample(), + expectedDecision: RecordAndSample, + }, + { + name: "when root sampler decision is Drop, AlwaysRecord returns RecordOnly", + rootSampler: NeverSample(), + expectedDecision: RecordOnly, + }, + { + name: "when root sampler decision is RecordOnly, AlwaysRecord returns RecordOnly", + rootSampler: RecordingOnly(), + expectedDecision: RecordOnly, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sampler := AlwaysRecord(tc.rootSampler) + parentCtx := trace.ContextWithSpanContext( + t.Context(), + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsSampled, + }), + ) + samplingResult := sampler.ShouldSample(SamplingParameters{ParentContext: parentCtx}) + if samplingResult.Decision != tc.expectedDecision { + t.Errorf("Sampling decision should be %v, got %v instead", + tc.expectedDecision, + samplingResult.Decision, + ) + } + }) + } +} + +func TestAlwaysRecordDefaultDescription(t *testing.T) { + sampler := AlwaysRecord(NeverSample()) + + expectedDescription := fmt.Sprintf("AlwaysRecord{root:%s}", NeverSample().Description()) + + if sampler.Description() != expectedDescription { + t.Errorf("Sampler description should be %s, got '%s' instead", + expectedDescription, + sampler.Description(), + ) + } +}