diff --git a/propagation/trace_context.go b/propagation/trace_context.go index 6870e316dc0..f6c0b049055 100644 --- a/propagation/trace_context.go +++ b/propagation/trace_context.go @@ -46,8 +46,8 @@ func (tc TraceContext) Inject(ctx context.Context, carrier TextMapCarrier) { carrier.Set(tracestateHeader, ts) } - // Clear all flags other than the trace-context supported sampling bit. - flags := sc.TraceFlags() & trace.FlagsSampled + // Clear all flags other than the trace-context supported bits. + flags := sc.TraceFlags() & (trace.FlagsSampled | trace.FlagsRandom) var sb strings.Builder sb.Grow(2 + 32 + 16 + 2 + 3) @@ -111,7 +111,7 @@ func (tc TraceContext) extract(carrier TextMapCarrier) trace.SpanContext { } // Clear all flags other than the trace-context supported sampling bit. - scc.TraceFlags = trace.TraceFlags(opts[0]) & trace.FlagsSampled + scc.TraceFlags = trace.TraceFlags(opts[0]) & (trace.FlagsSampled | trace.FlagsRandom) // Ignore the error returned here. Failure to parse tracestate MUST NOT // affect the parsing of traceparent according to the W3C tracecontext diff --git a/propagation/trace_context_test.go b/propagation/trace_context_test.go index 4579fd19ea0..0e8f0f56742 100644 --- a/propagation/trace_context_test.go +++ b/propagation/trace_context_test.go @@ -94,14 +94,14 @@ func TestExtractValidTraceContext(t *testing.T) { }), }, { - name: "future version sampled", + name: "future version sampled and random", header: http.Header{ - traceparent: []string{"02-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, + traceparent: []string{"02-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03"}, }, sc: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, - TraceFlags: trace.FlagsSampled, + TraceFlags: trace.FlagsSampled | trace.FlagsRandom, Remote: true, }), }, @@ -290,7 +290,7 @@ func TestInjectValidTraceContext(t *testing.T) { { name: "unsupported trace flag bits dropped", header: http.Header{ - traceparent: []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, + traceparent: []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-03"}, }, sc: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, diff --git a/sdk/trace/id_generator.go b/sdk/trace/id_generator.go index 925bcf99305..70e53f52c50 100644 --- a/sdk/trace/id_generator.go +++ b/sdk/trace/id_generator.go @@ -29,12 +29,22 @@ type IDGenerator interface { // must never be done outside of a new major release. } +// IDGeneratorRandom allows custom generators for TraceID and SpanID that comply +// with W3C Trace Context Level 2 randomness requirements. +type W3CTraceContextIDGenerator interface { + // W3CTraceContextLevel2Random, when implemented by a + // generator, indicates that this generator meets the + // requirements + W3CTraceContextLevel2Random() +} + type randomIDGenerator struct { sync.Mutex randSource *rand.Rand } var _ IDGenerator = &randomIDGenerator{} +var _ W3CTraceContextIDGenerator = &randomIDGenerator{} // NewSpanID returns a non-zero span ID from a randomly-chosen sequence. func (gen *randomIDGenerator) NewSpanID(ctx context.Context, traceID trace.TraceID) trace.SpanID { @@ -72,6 +82,10 @@ func (gen *randomIDGenerator) NewIDs(ctx context.Context) (trace.TraceID, trace. return tid, sid } +// W3CTraceContextLevel2Random declares meeting the W3C trace context +// level 2 randomness requirement. +func (gen *randomIDGenerator) W3CTraceContextLevel2Random() {} + func defaultIDGenerator() IDGenerator { gen := &randomIDGenerator{} var rngSeed int64 diff --git a/sdk/trace/sampling.go b/sdk/trace/sampling.go index ebb6df6c908..b2831b687e0 100644 --- a/sdk/trace/sampling.go +++ b/sdk/trace/sampling.go @@ -7,7 +7,11 @@ import ( "context" "encoding/binary" "fmt" + "math" + "strconv" + "strings" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -64,29 +68,180 @@ type SamplingResult struct { } type traceIDRatioSampler struct { - traceIDUpperBound uint64 - description string + // threshold is a rejection threshold. + // Select when (T <= R) + // Drop when (T > R) + // Range is [0, 1<<56). + threshold uint64 + + // otts is the encoded OTel trace state field, containing "th:" + otts string + + description string +} + +// tracestateHasRandomness determines whether there is a "rv" sub-key +// in `otts` which is the OTel tracestate value (i.e., the top-level "ot" value). +func tracestateHasRandomness(otts string) (randomness uint64, hasRandom bool) { + var low int + if has := strings.HasPrefix(otts, "rv:"); has { + low = 3 + } else if pos := strings.Index(otts, ";rv:"); pos > 0 { + low = pos + 4 + } else { + return 0, false + } + if len(otts) < low+14 { + otel.Handle(fmt.Errorf("could not parse tracestate randomness: %q: %w", otts, strconv.ErrSyntax)) + } else if len(otts) > low+14 && otts[low+14] != ';' { + otel.Handle(fmt.Errorf("could not parse tracestate randomness: %q: %w", otts, strconv.ErrSyntax)) + } else { + randomIn := otts[low : low+14] + if rv, err := strconv.ParseUint(randomIn, 16, 64); err == nil { + randomness = rv + hasRandom = true + } else { + otel.Handle(fmt.Errorf("could not parse tracestate randomness: %q: %w", randomIn, err)) + } + } + return } func (ts traceIDRatioSampler) ShouldSample(p SamplingParameters) SamplingResult { psc := trace.SpanContextFromContext(p.ParentContext) - x := binary.BigEndian.Uint64(p.TraceID[8:16]) >> 1 - if x < ts.traceIDUpperBound { + state := psc.TraceState() + + existOtts := state.Get("ot") + + var randomness uint64 + var hasRandom bool + if existOtts != "" { + // When the OTel trace state field exists, we will + // inspect for a "rv", otherwise assume that the + // TraceID is random. + randomness, hasRandom = tracestateHasRandomness(existOtts) + } + if !hasRandom { + // Interpret the least-significant 8-bytes as an + // unsigned number, then zero the top 8 bits using + // randomnessMask, yielding the least-significant 56 + // bits of randomness, as specified in W3C Trace + // Context Level 2. + randomness = binary.BigEndian.Uint64(p.TraceID[8:16]) & randomnessMask + } + if ts.threshold > randomness { return SamplingResult{ - Decision: RecordAndSample, - Tracestate: psc.TraceState(), + Decision: Drop, + Tracestate: state, } } + + if mod, err := state.Insert("ot", combineTracestate(existOtts, ts.otts)); err == nil { + state = mod + } else { + otel.Handle(fmt.Errorf("could not update tracestate: %q", err)) + } return SamplingResult{ - Decision: Drop, - Tracestate: psc.TraceState(), + Decision: RecordAndSample, + Tracestate: state, } } +// combineTracestate combines an existing OTel tracestate fragment, +// which is the value of a top-level "ot" tracestate vendor tag. +func combineTracestate(incoming, updated string) string { + // `incoming` is formatted according to the OTel tracestate + // spec, with colon separating two-byte key and value, with + // semi-colon separating key-value pairs. + // + // `updated` should be a single two-byte key:value to modify + // or insert therefore colonOffset is 2 bytes, valueOffset is + // 3 bytes into `incoming`. + const colonOffset = 2 + const valueOffset = colonOffset + 1 + + if incoming == "" { + return updated + } + var out strings.Builder + + // The update is expected to be a single key-value of the form + // `XX:value` for with two-character key. + upkey := updated[:colonOffset] + + // In this case, there is an existing field under "ot" and we + // need to combine. We will pass the parts of "incoming" + // through except the field we are updating, which we will + // modify if it is found. + foundUp := false + + for count := 0; len(incoming) != 0; count++ { + key, rest, hasCol := strings.Cut(incoming, ":") + if !hasCol { + // return the updated value, ignore invalid inputs + return updated + } + value, next, _ := strings.Cut(rest, ";") + + if key == upkey { + value = updated[valueOffset:] + foundUp = true + } + if count != 0 { + out.WriteString(";") + } + out.WriteString(key) + out.WriteString(":") + out.WriteString(value) + + incoming = next + } + if !foundUp { + out.WriteString(";") + out.WriteString(updated) + } + return out.String() +} + func (ts traceIDRatioSampler) Description() string { return ts.description } +const ( + // DefaultSamplingPrecision is the number of hexadecimal + // digits of precision used to expressed the samplling probability. + DefaultSamplingPrecision = 4 + + // MinSupportedProbability is the smallest probability that + // can be encoded by this implementation, and it defines the + // smallest interval between probabilities across the range. + // The largest supported probability is (1-MinSupportedProbability). + // + // This value corresponds with the size of a float64 + // significand, because it simplifies this implementation to + // restrict the probability to use 52 bits (vs 56 bits). + minSupportedProbability float64 = 1 / float64(maxAdjustedCount) + + // maxSupportedProbability is the number closest to 1.0 (i.e., + // near 99.999999%) that is not equal to 1.0 in terms of the + // float64 representation, having 52 bits of significand. + // Other ways to express this number: + // + // 0x1.ffffffffffffe0p-01 + // 0x0.fffffffffffff0p+00 + // math.Nextafter(1.0, 0.0) + maxSupportedProbability float64 = 1 - 0x1p-52 + + // maxAdjustedCount is the inverse of the smallest + // representable sampling probability, it is the number of + // distinct 56 bit values. + maxAdjustedCount uint64 = 1 << 56 + + // randomnessMask is a mask that selects the least-significant + // 56 bits of a uint64. + randomnessMask uint64 = maxAdjustedCount - 1 +) + // TraceIDRatioBased samples a given fraction of traces. Fractions >= 1 will // always sample. Fractions < 0 are treated as zero. To respect the // parent trace's `SampledFlag`, the `TraceIDRatioBased` sampler should be used @@ -94,26 +249,77 @@ func (ts traceIDRatioSampler) Description() string { // //nolint:revive // revive complains about stutter of `trace.TraceIDRatioBased` func TraceIDRatioBased(fraction float64) Sampler { - if fraction >= 1 { + const ( + maxp = 14 // maximum precision is 56 bits + defp = DefaultSamplingPrecision // default precision + hbits = 4 // bits per hex digit + ) + + if fraction > 1-0x1p-52 { return AlwaysSample() } - if fraction <= 0 { - fraction = 0 + if fraction < minSupportedProbability { + return NeverSample() + } + + // Calculate the amount of precision needed to encode the + // threshold with reasonable precision. + // + // 13 hex digits is the maximum reasonable precision, since + // that equals 52 bits, the number of bits in the float64 + // significand. + // + // Frexp() normalizes both the fraction and one-minus the + // fraction, because more digits of precision are needed in + // both cases -- in these cases the threshold has all leading + // '0' or 'f' characters. + // + // We know that `exp <= 0`. If `exp <= -4`, there will be a + // leading hex `0` or `f`. For every multiple of -4, another + // leading `0` or `f` appears, so this raises precision + // accordingly. + _, expF := math.Frexp(fraction) + _, expR := math.Frexp(1 - fraction) + precision := min(maxp, max(defp+expF/-hbits, defp+expR/-hbits)) + + // Compute the threshold + scaled := uint64(math.Round(fraction * float64(maxAdjustedCount))) + threshold := maxAdjustedCount - scaled + + // Round to the specified precision, if less than the maximum. + if shift := hbits * (maxp - precision); shift != 0 { + half := uint64(1) << (shift - 1) + threshold += half + threshold >>= shift + threshold <<= shift } + // Add maxAdjustedCount so that leading-zeros are formatted by + // the strconv library after an artificial leading "1". Then, + // strip the leadingt "1", then remove trailing zeros. + tvalue := strings.TrimRight(strconv.FormatUint(maxAdjustedCount+threshold, 16)[1:], "0") + return &traceIDRatioSampler{ - traceIDUpperBound: uint64(fraction * (1 << 63)), - description: fmt.Sprintf("TraceIDRatioBased{%g}", fraction), + threshold: threshold, + otts: fmt.Sprint("th:", tvalue), + description: fmt.Sprintf("TraceIDRatioBased{%g}", fraction), } } type alwaysOnSampler struct{} func (as alwaysOnSampler) ShouldSample(p SamplingParameters) SamplingResult { + ts := trace.SpanContextFromContext(p.ParentContext).TraceState() + // 100% sampling equals zero rejection threshold. + if mod, err := ts.Insert("ot", combineTracestate(ts.Get("ot"), "th:0")); err == nil { + ts = mod + } else { + otel.Handle(fmt.Errorf("could not update tracestate: %w", err)) + } return SamplingResult{ Decision: RecordAndSample, - Tracestate: trace.SpanContextFromContext(p.ParentContext).TraceState(), + Tracestate: ts, } } diff --git a/sdk/trace/sampling_test.go b/sdk/trace/sampling_test.go index 7480d5ef812..d3cb6043124 100644 --- a/sdk/trace/sampling_test.go +++ b/sdk/trace/sampling_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "math/rand" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -168,6 +169,56 @@ func TestParentBasedDefaultDescription(t *testing.T) { } } +// TestTraceIDRatioBasedDescription tests the formatted description and +// the corresponding threshold. +func TestTraceIDRatioBasedDescription(t *testing.T) { + for _, tc := range []struct { + prob float64 + desc string + }{ + // Some well-known values + {0.5, "TraceIDRatioBased{0.5}"}, + {1. / 3, "TraceIDRatioBased{0.3333333333333333}"}, + {1. / 10000, "TraceIDRatioBased{0.0001}"}, + + // Values very close to 1.0 round up to 1.0 + {1, "AlwaysOnSampler"}, + {1 - 0x1p-55, "AlwaysOnSampler"}, + {1 - 0x1p-54, "AlwaysOnSampler"}, + {1 - 0x1p-53, "AlwaysOnSampler"}, + } { + sampler := TraceIDRatioBased(tc.prob) + + require.Equal(t, tc.desc, sampler.Description()) + } +} + +// TestTraceIDRatioBasedThreshold tests the unsigned threshold value to ensure +// it is calculated correctly, separately from the printed threshold tested as +// part of the description. The test inputs are some of same as are used in +// TestTraceIDRatioBasedDescription. +func TestTraceIDRatioBasedThreshold(t *testing.T) { + for _, tc := range []struct { + prob float64 + threshold uint64 + }{ + // Some well-known values + {0.5, 0x80000000000000}, + {1 / 3.0, 0xaaab0000000000}, + {2 / 3.0, 0x55550000000000}, + {1 / 10.0, 0xe6660000000000}, + + // Small powers of two + {1 / 256.0, 0xff000000000000}, + {1 / 65536.0, 0xffff0000000000}, + {1 / 1048576.0, 0xfffff000000000}, + } { + sampler := TraceIDRatioBased(tc.prob).(*traceIDRatioSampler) + + require.Equal(t, tc.threshold, sampler.threshold) + } +} + // TraceIDRatioBased sampler requirements state // // "A TraceIDRatioBased sampler with a given sampling rate MUST also sample @@ -199,50 +250,229 @@ func TestTraceIdRatioSamplesInclusively(t *testing.T) { } } +type unusedSampler struct{} + +var _ Sampler = unusedSampler{} + +func (unusedSampler) ShouldSample(parameters SamplingParameters) SamplingResult { + panic("unused sampler should not be called") +} + +func (unusedSampler) Description() string { + return "" +} + +// TestTracestateIsPassed exercises a variety of sampler +// configurations and ensures their tracestate output is correct, with +// and without selecting the item for sampling. For non-100%, non-0% +// configurations, this is tested using the explicit R-value logic +// which makes the test deterministic. func TestTracestateIsPassed(t *testing.T) { + type outcome struct { + sampled bool + ts string + } + // Note: Inputs always have valid span context (TraceID and SpanID) + // so ParentBased always takes the always/never sampled of + // the incoming trace flags. testCases := []struct { name string sampler Sampler + + // invalidCtx, if true, indicates not to set TraceID + // and SpanID, which will cause a ParentBased sampler + // to call the root sampler. + invalidCtx bool + + // inputTs is the arriving encoded TraceState + inputTs string + + // ifSampled is the outcome when the incoming context is sampled + ifSampled outcome + + // ifUnsampled is the outcome when the incoming context is unsampled. + ifUnsampled outcome }{ { - "notSampled", - NeverSample(), + // NeverSample() passes trace state. + name: "neverSample", + sampler: NeverSample(), + inputTs: "k=v", + ifSampled: outcome{false, "k=v"}, + ifUnsampled: outcome{false, "k=v"}, + }, + { + // AlwaysSample() passes trace state. + name: "alwaysSample", + sampler: AlwaysSample(), + inputTs: "k=v", + ifSampled: outcome{true, "ot=th:0,k=v"}, + ifUnsampled: outcome{true, "ot=th:0,k=v"}, }, { - "sampled", - AlwaysSample(), + // ParentBased() passes trace state to the + // Always- or NeverSample(). + name: "parentBasedDefaults", + sampler: ParentBased(unusedSampler{}), + inputTs: "k=v", + ifSampled: outcome{true, "ot=th:0,k=v"}, + ifUnsampled: outcome{false, "k=v"}, }, { - "parentSampled", - ParentBased(AlwaysSample()), + // ParentBased passes trace state to the + // root-based sampler + name: "parentBasedRootAlways", + sampler: ParentBased(AlwaysSample()), + invalidCtx: true, + inputTs: "k=v", + ifSampled: outcome{true, "ot=th:0,k=v"}, + ifUnsampled: outcome{true, "ot=th:0,k=v"}, }, { - "parentNotSampled", - ParentBased(NeverSample()), + // TraceIDRatioBased ignores parent decision, + // 50% sampler w/ sampled R-value. + name: "fiftyPctSampled", + sampler: TraceIDRatioBased(0.5), + inputTs: "k=v,ot=rv:ababababababab", + ifSampled: outcome{true, "ot=rv:ababababababab;th:8,k=v"}, + ifUnsampled: outcome{true, "ot=rv:ababababababab;th:8,k=v"}, }, { - "traceIDRatioSampler", - TraceIDRatioBased(.5), + // TraceIDRatioBased ignores parent decision, + // 50% sampler w/ unsampled R-value. + name: "fiftyPctUnsampled", + sampler: TraceIDRatioBased(0.5), + inputTs: "k=v,ot=rv:12121212121212", + ifSampled: outcome{false, "k=v,ot=rv:12121212121212"}, + ifUnsampled: outcome{false, "k=v,ot=rv:12121212121212"}, }, } + generator := defaultIDGenerator() + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - traceState, err := trace.ParseTraceState("k=v") - if err != nil { - t.Error(err) - } + for _, inputSampled := range []bool{true, false} { + t.Run(fmt.Sprint("sampled=", inputSampled), func(t *testing.T) { + // Since the TraceID generation step is + // randomized, repeating the test ensures it + // is deterministic. The outcome should not + // be probabilistic, the repetition is to + // verify that. + const repeats = 10 + for i := 0; i < repeats; i++ { + traceState, err := trace.ParseTraceState(tc.inputTs) + if err != nil { + t.Error(err) + } - params := SamplingParameters{ - ParentContext: trace.ContextWithSpanContext( - context.Background(), - trace.NewSpanContext(trace.SpanContextConfig{ - TraceState: traceState, - }), - ), - } + var scc trace.SpanContextConfig + scc.TraceState = traceState + + if !tc.invalidCtx { + randTid, randSid := generator.NewIDs(context.Background()) + scc.TraceID = randTid + scc.SpanID = randSid + } + + var expect outcome + if inputSampled { + scc.TraceFlags = trace.FlagsSampled + expect = tc.ifSampled + } else { + expect = tc.ifUnsampled + } - require.Equal(t, traceState, tc.sampler.ShouldSample(params).Tracestate, "TraceState is not equal") + expectState, err := trace.ParseTraceState(expect.ts) + if err != nil { + t.Error(err) + } + + params := SamplingParameters{ + ParentContext: trace.ContextWithSpanContext( + context.Background(), + trace.NewSpanContext(scc), + ), + } + + decision := tc.sampler.ShouldSample(params) + require.Equal(t, expect.sampled, decision.Decision == RecordAndSample, "Sampler decision is unexpected") + require.Equal(t, expectState, decision.Tracestate, "TraceState is unexpected") + } + }) + } }) } } + +// TestCombineTracestate exercises combineTraceState in a variety of ways +func TestCombineTracestate(t *testing.T) { + for _, tc := range []struct { + orig, add, out string + }{ + // R-value exists : T-value added + {"rv:ababababababab", "th:123", "rv:ababababababab;th:123"}, + // Ex + R-value : T-value added + {"ex:xyz;rv:ababababababab", "th:123", "ex:xyz;rv:ababababababab;th:123"}, + // R-value + Ex : T-value added + {"rv:ababababababab;ex:xyz", "th:123", "rv:ababababababab;ex:xyz;th:123"}, + // Ex : T-value added + {"ex:xyz", "th:123", "ex:xyz;th:123"}, + // T-value, Ex : T-value overwritten + {"th:456;ex:xyz", "th:12345", "th:12345;ex:xyz"}, + // Ex, T-value : T-value overwritten + {"ex:xyz;th:456", "th:12345", "ex:xyz;th:12345"}, + // Ex1, T-value, Ex2 : T-value overwritten + {"ex1:xyz;th:456;ex2:zyx", "th:12345", "ex1:xyz;th:12345;ex2:zyx"}, + // Ex1, Ex2 : T-value added + {"ex1:xyz;ex2:zyx", "th:12345", "ex1:xyz;ex2:zyx;th:12345"}, + + // R-value added + {"ex:xyz", "rv:01230123012301", "ex:xyz;rv:01230123012301"}, + // R-value only + {"", "rv:01230123012301", "rv:01230123012301"}, + // R-value incorrect, overwritten + {"ex:xyz;rv:0123", "rv:01230123012301", "ex:xyz;rv:01230123012301"}, + } { + require.Equal(t, tc.out, combineTracestate(tc.orig, tc.add)) + } +} + +// TestTraceStateHasRandomness ensures the tracestateHasRandomness +// method works as expected. +func TestTraceStateHasRandomness(t *testing.T) { + for _, tc := range []struct { + in string + rnd uint64 + has bool + err error + }{ + // R-value parse errors + {"rv:", 0, false, strconv.ErrSyntax}, + {"rv:0123", 0, false, strconv.ErrSyntax}, + {"rv:0123012301230", 0, false, strconv.ErrSyntax}, + {"rv:012301230123012", 0, false, strconv.ErrSyntax}, + + // R-value is correct + {"rv:abcdef01234567", 0xabcdef01234567, true, nil}, + {"rv:01230123012301", 0x01230123012301, true, nil}, + {"rv:01230123012301;xyz=123", 0x01230123012301, true, nil}, + {"xy:123;rv:01230123012301", 0x01230123012301, true, nil}, + {"xy:123;rv:01230123012301;zz:def", 0x01230123012301, true, nil}, + + // R-value is not set + {"xyz:123", 0, false, nil}, + {"xyz:123;th=123", 0, false, nil}, + } { + handler.Reset() + rnd, has := tracestateHasRandomness(tc.in) + require.Equal(t, tc.has, has) + require.Equal(t, tc.rnd, rnd) + if tc.err == nil { + assert.Equal(t, 0, len(handler.errs), "unexpected errors: %v", handler.errs) + } else { + assert.Equal(t, 1, len(handler.errs), "expected errors: %v: %v", tc.in, handler.errs) + assert.ErrorIs(t, handler.errs[0], tc.err) + } + } +} diff --git a/sdk/trace/trace_test.go b/sdk/trace/trace_test.go index 87247d1f167..f993deb1690 100644 --- a/sdk/trace/trace_test.go +++ b/sdk/trace/trace_test.go @@ -49,16 +49,21 @@ var ( sc trace.SpanContext ts trace.TraceState + alwaysSampledTs trace.TraceState + handler = &storingHandler{} ) func init() { + alwaysSampledTs = mustParseTraceState("ot=th:0") + tid, _ = trace.TraceIDFromHex("01020304050607080102040810203040") sid, _ = trace.SpanIDFromHex("0102040810203040") sc = trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, SpanID: sid, - TraceFlags: 0x1, + TraceFlags: trace.FlagsSampled, + TraceState: alwaysSampledTs, }) ts, _ = trace.ParseTraceState("k=v") @@ -84,6 +89,14 @@ type testExporter struct { spans []*snapshot } +func mustParseTraceState(encoded string) trace.TraceState { + ts, err := trace.ParseTraceState(encoded) + if err != nil { + panic(err) + } + return ts +} + func NewTestExporter() *testExporter { return &testExporter{idx: make(map[string]int)} } @@ -401,6 +414,7 @@ func TestSetSpanAttributesOnStart(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -656,6 +670,7 @@ func TestEvents(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -707,6 +722,7 @@ func TestEventsOverLimit(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -749,6 +765,7 @@ func TestLinks(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -801,6 +818,7 @@ func TestLinksOverLimit(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -850,6 +868,7 @@ func TestSetSpanStatus(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -880,6 +899,7 @@ func TestSetSpanStatusWithoutMessageWhenStatusIsNotError(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -919,7 +939,9 @@ func checkChild(t *testing.T, p trace.SpanContext, apiSpan trace.Span) error { if got, want := s.spanContext.TraceFlags(), p.TraceFlags(); got != want { return fmt.Errorf("got child trace options %d, want %d", got, want) } - got, want := s.spanContext.TraceState(), p.TraceState() + got := s.spanContext.TraceState() + want := p.TraceState() + want, _ = want.Insert("ot", "th:0") assert.Equal(t, want, got) return nil } @@ -1224,6 +1246,7 @@ func TestRecordError(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -1268,6 +1291,7 @@ func TestRecordErrorWithStackTrace(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -1315,6 +1339,7 @@ func TestRecordErrorNil(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -1432,6 +1457,7 @@ func TestWithResource(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -1469,6 +1495,7 @@ func TestWithInstrumentationVersionAndSchema(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -1686,6 +1713,7 @@ func TestAddEventsWithMoreAttributesThanLimit(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: alwaysSampledTs, }), parent: sc.WithRemote(true), name: "span0", @@ -1715,6 +1743,62 @@ func TestAddEventsWithMoreAttributesThanLimit(t *testing.T) { } } +func TestAddLinksWithMoreAttributesThanLimit(t *testing.T) { + te := NewTestExporter() + sl := NewSpanLimits() + sl.AttributePerLinkCountLimit = 1 + tp := NewTracerProvider( + WithSpanLimits(sl), + WithSyncer(te), + WithResource(resource.Empty()), + ) + + k1v1 := attribute.String("key1", "value1") + k2v2 := attribute.String("key2", "value2") + k3v3 := attribute.String("key3", "value3") + k4v4 := attribute.String("key4", "value4") + + sc1 := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID([16]byte{1, 1}), SpanID: trace.SpanID{3}}) + sc2 := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID([16]byte{1, 1}), SpanID: trace.SpanID{3}}) + + span := startSpan(tp, "Links", trace.WithLinks([]trace.Link{ + {SpanContext: sc1, Attributes: []attribute.KeyValue{k1v1, k2v2}}, + {SpanContext: sc2, Attributes: []attribute.KeyValue{k2v2, k3v3, k4v4}}, + }...)) + + got, err := endSpan(te, span) + if err != nil { + t.Fatal(err) + } + + want := &snapshot{ + spanContext: trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: tid, + TraceFlags: 0x1, + TraceState: alwaysSampledTs, + }), + parent: sc.WithRemote(true), + name: "span0", + links: []Link{ + { + SpanContext: sc1, + Attributes: []attribute.KeyValue{k1v1}, + DroppedAttributeCount: 1, + }, + { + SpanContext: sc2, + Attributes: []attribute.KeyValue{k2v2}, + DroppedAttributeCount: 2, + }, + }, + spanKind: trace.SpanKindInternal, + instrumentationScope: instrumentation.Scope{Name: "Links"}, + } + if diff := cmpDiff(got, want); diff != "" { + t.Errorf("Link: -got +want %s", diff) + } +} + type stateSampler struct { prefix string f func(trace.TraceState) trace.TraceState @@ -1770,7 +1854,7 @@ func TestSamplerTraceState(t *testing.T) { name: "alwaysOn", sampler: AlwaysSample(), input: mustTS(trace.ParseTraceState("k1=v1")), - want: mustTS(trace.ParseTraceState("k1=v1")), + want: mustTS(trace.ParseTraceState("ot=th:0,k1=v1")), exportSpan: true, }, { @@ -1890,6 +1974,7 @@ func TestWithIDGenerator(t *testing.T) { numSpan = 5 ) + // Note that testIDGen does not implement the W3C option interface gen := &testIDGenerator{traceID: startTraceID, spanID: startSpanID} te := NewTestExporter() tp := NewTracerProvider( @@ -1908,6 +1993,43 @@ func TestWithIDGenerator(t *testing.T) { gotTraceID, err := strconv.ParseUint(span.SpanContext().TraceID().String(), 16, 64) require.NoError(t, err) assert.Equal(t, uint64(startTraceID+i), gotTraceID) + + // Random flag is not set. + require.Equal(t, span.SpanContext().TraceFlags(), trace.FlagsSampled) + + // The tracestate has a random value suitable for sampling. + ts := span.SpanContext().TraceState() + otts := ts.Get("ot") + rnd, has := tracestateHasRandomness(otts) + require.True(t, has, "tracestate has R-value randomness: %v", ts) + + // Any value less than maxAdjustedCount is permitted. + require.Less(t, rnd, maxAdjustedCount) + }() + } +} + +func TestWithBuiltinIDGenerator(t *testing.T) { + const ( + startTraceID = 1 + startSpanID = 10 + numSpan = 5 + ) + + te := NewTestExporter() + tp := NewTracerProvider( + WithSyncer(te), + ) + for i := 0; i < numSpan; i++ { + func() { + _, span := tp.Tracer(t.Name()).Start(context.Background(), strconv.Itoa(i)) + defer span.End() + + // Random flag is set. + require.Equal(t, span.SpanContext().TraceFlags(), trace.FlagsSampled|trace.FlagsRandom) + + // The tracestate equals "ot=th:0" + require.Equal(t, "ot=th:0", span.SpanContext().TraceState().String()) }() } } @@ -1938,6 +2060,7 @@ func TestSpanAddLink(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: mustParseTraceState("ot=th:0"), }), parent: sc.WithRemote(true), links: nil, @@ -1957,6 +2080,7 @@ func TestSpanAddLink(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: mustParseTraceState("ot=th:0"), }), parent: sc.WithRemote(true), links: []Link{ @@ -1986,6 +2110,7 @@ func TestSpanAddLink(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: mustParseTraceState("ot=th:0"), }), parent: sc.WithRemote(true), links: []Link{ @@ -2010,6 +2135,7 @@ func TestSpanAddLink(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: mustParseTraceState("ot=th:0"), }), parent: sc.WithRemote(true), links: []Link{ @@ -2032,6 +2158,7 @@ func TestSpanAddLink(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: mustParseTraceState("ot=th:0"), }), parent: sc.WithRemote(true), links: []Link{ @@ -2096,6 +2223,7 @@ func TestAddLinkToNonRecordingSpan(t *testing.T) { spanContext: trace.NewSpanContext(trace.SpanContextConfig{ TraceID: tid, TraceFlags: 0x1, + TraceState: mustParseTraceState("ot=th:0"), }), parent: sc.WithRemote(true), links: nil, diff --git a/sdk/trace/tracer.go b/sdk/trace/tracer.go index 43419d3b541..0de44213b11 100644 --- a/sdk/trace/tracer.go +++ b/sdk/trace/tracer.go @@ -5,8 +5,11 @@ package trace // import "go.opentelemetry.io/otel/sdk/trace" import ( "context" + "fmt" + "math/rand" "time" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/sdk/instrumentation" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/embedded" @@ -66,9 +69,8 @@ func (tr *tracer) newSpan(ctx context.Context, name string, config *trace.SpanCo // If told explicitly to make this a new root use a zero value SpanContext // as a parent which contains an invalid trace ID and is not remote. var psc trace.SpanContext - if config.NewRoot() { - ctx = trace.ContextWithSpanContext(ctx, psc) - } else { + if !config.NewRoot() { + // Load the incoming span context. psc = trace.SpanContextFromContext(ctx) } @@ -78,12 +80,49 @@ func (tr *tracer) newSpan(ctx context.Context, name string, config *trace.SpanCo var tid trace.TraceID var sid trace.SpanID if !psc.TraceID().IsValid() { + // It's a root span. It may be possible for the incoming context to + // specify a randomness value via TraceState. However, since the tid, sid = tr.provider.idGenerator.NewIDs(ctx) + + _, isW3CRandom := tr.provider.idGenerator.(W3CTraceContextIDGenerator) + if isW3CRandom { + // If the generator meets the W3C trace context level 2 + // randomness requirement, include the associated flag. + psc = psc.WithTraceFlags(trace.FlagsRandom) + } else { + // Trace ID is invalid, so an arriving value for + // trace.FlagsRandom is meaningless. + psc = psc.WithTraceFlags(0) + } + + if !isW3CRandom { + ts := trace.SpanContextFromContext(ctx).TraceState() + otts := ts.Get("ot") + _, isTraceStateRandom := tracestateHasRandomness(otts) + + if !isTraceStateRandom { + // If the TraceID generator is not random, create a + // new randomness value and set it in the "rv" field. + rnd := uint64(rand.Int63n(int64(maxAdjustedCount))) + ts, err := ts.Insert("ot", combineTracestate(otts, fmt.Sprintf("rv:%014x", rnd))) + if err == nil { + psc = psc.WithTraceState(ts) + } else { + otel.Handle(fmt.Errorf("tracestate format: %w", err)) + } + } + } + } else { + // It's a child span. tid = psc.TraceID() sid = tr.provider.idGenerator.NewSpanID(ctx, tid) } + // Reset to the effective parent span context, which includes the potentially + // modified tracestate including randomness value and/or Random flag. + ctx = trace.ContextWithSpanContext(ctx, psc) + samplingResult := tr.provider.sampler.ShouldSample(SamplingParameters{ ParentContext: ctx, TraceID: tid, diff --git a/trace/trace.go b/trace/trace.go index d49adf671b9..78f1f070e88 100644 --- a/trace/trace.go +++ b/trace/trace.go @@ -12,8 +12,18 @@ import ( const ( // FlagsSampled is a bitmask with the sampled bit set. A SpanContext // with the sampling bit set means the span is sampled. + // + // Since W3C Trace Context Level 1. FlagsSampled = TraceFlags(0x01) + // FlagsRandom is a bitmask with the random bit set. A + // SpanContext with the random bit set means the span has 7 + // bytes of definite TraceID randomness, meaning the 7 least + // significant bytes (56 bits) have good randomness. + // + // Since W3C Trace Context Level 2. + FlagsRandom = TraceFlags(0x02) + errInvalidHexID errorConst = "trace-id and span-id can only contain [0-9a-f] characters, all lowercase" errInvalidTraceIDLength errorConst = "hex encoded trace-id must have length equals to 32" @@ -157,6 +167,20 @@ func (tf TraceFlags) WithSampled(sampled bool) TraceFlags { // nolint:revive // return tf &^ FlagsSampled } +// IsRandom returns if the random bit is set in the TraceFlags. +func (tf TraceFlags) IsRandom() bool { + return tf&FlagsRandom == FlagsRandom +} + +// WithRandom sets the random bit in a new copy of the TraceFlags. +func (tf TraceFlags) WithRandom(random bool) TraceFlags { // nolint:revive // random is not a control flag. + if random { + return tf | FlagsRandom + } + + return tf &^ FlagsRandom +} + // MarshalJSON implements a custom marshal function to encode TraceFlags // as a hex string. func (tf TraceFlags) MarshalJSON() ([]byte, error) { @@ -275,6 +299,11 @@ func (sc SpanContext) IsSampled() bool { return sc.traceFlags.IsSampled() } +// IsRandom returns if the random bit is set in the SpanContext's TraceFlags. +func (sc SpanContext) IsRandom() bool { + return sc.traceFlags.IsRandom() +} + // WithTraceFlags returns a new SpanContext with the TraceFlags replaced. func (sc SpanContext) WithTraceFlags(flags TraceFlags) SpanContext { return SpanContext{