From f219a81773df9d970be0b71bed39bc1f1af71268 Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Wed, 30 Nov 2022 13:55:05 +0100 Subject: [PATCH] [Dynamic Sampling] Head of trace (#492) Co-authored-by: Abhijeet Prasad --- dynamic_sampling_context.go | 51 ++++++++++++++++++- dynamic_sampling_context_test.go | 84 +++++++++++++++++++++++++++++++- http/sentryhttp.go | 7 +-- interfaces.go | 3 +- tracing.go | 58 ++++++++++++++++++---- 5 files changed, 186 insertions(+), 17 deletions(-) diff --git a/dynamic_sampling_context.go b/dynamic_sampling_context.go index a9c9d6b8..1cdab28f 100644 --- a/dynamic_sampling_context.go +++ b/dynamic_sampling_context.go @@ -1,6 +1,7 @@ package sentry import ( + "strconv" "strings" "github.com/getsentry/sentry-go/internal/otel/baggage" @@ -16,7 +17,7 @@ type DynamicSamplingContext struct { Frozen bool } -func NewDynamicSamplingContext(header []byte) (DynamicSamplingContext, error) { +func DynamicSamplingContextFromHeader(header []byte) (DynamicSamplingContext, error) { bag, err := baggage.Parse(string(header)) if err != nil { return DynamicSamplingContext{}, err @@ -36,10 +37,58 @@ func NewDynamicSamplingContext(header []byte) (DynamicSamplingContext, error) { }, nil } +func DynamicSamplingContextFromTransaction(span *Span) DynamicSamplingContext { + entries := map[string]string{} + + hub := hubFromContext(span.Context()) + scope := hub.Scope() + client := hub.Client() + options := client.Options() + + if traceID := span.TraceID.String(); traceID != "" { + entries["trace_id"] = traceID + } + if sampleRate := span.sampleRate; sampleRate != 0 { + entries["sample_rate"] = strconv.FormatFloat(sampleRate, 'f', -1, 64) + } + + if dsn := client.dsn; dsn != nil { + if publicKey := dsn.publicKey; publicKey != "" { + entries["public_key"] = publicKey + } + } + if release := options.Release; release != "" { + entries["release"] = release + } + if environment := options.Environment; environment != "" { + entries["environment"] = environment + } + + // Only include the transaction name if it's of good quality (not empty and not SourceURL) + if span.Source != "" && span.Source != SourceURL { + if transactionName := scope.Transaction(); transactionName != "" { + entries["transaction"] = transactionName + } + } + + if userSegment := scope.user.Segment; userSegment != "" { + entries["user_segment"] = userSegment + } + + return DynamicSamplingContext{ + Entries: entries, + Frozen: true, + } +} + func (d DynamicSamplingContext) HasEntries() bool { return len(d.Entries) > 0 } +func (d DynamicSamplingContext) IsFrozen() bool { + return d.Frozen +} + func (d DynamicSamplingContext) String() string { // TODO implement me return "" diff --git a/dynamic_sampling_context_test.go b/dynamic_sampling_context_test.go index 0be16d55..ca8bec70 100644 --- a/dynamic_sampling_context_test.go +++ b/dynamic_sampling_context_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestNewDynamicSamplingContext(t *testing.T) { +func TestDynamicSamplingContextFromHeader(t *testing.T) { tests := []struct { input []byte want DynamicSamplingContext @@ -41,10 +41,90 @@ func TestNewDynamicSamplingContext(t *testing.T) { } for _, tc := range tests { - got, err := NewDynamicSamplingContext(tc.input) + got, err := DynamicSamplingContextFromHeader(tc.input) if err != nil { t.Fatal(err) } assertEqual(t, got, tc.want) } } + +func TestDynamicSamplingContextFromTransaction(t *testing.T) { + tests := []struct { + input *Span + want DynamicSamplingContext + }{ + // Normal flow + { + input: func() *Span { + ctx := NewTestContext(ClientOptions{ + EnableTracing: true, + TracesSampleRate: 0.5, + Dsn: "http://public@example.com/sentry/1", + Release: "1.0.0", + Environment: "test", + }) + hubFromContext(ctx).ConfigureScope(func(scope *Scope) { + scope.SetUser(User{Segment: "user_segment"}) + }) + txn := StartTransaction(ctx, "name", TransctionSource(SourceCustom)) + txn.TraceID = TraceIDFromHex("d49d9bf66f13450b81f65bc51cf49c03") + return txn + }(), + want: DynamicSamplingContext{ + Frozen: true, + Entries: map[string]string{ + "sample_rate": "0.5", + "trace_id": "d49d9bf66f13450b81f65bc51cf49c03", + "public_key": "public", + "release": "1.0.0", + "environment": "test", + "transaction": "name", + "user_segment": "user_segment", + }, + }, + }, + // Transaction with source url, do not include in Dynamic Sampling context + { + input: func() *Span { + ctx := NewTestContext(ClientOptions{ + EnableTracing: true, + TracesSampleRate: 0.5, + Dsn: "http://public@example.com/sentry/1", + Release: "1.0.0", + }) + txn := StartTransaction(ctx, "name") + txn.TraceID = TraceIDFromHex("d49d9bf66f13450b81f65bc51cf49c03") + return txn + }(), + want: DynamicSamplingContext{ + Frozen: true, + Entries: map[string]string{ + "sample_rate": "0.5", + "trace_id": "d49d9bf66f13450b81f65bc51cf49c03", + "public_key": "public", + "release": "1.0.0", + }, + }, + }, + } + + for _, tc := range tests { + got := DynamicSamplingContextFromTransaction(tc.input) + assertEqual(t, got, tc.want) + } +} + +func TestHasEntries(t *testing.T) { + var dsc DynamicSamplingContext + + dsc = DynamicSamplingContext{} + assertEqual(t, dsc.HasEntries(), false) + + dsc = DynamicSamplingContext{ + Entries: map[string]string{ + "foo": "bar", + }, + } + assertEqual(t, dsc.HasEntries(), true) +} diff --git a/http/sentryhttp.go b/http/sentryhttp.go index 86c5df38..10515310 100644 --- a/http/sentryhttp.go +++ b/http/sentryhttp.go @@ -71,9 +71,9 @@ func (h *Handler) Handle(handler http.Handler) http.Handler { // where that is convenient. In particular, use it to wrap a handler function // literal. // -// http.Handle(pattern, h.HandleFunc(func (w http.ResponseWriter, r *http.Request) { -// // handler code here -// })) +// http.Handle(pattern, h.HandleFunc(func (w http.ResponseWriter, r *http.Request) { +// // handler code here +// })) func (h *Handler) HandleFunc(handler http.HandlerFunc) http.HandlerFunc { return h.handle(handler) } @@ -89,6 +89,7 @@ func (h *Handler) handle(handler http.Handler) http.HandlerFunc { options := []sentry.SpanOption{ sentry.OpName("http.server"), sentry.ContinueFromRequest(r), + sentry.TransctionSource(sentry.SourceURL), } // We don't mind getting an existing transaction back so we don't need to // check if it is. diff --git a/interfaces.go b/interfaces.go index 01de6c01..5adf1251 100644 --- a/interfaces.go +++ b/interfaces.go @@ -218,7 +218,8 @@ type Exception struct { // SDKMetaData is a struct to stash data which is needed at some point in the SDK's event processing pipeline // but which shouldn't get send to Sentry. type SDKMetaData struct { - dsc DynamicSamplingContext + dsc DynamicSamplingContext + transactionSource TransactionSource } // EventID is a hexadecimal string representing a unique uuid4 for an Event. diff --git a/tracing.go b/tracing.go index 6023cd9d..33b4e5aa 100644 --- a/tracing.go +++ b/tracing.go @@ -28,25 +28,23 @@ type Span struct { //nolint: maligned // prefer readability over optimal memory StartTime time.Time `json:"start_timestamp"` EndTime time.Time `json:"timestamp"` Data map[string]interface{} `json:"data,omitempty"` + Sampled Sampled `json:"-"` + Source TransactionSource `json:"-"` - Sampled Sampled `json:"-"` - + // sample rate the span was sampled with. + sampleRate float64 // ctx is the context where the span was started. Always non-nil. ctx context.Context - // Dynamic Sampling context dynamicSamplingContext DynamicSamplingContext - // parent refers to the immediate local parent span. A remote parent span is // only referenced by setting ParentSpanID. parent *Span - // isTransaction is true only for the root span of a local span tree. The // root span is the first span started in a context. Note that a local root // span may have a remote parent belonging to the same trace, therefore // isTransaction depends on ctx and not on parent. isTransaction bool - // recorder stores all spans in a transaction. Guaranteed to be non-nil. recorder *spanRecorder } @@ -141,7 +139,6 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp option(&span) } - // TODO only sample transactions? span.Sampled = span.sample() if hasParent { @@ -273,7 +270,7 @@ func (s *Span) updateFromSentryTrace(header []byte) { func (s *Span) updateFromBaggage(header []byte) { if s.isTransaction { - dsc, err := NewDynamicSamplingContext(header) + dsc, err := DynamicSamplingContextFromHeader(header) if err != nil { return } @@ -311,12 +308,19 @@ func (s *Span) sample() Sampled { // #1 tracing is not enabled. if !clientOptions.EnableTracing { Logger.Printf("Dropping transaction: EnableTracing is set to %t", clientOptions.EnableTracing) + s.sampleRate = 0.0 return SampledFalse } // #2 explicit sampling decision via StartSpan/StartTransaction options. if s.Sampled != SampledUndefined { Logger.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.Sampled) + switch s.Sampled { + case SampledTrue: + s.sampleRate = 1.0 + case SampledFalse: + s.sampleRate = 0.0 + } return s.Sampled } @@ -333,6 +337,7 @@ func (s *Span) sample() Sampled { samplingContext := SamplingContext{Span: s, Parent: s.parent} if sampler != nil { tracesSamplerSampleRate := sampler.Sample(samplingContext) + s.sampleRate = tracesSamplerSampleRate if tracesSamplerSampleRate < 0.0 || tracesSamplerSampleRate > 1.0 { Logger.Printf("Dropping transaction: Returned TracesSampler rate is out of range [0.0, 1.0]: %f", tracesSamplerSampleRate) return SampledFalse @@ -351,11 +356,18 @@ func (s *Span) sample() Sampled { // #4 inherit parent decision. if s.parent != nil { Logger.Printf("Using sampling decision from parent: %v", s.parent.Sampled) + switch s.parent.Sampled { + case SampledTrue: + s.sampleRate = 1.0 + case SampledFalse: + s.sampleRate = 0.0 + } return s.parent.Sampled } // #5 use TracesSampleRate from ClientOptions. sampleRate := clientOptions.TracesSampleRate + s.sampleRate = sampleRate if sampleRate < 0.0 || sampleRate > 1.0 { Logger.Printf("Dropping transaction: TracesSamplerRate out of range [0.0, 1.0]: %f", sampleRate) return SampledFalse @@ -388,6 +400,12 @@ func (s *Span) toEvent() *Event { finished = append(finished, child) } + // Create and attach a DynamicSamplingContext to the transaction. + // If the DynamicSamplingContext is not frozen at this point, we can assume being head of trace. + if !s.dynamicSamplingContext.IsFrozen() { + s.dynamicSamplingContext = DynamicSamplingContextFromTransaction(s) + } + return &Event{ Type: transactionType, Transaction: hub.Scope().Transaction(), @@ -400,7 +418,8 @@ func (s *Span) toEvent() *Event { StartTime: s.StartTime, Spans: finished, sdkMetaData: SDKMetaData{ - dsc: s.dynamicSamplingContext, + dsc: s.dynamicSamplingContext, + transactionSource: s.Source, }, } } @@ -459,6 +478,18 @@ var ( zeroSpanID SpanID ) +// Contains information about how the name of the transaction was determined. +type TransactionSource string + +const ( + SourceCustom TransactionSource = "custom" + SourceURL TransactionSource = "url" + SourceRoute TransactionSource = "route" + SourceView TransactionSource = "view" + SourceComponent TransactionSource = "component" + SourceTask TransactionSource = "task" +) + // SpanStatus is the status of a span. type SpanStatus uint8 @@ -639,13 +670,20 @@ func OpName(name string) SpanOption { } } +// TransctionSource sets the source of the transaction name. +func TransctionSource(source TransactionSource) SpanOption { + return func(s *Span) { + s.Source = source + } +} + // ContinueFromRequest returns a span option that updates the span to continue // an existing trace. If it cannot detect an existing trace in the request, the // span will be left unchanged. // // ContinueFromRequest is an alias for: // -// ContinueFromHeaders(r.Header.Get("sentry-trace"), r.Header.Get("baggage")) +// ContinueFromHeaders(r.Header.Get("sentry-trace"), r.Header.Get("baggage")). func ContinueFromRequest(r *http.Request) SpanOption { return ContinueFromHeaders(r.Header.Get("sentry-trace"), r.Header.Get("baggage")) }