diff --git a/.craft.yml b/.craft.yml index 5f786f52..4af175ad 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,6 +1,5 @@ minVersion: 0.23.1 -preReleaseCommand: bash scripts/craft-pre-release.sh -changelogPolicy: auto +changelogPolicy: simple artifactProvider: name: none targets: diff --git a/client.go b/client.go index 9f615b12..2c580b03 100644 --- a/client.go +++ b/client.go @@ -621,12 +621,12 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod event.Platform = "go" event.Sdk = SdkInfo{ - Name: "sentry.go", - Version: Version, + Name: SDKIdentifier, + Version: SDKVersion, Integrations: client.listIntegrations(), Packages: []SdkPackage{{ Name: "sentry-go", - Version: Version, + Version: SDKVersion, }}, } diff --git a/client_test.go b/client_test.go index 88a98824..d7d54d3a 100644 --- a/client_test.go +++ b/client_test.go @@ -78,11 +78,15 @@ func TestCaptureMessageEmptyString(t *testing.T) { }, } got := transport.lastEvent - opts := cmp.Transformer("SimplifiedEvent", func(e *Event) *Event { - return &Event{ - Exception: e.Exception, - } - }) + opts := cmp.Options{ + cmpopts.IgnoreFields(Event{}, "sdkMetaData"), + cmp.Transformer("SimplifiedEvent", func(e *Event) *Event { + return &Event{ + Exception: e.Exception, + } + }), + } + if diff := cmp.Diff(want, got, opts); diff != "" { t.Errorf("(-want +got):\n%s", diff) } @@ -266,14 +270,14 @@ func TestCaptureEvent(t *testing.T) { Platform: "go", Sdk: SdkInfo{ Name: "sentry.go", - Version: Version, + Version: SDKVersion, Integrations: []string{}, Packages: []SdkPackage{ { // FIXME: name format doesn't follow spec in // https://docs.sentry.io/development/sdk-dev/event-payloads/sdk/ Name: "sentry-go", - Version: Version, + Version: SDKVersion, }, // TODO: perhaps the list of packages is incomplete or there // should not be any package at all. We may include references @@ -282,7 +286,7 @@ func TestCaptureEvent(t *testing.T) { }, } got := transport.lastEvent - opts := cmp.Options{cmpopts.IgnoreFields(Event{}, "Release")} + opts := cmp.Options{cmpopts.IgnoreFields(Event{}, "Release", "sdkMetaData")} if diff := cmp.Diff(want, got, opts); diff != "" { t.Errorf("Event mismatch (-want +got):\n%s", diff) } @@ -309,11 +313,14 @@ func TestCaptureEventNil(t *testing.T) { }, } got := transport.lastEvent - opts := cmp.Transformer("SimplifiedEvent", func(e *Event) *Event { - return &Event{ - Exception: e.Exception, - } - }) + opts := cmp.Options{ + cmpopts.IgnoreFields(Event{}, "sdkMetaData"), + cmp.Transformer("SimplifiedEvent", func(e *Event) *Event { + return &Event{ + Exception: e.Exception, + } + }), + } if diff := cmp.Diff(want, got, opts); diff != "" { t.Errorf("(-want +got):\n%s", diff) } @@ -476,13 +483,17 @@ func TestRecover(t *testing.T) { t.Fatalf("events = %s\ngot %d events, want 1", b, len(events)) } got := events[0] - opts := cmp.Transformer("SimplifiedEvent", func(e *Event) *Event { - return &Event{ - Message: e.Message, - Exception: e.Exception, - Level: e.Level, - } - }) + opts := cmp.Options{ + cmpopts.IgnoreFields(Event{}, "sdkMetaData"), + cmp.Transformer("SimplifiedEvent", func(e *Event) *Event { + return &Event{ + Message: e.Message, + Exception: e.Exception, + Level: e.Level, + } + }), + } + if diff := cmp.Diff(want, got, opts); diff != "" { t.Errorf("(-want +got):\n%s", diff) } diff --git a/dynamic_sampling_context.go b/dynamic_sampling_context.go new file mode 100644 index 00000000..3e54839b --- /dev/null +++ b/dynamic_sampling_context.go @@ -0,0 +1,110 @@ +package sentry + +import ( + "strconv" + "strings" + + "github.com/getsentry/sentry-go/internal/otel/baggage" +) + +const ( + sentryPrefix = "sentry-" +) + +// DynamicSamplingContext holds information about the current event that can be used to make dynamic sampling decisions. +type DynamicSamplingContext struct { + Entries map[string]string + Frozen bool +} + +func DynamicSamplingContextFromHeader(header []byte) (DynamicSamplingContext, error) { + bag, err := baggage.Parse(string(header)) + if err != nil { + return DynamicSamplingContext{}, err + } + + entries := map[string]string{} + for _, member := range bag.Members() { + // We only store baggage members if their key starts with "sentry-". + if k, v := member.Key(), member.Value(); strings.HasPrefix(k, sentryPrefix) { + entries[strings.TrimPrefix(k, sentryPrefix)] = v + } + } + + return DynamicSamplingContext{ + Entries: entries, + Frozen: true, + }, 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 { + members := []baggage.Member{} + for k, entry := range d.Entries { + member, err := baggage.NewMember(sentryPrefix+k, entry) + if err != nil { + continue + } + members = append(members, member) + } + if len(members) > 0 { + baggage, err := baggage.New(members...) + if err != nil { + return "" + } + return baggage.String() + } + + return "" +} diff --git a/dynamic_sampling_context_test.go b/dynamic_sampling_context_test.go new file mode 100644 index 00000000..6cca5659 --- /dev/null +++ b/dynamic_sampling_context_test.go @@ -0,0 +1,150 @@ +package sentry + +import ( + "strings" + "testing" +) + +func TestDynamicSamplingContextFromHeader(t *testing.T) { + tests := []struct { + input []byte + want DynamicSamplingContext + }{ + { + input: []byte(""), + want: DynamicSamplingContext{ + Frozen: true, + Entries: map[string]string{}, + }, + }, + { + input: []byte("sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03,sentry-public_key=public,sentry-sample_rate=1"), + want: DynamicSamplingContext{ + Frozen: true, + Entries: map[string]string{ + "trace_id": "d49d9bf66f13450b81f65bc51cf49c03", + "public_key": "public", + "sample_rate": "1", + }, + }, + }, + { + input: []byte("sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03,sentry-public_key=public,sentry-sample_rate=1,foo=bar;foo;bar;bar=baz"), + want: DynamicSamplingContext{ + Frozen: true, + Entries: map[string]string{ + "trace_id": "d49d9bf66f13450b81f65bc51cf49c03", + "public_key": "public", + "sample_rate": "1", + }, + }, + }, + } + + for _, tc := range tests { + 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", TransctionSource(SourceURL)) + 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) +} + +func TestString(t *testing.T) { + var dsc DynamicSamplingContext + + dsc = DynamicSamplingContext{} + assertEqual(t, dsc.String(), "") + + dsc = DynamicSamplingContext{ + Frozen: true, + Entries: map[string]string{ + "trace_id": "d49d9bf66f13450b81f65bc51cf49c03", + "public_key": "public", + "sample_rate": "1", + }, + } + assertEqual(t, strings.Contains(dsc.String(), "sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03"), true) + assertEqual(t, strings.Contains(dsc.String(), "sentry-public_key=public"), true) + assertEqual(t, strings.Contains(dsc.String(), "sentry-sample_rate=1"), true) +} diff --git a/fasthttp/sentryfasthttp_test.go b/fasthttp/sentryfasthttp_test.go index 0c718636..3ee8b666 100644 --- a/fasthttp/sentryfasthttp_test.go +++ b/fasthttp/sentryfasthttp_test.go @@ -208,6 +208,7 @@ func TestIntegration(t *testing.T) { sentry.Event{}, "Contexts", "EventID", "Extra", "Platform", "Modules", "Release", "Sdk", "ServerName", "Tags", "Timestamp", + "sdkMetaData", ), cmpopts.IgnoreMapEntries(func(k string, v string) bool { // fasthttp changed Content-Length behavior in 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/http/sentryhttp_test.go b/http/sentryhttp_test.go index 9b8efb54..2d97ce8b 100644 --- a/http/sentryhttp_test.go +++ b/http/sentryhttp_test.go @@ -216,6 +216,7 @@ func TestIntegration(t *testing.T) { sentry.Event{}, "Contexts", "EventID", "Extra", "Platform", "Modules", "Release", "Sdk", "ServerName", "Tags", "Timestamp", + "sdkMetaData", ), cmpopts.IgnoreFields( sentry.Request{}, diff --git a/interfaces.go b/interfaces.go index 6f792220..0d20ba39 100644 --- a/interfaces.go +++ b/interfaces.go @@ -215,6 +215,17 @@ type Exception struct { Stacktrace *Stacktrace `json:"stacktrace,omitempty"` } +// 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 +} + +// Contains information about how the name of the transaction was determined. +type TransactionInfo struct { + Source TransactionSource `json:"source,omitempty"` +} + // EventID is a hexadecimal string representing a unique uuid4 for an Event. // An EventID must be 32 characters long, lowercase and not have any dashes. type EventID string @@ -248,9 +259,14 @@ type Event struct { // The fields below are only relevant for transactions. - Type string `json:"type,omitempty"` - StartTime time.Time `json:"start_timestamp"` - Spans []*Span `json:"spans,omitempty"` + Type string `json:"type,omitempty"` + StartTime time.Time `json:"start_timestamp"` + Spans []*Span `json:"spans,omitempty"` + TransactionInfo *TransactionInfo `json:"transaction_info,omitempty"` + + // The fields below are not part of the final JSON payload. + + sdkMetaData SDKMetaData } // TODO: Event.Contexts map[string]interface{} => map[string]EventContext, @@ -292,9 +308,10 @@ func (e *Event) defaultMarshalJSON() ([]byte, error) { // be sent for transactions. They shadow the respective fields in Event // and are meant to remain nil, triggering the omitempty behavior. - Type json.RawMessage `json:"type,omitempty"` - StartTime json.RawMessage `json:"start_timestamp,omitempty"` - Spans json.RawMessage `json:"spans,omitempty"` + Type json.RawMessage `json:"type,omitempty"` + StartTime json.RawMessage `json:"start_timestamp,omitempty"` + Spans json.RawMessage `json:"spans,omitempty"` + TransactionInfo json.RawMessage `json:"transaction_info,omitempty"` } x := errorEvent{event: (*event)(e)} diff --git a/internal/otel/baggage/baggage.go b/internal/otel/baggage/baggage.go new file mode 100644 index 00000000..16e8ed0c --- /dev/null +++ b/internal/otel/baggage/baggage.go @@ -0,0 +1,573 @@ +// This file was vendored in unmodified from +// https://github.com/open-telemetry/opentelemetry-go/blob/c21b6b6bb31a2f74edd06e262f1690f3f6ea3d5c/baggage/baggage.go +// +// # Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package baggage + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/getsentry/sentry-go/internal/otel/baggage/internal/baggage" +) + +const ( + maxMembers = 180 + maxBytesPerMembers = 4096 + maxBytesPerBaggageString = 8192 + + listDelimiter = "," + keyValueDelimiter = "=" + propertyDelimiter = ";" + + keyDef = `([\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5a\x5e-\x7a\x7c\x7e]+)` + valueDef = `([\x21\x23-\x2b\x2d-\x3a\x3c-\x5B\x5D-\x7e]*)` + keyValueDef = `\s*` + keyDef + `\s*` + keyValueDelimiter + `\s*` + valueDef + `\s*` +) + +var ( + keyRe = regexp.MustCompile(`^` + keyDef + `$`) + valueRe = regexp.MustCompile(`^` + valueDef + `$`) + propertyRe = regexp.MustCompile(`^(?:\s*` + keyDef + `\s*|` + keyValueDef + `)$`) +) + +var ( + errInvalidKey = errors.New("invalid key") + errInvalidValue = errors.New("invalid value") + errInvalidProperty = errors.New("invalid baggage list-member property") + errInvalidMember = errors.New("invalid baggage list-member") + errMemberNumber = errors.New("too many list-members in baggage-string") + errMemberBytes = errors.New("list-member too large") + errBaggageBytes = errors.New("baggage-string too large") +) + +// Property is an additional metadata entry for a baggage list-member. +type Property struct { + key, value string + + // hasValue indicates if a zero-value value means the property does not + // have a value or if it was the zero-value. + hasValue bool + + // hasData indicates whether the created property contains data or not. + // Properties that do not contain data are invalid with no other check + // required. + hasData bool +} + +// NewKeyProperty returns a new Property for key. +// +// If key is invalid, an error will be returned. +func NewKeyProperty(key string) (Property, error) { + if !keyRe.MatchString(key) { + return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidKey, key) + } + + p := Property{key: key, hasData: true} + return p, nil +} + +// NewKeyValueProperty returns a new Property for key with value. +// +// If key or value are invalid, an error will be returned. +func NewKeyValueProperty(key, value string) (Property, error) { + if !keyRe.MatchString(key) { + return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidKey, key) + } + if !valueRe.MatchString(value) { + return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidValue, value) + } + + p := Property{ + key: key, + value: value, + hasValue: true, + hasData: true, + } + return p, nil +} + +func newInvalidProperty() Property { + return Property{} +} + +// parseProperty attempts to decode a Property from the passed string. It +// returns an error if the input is invalid according to the W3C Baggage +// specification. +func parseProperty(property string) (Property, error) { + if property == "" { + return newInvalidProperty(), nil + } + + match := propertyRe.FindStringSubmatch(property) + if len(match) != 4 { + return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidProperty, property) + } + + p := Property{hasData: true} + if match[1] != "" { + p.key = match[1] + } else { + p.key = match[2] + p.value = match[3] + p.hasValue = true + } + + return p, nil +} + +// validate ensures p conforms to the W3C Baggage specification, returning an +// error otherwise. +func (p Property) validate() error { + errFunc := func(err error) error { + return fmt.Errorf("invalid property: %w", err) + } + + if !p.hasData { + return errFunc(fmt.Errorf("%w: %q", errInvalidProperty, p)) + } + + if !keyRe.MatchString(p.key) { + return errFunc(fmt.Errorf("%w: %q", errInvalidKey, p.key)) + } + if p.hasValue && !valueRe.MatchString(p.value) { + return errFunc(fmt.Errorf("%w: %q", errInvalidValue, p.value)) + } + if !p.hasValue && p.value != "" { + return errFunc(errors.New("inconsistent value")) + } + return nil +} + +// Key returns the Property key. +func (p Property) Key() string { + return p.key +} + +// Value returns the Property value. Additionally, a boolean value is returned +// indicating if the returned value is the empty if the Property has a value +// that is empty or if the value is not set. +func (p Property) Value() (string, bool) { + return p.value, p.hasValue +} + +// String encodes Property into a string compliant with the W3C Baggage +// specification. +func (p Property) String() string { + if p.hasValue { + return fmt.Sprintf("%s%s%v", p.key, keyValueDelimiter, p.value) + } + return p.key +} + +type properties []Property + +func fromInternalProperties(iProps []baggage.Property) properties { + if len(iProps) == 0 { + return nil + } + + props := make(properties, len(iProps)) + for i, p := range iProps { + props[i] = Property{ + key: p.Key, + value: p.Value, + hasValue: p.HasValue, + } + } + return props +} + +func (p properties) asInternal() []baggage.Property { + if len(p) == 0 { + return nil + } + + iProps := make([]baggage.Property, len(p)) + for i, prop := range p { + iProps[i] = baggage.Property{ + Key: prop.key, + Value: prop.value, + HasValue: prop.hasValue, + } + } + return iProps +} + +func (p properties) Copy() properties { + if len(p) == 0 { + return nil + } + + props := make(properties, len(p)) + copy(props, p) + return props +} + +// validate ensures each Property in p conforms to the W3C Baggage +// specification, returning an error otherwise. +func (p properties) validate() error { + for _, prop := range p { + if err := prop.validate(); err != nil { + return err + } + } + return nil +} + +// String encodes properties into a string compliant with the W3C Baggage +// specification. +func (p properties) String() string { + props := make([]string, len(p)) + for i, prop := range p { + props[i] = prop.String() + } + return strings.Join(props, propertyDelimiter) +} + +// Member is a list-member of a baggage-string as defined by the W3C Baggage +// specification. +type Member struct { + key, value string + properties properties + + // hasData indicates whether the created property contains data or not. + // Properties that do not contain data are invalid with no other check + // required. + hasData bool +} + +// NewMember returns a new Member from the passed arguments. The key will be +// used directly while the value will be url decoded after validation. An error +// is returned if the created Member would be invalid according to the W3C +// Baggage specification. +func NewMember(key, value string, props ...Property) (Member, error) { + m := Member{ + key: key, + value: value, + properties: properties(props).Copy(), + hasData: true, + } + if err := m.validate(); err != nil { + return newInvalidMember(), err + } + decodedValue, err := url.QueryUnescape(value) + if err != nil { + return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value) + } + m.value = decodedValue + return m, nil +} + +func newInvalidMember() Member { + return Member{} +} + +// parseMember attempts to decode a Member from the passed string. It returns +// an error if the input is invalid according to the W3C Baggage +// specification. +func parseMember(member string) (Member, error) { + if n := len(member); n > maxBytesPerMembers { + return newInvalidMember(), fmt.Errorf("%w: %d", errMemberBytes, n) + } + + var ( + key, value string + props properties + ) + + parts := strings.SplitN(member, propertyDelimiter, 2) + switch len(parts) { + case 2: + // Parse the member properties. + for _, pStr := range strings.Split(parts[1], propertyDelimiter) { + p, err := parseProperty(pStr) + if err != nil { + return newInvalidMember(), err + } + props = append(props, p) + } + fallthrough + case 1: + // Parse the member key/value pair. + + // Take into account a value can contain equal signs (=). + kv := strings.SplitN(parts[0], keyValueDelimiter, 2) + if len(kv) != 2 { + return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidMember, member) + } + // "Leading and trailing whitespaces are allowed but MUST be trimmed + // when converting the header into a data structure." + key = strings.TrimSpace(kv[0]) + var err error + value, err = url.QueryUnescape(strings.TrimSpace(kv[1])) + if err != nil { + return newInvalidMember(), fmt.Errorf("%w: %q", err, value) + } + if !keyRe.MatchString(key) { + return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidKey, key) + } + if !valueRe.MatchString(value) { + return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value) + } + default: + // This should never happen unless a developer has changed the string + // splitting somehow. Panic instead of failing silently and allowing + // the bug to slip past the CI checks. + panic("failed to parse baggage member") + } + + return Member{key: key, value: value, properties: props, hasData: true}, nil +} + +// validate ensures m conforms to the W3C Baggage specification. +// A key is just an ASCII string, but a value must be URL encoded UTF-8, +// returning an error otherwise. +func (m Member) validate() error { + if !m.hasData { + return fmt.Errorf("%w: %q", errInvalidMember, m) + } + + if !keyRe.MatchString(m.key) { + return fmt.Errorf("%w: %q", errInvalidKey, m.key) + } + if !valueRe.MatchString(m.value) { + return fmt.Errorf("%w: %q", errInvalidValue, m.value) + } + return m.properties.validate() +} + +// Key returns the Member key. +func (m Member) Key() string { return m.key } + +// Value returns the Member value. +func (m Member) Value() string { return m.value } + +// Properties returns a copy of the Member properties. +func (m Member) Properties() []Property { return m.properties.Copy() } + +// String encodes Member into a string compliant with the W3C Baggage +// specification. +func (m Member) String() string { + // A key is just an ASCII string, but a value is URL encoded UTF-8. + s := fmt.Sprintf("%s%s%s", m.key, keyValueDelimiter, url.QueryEscape(m.value)) + if len(m.properties) > 0 { + s = fmt.Sprintf("%s%s%s", s, propertyDelimiter, m.properties.String()) + } + return s +} + +// Baggage is a list of baggage members representing the baggage-string as +// defined by the W3C Baggage specification. +type Baggage struct { //nolint:golint + list baggage.List +} + +// New returns a new valid Baggage. It returns an error if it results in a +// Baggage exceeding limits set in that specification. +// +// It expects all the provided members to have already been validated. +func New(members ...Member) (Baggage, error) { + if len(members) == 0 { + return Baggage{}, nil + } + + b := make(baggage.List) + for _, m := range members { + if !m.hasData { + return Baggage{}, errInvalidMember + } + + // OpenTelemetry resolves duplicates by last-one-wins. + b[m.key] = baggage.Item{ + Value: m.value, + Properties: m.properties.asInternal(), + } + } + + // Check member numbers after deduplication. + if len(b) > maxMembers { + return Baggage{}, errMemberNumber + } + + bag := Baggage{b} + if n := len(bag.String()); n > maxBytesPerBaggageString { + return Baggage{}, fmt.Errorf("%w: %d", errBaggageBytes, n) + } + + return bag, nil +} + +// Parse attempts to decode a baggage-string from the passed string. It +// returns an error if the input is invalid according to the W3C Baggage +// specification. +// +// If there are duplicate list-members contained in baggage, the last one +// defined (reading left-to-right) will be the only one kept. This diverges +// from the W3C Baggage specification which allows duplicate list-members, but +// conforms to the OpenTelemetry Baggage specification. +func Parse(bStr string) (Baggage, error) { + if bStr == "" { + return Baggage{}, nil + } + + if n := len(bStr); n > maxBytesPerBaggageString { + return Baggage{}, fmt.Errorf("%w: %d", errBaggageBytes, n) + } + + b := make(baggage.List) + for _, memberStr := range strings.Split(bStr, listDelimiter) { + m, err := parseMember(memberStr) + if err != nil { + return Baggage{}, err + } + // OpenTelemetry resolves duplicates by last-one-wins. + b[m.key] = baggage.Item{ + Value: m.value, + Properties: m.properties.asInternal(), + } + } + + // OpenTelemetry does not allow for duplicate list-members, but the W3C + // specification does. Now that we have deduplicated, ensure the baggage + // does not exceed list-member limits. + if len(b) > maxMembers { + return Baggage{}, errMemberNumber + } + + return Baggage{b}, nil +} + +// Member returns the baggage list-member identified by key. +// +// If there is no list-member matching the passed key the returned Member will +// be a zero-value Member. +// The returned member is not validated, as we assume the validation happened +// when it was added to the Baggage. +func (b Baggage) Member(key string) Member { + v, ok := b.list[key] + if !ok { + // We do not need to worry about distinguishing between the situation + // where a zero-valued Member is included in the Baggage because a + // zero-valued Member is invalid according to the W3C Baggage + // specification (it has an empty key). + return newInvalidMember() + } + + return Member{ + key: key, + value: v.Value, + properties: fromInternalProperties(v.Properties), + hasData: true, + } +} + +// Members returns all the baggage list-members. +// The order of the returned list-members does not have significance. +// +// The returned members are not validated, as we assume the validation happened +// when they were added to the Baggage. +func (b Baggage) Members() []Member { + if len(b.list) == 0 { + return nil + } + + members := make([]Member, 0, len(b.list)) + for k, v := range b.list { + members = append(members, Member{ + key: k, + value: v.Value, + properties: fromInternalProperties(v.Properties), + hasData: true, + }) + } + return members +} + +// SetMember returns a copy the Baggage with the member included. If the +// baggage contains a Member with the same key the existing Member is +// replaced. +// +// If member is invalid according to the W3C Baggage specification, an error +// is returned with the original Baggage. +func (b Baggage) SetMember(member Member) (Baggage, error) { + if !member.hasData { + return b, errInvalidMember + } + + n := len(b.list) + if _, ok := b.list[member.key]; !ok { + n++ + } + list := make(baggage.List, n) + + for k, v := range b.list { + // Do not copy if we are just going to overwrite. + if k == member.key { + continue + } + list[k] = v + } + + list[member.key] = baggage.Item{ + Value: member.value, + Properties: member.properties.asInternal(), + } + + return Baggage{list: list}, nil +} + +// DeleteMember returns a copy of the Baggage with the list-member identified +// by key removed. +func (b Baggage) DeleteMember(key string) Baggage { + n := len(b.list) + if _, ok := b.list[key]; ok { + n-- + } + list := make(baggage.List, n) + + for k, v := range b.list { + if k == key { + continue + } + list[k] = v + } + + return Baggage{list: list} +} + +// Len returns the number of list-members in the Baggage. +func (b Baggage) Len() int { + return len(b.list) +} + +// String encodes Baggage into a string compliant with the W3C Baggage +// specification. The returned string will be invalid if the Baggage contains +// any invalid list-members. +func (b Baggage) String() string { + members := make([]string, 0, len(b.list)) + for k, v := range b.list { + members = append(members, Member{ + key: k, + value: v.Value, + properties: fromInternalProperties(v.Properties), + }.String()) + } + return strings.Join(members, listDelimiter) +} diff --git a/internal/otel/baggage/internal/baggage/baggage.go b/internal/otel/baggage/internal/baggage/baggage.go new file mode 100644 index 00000000..04e41402 --- /dev/null +++ b/internal/otel/baggage/internal/baggage/baggage.go @@ -0,0 +1,46 @@ +// This file was vendored in unmodified from +// https://github.com/open-telemetry/opentelemetry-go/blob/c21b6b6bb31a2f74edd06e262f1690f3f6ea3d5c/internal/baggage/baggage.go +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package baggage provides base types and functionality to store and retrieve +baggage in Go context. This package exists because the OpenTracing bridge to +OpenTelemetry needs to synchronize state whenever baggage for a context is +modified and that context contains an OpenTracing span. If it were not for +this need this package would not need to exist and the +`go.opentelemetry.io/otel/baggage` package would be the singular place where +W3C baggage is handled. +*/ +package baggage + +// List is the collection of baggage members. The W3C allows for duplicates, +// but OpenTelemetry does not, therefore, this is represented as a map. +type List map[string]Item + +// Item is the value and metadata properties part of a list-member. +type Item struct { + Value string + Properties []Property +} + +// Property is a metadata entry for a list-member. +type Property struct { + Key, Value string + + // HasValue indicates if a zero-value value means the property does not + // have a value or if it was the zero-value. + HasValue bool +} diff --git a/logrus/logrusentry_test.go b/logrus/logrusentry_test.go index 5e30e55e..5975ab06 100644 --- a/logrus/logrusentry_test.go +++ b/logrus/logrusentry_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" pkgerr "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -399,7 +400,12 @@ func Test_entry2event(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := h.entryToEvent(tt.entry) - if d := cmp.Diff(tt.want, got); d != "" { + opts := cmp.Options{ + cmpopts.IgnoreFields(sentry.Event{}, + "sdkMetaData", + ), + } + if d := cmp.Diff(tt.want, got, opts); d != "" { t.Error(d) } }) diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 00000000..9f4cb447 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -eux + +if [ "$(uname -s)" != "Linux" ]; then + echo "Please use the GitHub Action." + exit 1 +fi + +SCRIPT_DIR="$( dirname "$0" )" +cd $SCRIPT_DIR/.. + +OLD_VERSION="${1}" +NEW_VERSION="${2}" + +echo "Current version: $OLD_VERSION" +echo "Bumping version: $NEW_VERSION" + +function replace() { + ! grep "$2" $3 + perl -i -pe "s/$1/$2/g" $3 + grep "$2" $3 # verify that replacement was successful +} + +replace "const SDKVersion = \"[\w.-]+\"" "const SDKVersion = \"$NEW_VERSION\"" ./sentry.go diff --git a/scripts/craft-pre-release.sh b/scripts/craft-pre-release.sh deleted file mode 100755 index feb507d1..00000000 --- a/scripts/craft-pre-release.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -eux - -SCRIPT_DIR="$( dirname "$0" )" -cd $SCRIPT_DIR/.. - -function replace() { - ! grep "$2" $3 - perl -i -pe "s/$1/$2/g" $3 - grep "$2" $3 # verify that replacement was successful -} - -if [ "$#" -eq 1 ]; then - OLD_VERSION="" - NEW_VERSION="${1}" -elif [ "$#" -eq 2 ]; then - OLD_VERSION="${1}" - NEW_VERSION="${2}" -else - echo "Illegal number of parameters" - exit 1 -fi - -replace "const Version = \"[\w.-]+\"" "const Version = \"$NEW_VERSION\"" ./sentry.go diff --git a/sentry.go b/sentry.go index ce12c704..96f48c45 100644 --- a/sentry.go +++ b/sentry.go @@ -5,15 +5,21 @@ import ( "time" ) +// Deprecated: Use SDKVersion instead. +const Version = SDKVersion + // Version is the version of the SDK. -const Version = "0.15.0" +const SDKVersion = "0.15.0" + +// The identifier of the SDK. +const SDKIdentifier = "sentry.go" // apiVersion is the minimum version of the Sentry API compatible with the // sentry-go SDK. const apiVersion = "7" // userAgent is the User-Agent of outgoing HTTP requests. -const userAgent = "sentry-go/" + Version +const userAgent = "sentry-go/" + SDKVersion // Init initializes the SDK with options. The returned error is non-nil if // options is invalid, for instance if a malformed DSN is provided. diff --git a/tracing.go b/tracing.go index 94562ccb..314f8b49 100644 --- a/tracing.go +++ b/tracing.go @@ -28,22 +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 } @@ -90,6 +91,9 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp if hasParent { span.TraceID = parent.TraceID } else { + // Only set the Source if this is a transaction + span.Source = SourceCustom + // Implementation note: // // While math/rand is ~2x faster than crypto/rand (exact @@ -138,7 +142,6 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp option(&span) } - // TODO only sample transactions? span.Sampled = span.sample() if hasParent { @@ -228,6 +231,10 @@ func (s *Span) ToSentryTrace() string { return b.String() } +func (s *Span) ToBaggage() string { + return s.dynamicSamplingContext.String() +} + // sentryTracePattern matches either // // TRACE_ID - SPAN_ID @@ -260,6 +267,17 @@ func (s *Span) updateFromSentryTrace(header []byte) { } } +func (s *Span) updateFromBaggage(header []byte) { + if s.isTransaction { + dsc, err := DynamicSamplingContextFromHeader(header) + if err != nil { + return + } + + s.dynamicSamplingContext = dsc + } +} + func (s *Span) MarshalJSON() ([]byte, error) { // span aliases Span to allow calling json.Marshal without an infinite loop. // It preserves all fields while none of the attached methods. @@ -289,12 +307,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 } @@ -311,6 +336,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 @@ -329,11 +355,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 @@ -366,6 +399,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(), @@ -377,6 +416,12 @@ func (s *Span) toEvent() *Event { Timestamp: s.EndTime, StartTime: s.StartTime, Spans: finished, + TransactionInfo: &TransactionInfo{ + Source: s.Source, + }, + sdkMetaData: SDKMetaData{ + dsc: s.dynamicSamplingContext, + }, } } @@ -434,6 +479,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 @@ -614,15 +671,35 @@ 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: // -// ContinueFromTrace(r.Header.Get("sentry-trace")) +// ContinueFromHeaders(r.Header.Get("sentry-trace"), r.Header.Get("baggage")). func ContinueFromRequest(r *http.Request) SpanOption { - return ContinueFromTrace(r.Header.Get("sentry-trace")) + return ContinueFromHeaders(r.Header.Get("sentry-trace"), r.Header.Get("baggage")) +} + +// ContinueFromHeaders returns a span option that updates the span to continue +// an existing TraceID and propagates the Dynamic Sampling context. +func ContinueFromHeaders(trace, baggage string) SpanOption { + return func(s *Span) { + if trace != "" { + s.updateFromSentryTrace([]byte(trace)) + } + if baggage != "" { + s.updateFromBaggage([]byte(baggage)) + } + } } // ContinueFromTrace returns a span option that updates the span to continue diff --git a/tracing_test.go b/tracing_test.go index c95eec57..8902f75d 100644 --- a/tracing_test.go +++ b/tracing_test.go @@ -148,11 +148,15 @@ func TestStartSpan(t *testing.T) { Extra: span.Data, Timestamp: endTime, StartTime: startTime, + TransactionInfo: &TransactionInfo{ + Source: span.Source, + }, } opts := cmp.Options{ cmpopts.IgnoreFields(Event{}, "Contexts", "EventID", "Level", "Platform", "Release", "Sdk", "ServerName", "Modules", + "sdkMetaData", ), cmpopts.EquateEmpty(), } @@ -208,11 +212,15 @@ func TestStartChild(t *testing.T) { Sampled: SampledTrue, }, }, + TransactionInfo: &TransactionInfo{ + Source: span.Source, + }, } opts := cmp.Options{ cmpopts.IgnoreFields(Event{}, "EventID", "Level", "Platform", "Modules", "Release", "Sdk", "ServerName", "Timestamp", "StartTime", + "sdkMetaData", ), cmpopts.IgnoreMapEntries(func(k string, v interface{}) bool { return k != "trace" @@ -283,11 +291,15 @@ func TestStartTransaction(t *testing.T) { Extra: transaction.Data, Timestamp: endTime, StartTime: startTime, + TransactionInfo: &TransactionInfo{ + Source: transaction.Source, + }, } opts := cmp.Options{ cmpopts.IgnoreFields(Event{}, "Contexts", "EventID", "Level", "Platform", "Release", "Sdk", "ServerName", "Modules", + "sdkMetaData", ), cmpopts.EquateEmpty(), } diff --git a/transport.go b/transport.go index d8fa2be5..3722217e 100644 --- a/transport.go +++ b/transport.go @@ -94,21 +94,40 @@ func getRequestBodyFromEvent(event *Event) []byte { return nil } -func transactionEnvelopeFromBody(eventID EventID, sentAt time.Time, body json.RawMessage) (*bytes.Buffer, error) { +func transactionEnvelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMessage) (*bytes.Buffer, error) { var b bytes.Buffer enc := json.NewEncoder(&b) - // envelope header + + // Construct the trace envelope header + var trace = map[string]string{} + if dsc := event.sdkMetaData.dsc; dsc.HasEntries() { + for k, v := range dsc.Entries { + trace[k] = v + } + } + + // Envelope header err := enc.Encode(struct { - EventID EventID `json:"event_id"` - SentAt time.Time `json:"sent_at"` + EventID EventID `json:"event_id"` + SentAt time.Time `json:"sent_at"` + Dsn string `json:"dsn"` + Sdk map[string]string `json:"sdk"` + Trace map[string]string `json:"trace,omitempty"` }{ - EventID: eventID, + EventID: event.EventID, SentAt: sentAt, + Trace: trace, + Dsn: dsn.String(), + Sdk: map[string]string{ + "name": event.Sdk.Name, + "version": event.Sdk.Version, + }, }) if err != nil { return nil, err } - // item header + + // Item header err = enc.Encode(struct { Type string `json:"type"` Length int `json:"length"` @@ -124,6 +143,7 @@ func transactionEnvelopeFromBody(eventID EventID, sentAt time.Time, body json.Ra if err != nil { return nil, err } + return &b, nil } @@ -138,7 +158,7 @@ func getRequestFromEvent(event *Event, dsn *Dsn) (r *http.Request, err error) { return nil, errors.New("event could not be marshaled") } if event.Type == transactionType { - b, err := transactionEnvelopeFromBody(event.EventID, time.Now(), body) + b, err := transactionEnvelopeFromBody(event, dsn, time.Now(), body) if err != nil { return nil, err } diff --git a/transport_test.go b/transport_test.go index 82d2f170..5557c064 100644 --- a/transport_test.go +++ b/transport_test.go @@ -134,14 +134,28 @@ func TestGetRequestBodyFromEventCompletelyInvalid(t *testing.T) { func TestTransactionEnvelopeFromBody(t *testing.T) { const eventID = "b81c5be4d31e48959103a1f878a1efcb" + event := NewEvent() + event.EventID = eventID + event.Sdk = SdkInfo{ + Name: "sentry.go", + Version: "0.0.1", + } + + dsn, err := NewDsn("http://public@example.com/sentry/1") + if err != nil { + t.Fatal(err) + } + sentAt := time.Unix(0, 0).UTC() + body := json.RawMessage(`{"type":"transaction","fields":"omitted"}`) - b, err := transactionEnvelopeFromBody(eventID, sentAt, body) + + b, err := transactionEnvelopeFromBody(event, dsn, sentAt, body) if err != nil { t.Fatal(err) } got := b.String() - want := `{"event_id":"b81c5be4d31e48959103a1f878a1efcb","sent_at":"1970-01-01T00:00:00Z"} + want := `{"event_id":"b81c5be4d31e48959103a1f878a1efcb","sent_at":"1970-01-01T00:00:00Z","dsn":"http://public@example.com/sentry/1","sdk":{"name":"sentry.go","version":"0.0.1"}} {"type":"transaction","length":41} {"type":"transaction","fields":"omitted"} `