composer: glitchtip integration

This commit is contained in:
Diaa Sami 2024-01-18 20:22:36 +01:00 committed by Ondřej Budai
parent 95b4979d88
commit c9c51613a4
63 changed files with 8857 additions and 220 deletions

View file

@ -0,0 +1,79 @@
package debug
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"net/http/httputil"
)
// Transport implements http.RoundTripper and can be used to wrap other HTTP
// transports for debugging, normally http.DefaultTransport.
type Transport struct {
http.RoundTripper
Output io.Writer
// Dump controls whether to dump HTTP request and responses.
Dump bool
// Trace enables usage of net/http/httptrace.
Trace bool
}
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
var buf bytes.Buffer
if t.Dump {
b, err := httputil.DumpRequestOut(req, true)
if err != nil {
panic(err)
}
_, err = buf.Write(ensureTrailingNewline(b))
if err != nil {
panic(err)
}
}
if t.Trace {
trace := &httptrace.ClientTrace{
DNSDone: func(di httptrace.DNSDoneInfo) {
fmt.Fprintf(&buf, "* DNS %v → %v\n", req.Host, di.Addrs)
},
GotConn: func(ci httptrace.GotConnInfo) {
fmt.Fprintf(&buf, "* Connection local=%v remote=%v", ci.Conn.LocalAddr(), ci.Conn.RemoteAddr())
if ci.Reused {
fmt.Fprint(&buf, " (reused)")
}
if ci.WasIdle {
fmt.Fprintf(&buf, " (idle %v)", ci.IdleTime)
}
fmt.Fprintln(&buf)
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
}
resp, err := t.RoundTripper.RoundTrip(req)
if err != nil {
return nil, err
}
if t.Dump {
b, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
_, err = buf.Write(ensureTrailingNewline(b))
if err != nil {
panic(err)
}
}
_, err = io.Copy(t.Output, &buf)
if err != nil {
panic(err)
}
return resp, nil
}
func ensureTrailingNewline(b []byte) []byte {
if len(b) > 0 && b[len(b)-1] != '\n' {
b = append(b, '\n')
}
return b
}

View file

@ -0,0 +1,12 @@
## Why do we have this "otel/baggage" folder?
The root sentry-go SDK (namely, the Dynamic Sampling functionality) needs an implementation of the [baggage spec](https://www.w3.org/TR/baggage/).
For that reason, we've taken the existing baggage implementation from the [opentelemetry-go](https://github.com/open-telemetry/opentelemetry-go/) repository, and fixed a few things that in our opinion were violating the specification.
These issues are:
1. Baggage string value `one%20two` should be properly parsed as "one two"
1. Baggage string value `one+two` should be parsed as "one+two"
1. Go string value "one two" should be encoded as `one%20two` (percent encoding), and NOT as `one+two` (URL query encoding).
1. Go string value "1=1" might be encoded as `1=1`, because the spec says: "Note, value MAY contain any number of the equal sign (=) characters. Parsers MUST NOT assume that the equal sign is only used to separate key and value.". `1%3D1` is also valid, but to simplify the implementation we're not doing it.
Changes were made in this PR: https://github.com/getsentry/sentry-go/pull/568

View file

@ -0,0 +1,604 @@
// Adapted 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"
"unicode/utf8"
"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
}
//// NOTE(anton): I don't think we need to unescape here
// decodedValue, err := url.PathUnescape(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])
value = strings.TrimSpace(kv[1])
var err error
if !keyRe.MatchString(key) {
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidKey, key)
}
if !valueRe.MatchString(value) {
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value)
}
decodedValue, err := url.PathUnescape(value)
if err != nil {
return newInvalidMember(), fmt.Errorf("%w: %q", err, value)
}
value = decodedValue
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)
}
//// NOTE(anton): IMO it's too early to validate the value here.
// 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, percentEncodeValue(m.value))
if len(m.properties) > 0 {
s = fmt.Sprintf("%s%s%s", s, propertyDelimiter, m.properties.String())
}
return s
}
// percentEncodeValue encodes the baggage value, using percent-encoding for
// disallowed octets.
func percentEncodeValue(s string) string {
const upperhex = "0123456789ABCDEF"
var sb strings.Builder
for byteIndex, width := 0, 0; byteIndex < len(s); byteIndex += width {
runeValue, w := utf8.DecodeRuneInString(s[byteIndex:])
width = w
char := string(runeValue)
if valueRe.MatchString(char) && char != "%" {
// The character is returned as is, no need to percent-encode
sb.WriteString(char)
} else {
// We need to percent-encode each byte of the multi-octet character
for j := 0; j < width; j++ {
b := s[byteIndex+j]
sb.WriteByte('%')
// Bitwise operations are inspired by "net/url"
sb.WriteByte(upperhex[b>>4])
sb.WriteByte(upperhex[b&15])
}
}
}
return sb.String()
}
// 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)
}

View file

@ -0,0 +1,45 @@
// Adapted 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
}

View file

@ -0,0 +1,46 @@
package ratelimit
import (
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Reference:
// https://github.com/getsentry/relay/blob/0424a2e017d193a93918053c90cdae9472d164bf/relay-common/src/constants.rs#L116-L127
// Category classifies supported payload types that can be ingested by Sentry
// and, therefore, rate limited.
type Category string
// Known rate limit categories. As a special case, the CategoryAll applies to
// all known payload types.
const (
CategoryAll Category = ""
CategoryError Category = "error"
CategoryTransaction Category = "transaction"
)
// knownCategories is the set of currently known categories. Other categories
// are ignored for the purpose of rate-limiting.
var knownCategories = map[Category]struct{}{
CategoryAll: {},
CategoryError: {},
CategoryTransaction: {},
}
// String returns the category formatted for debugging.
func (c Category) String() string {
switch c {
case "":
return "CategoryAll"
default:
caser := cases.Title(language.English)
rv := "Category"
for _, w := range strings.Fields(string(c)) {
rv += caser.String(w)
}
return rv
}
}

View file

@ -0,0 +1,22 @@
package ratelimit
import "time"
// A Deadline is a time instant when a rate limit expires.
type Deadline time.Time
// After reports whether the deadline d is after other.
func (d Deadline) After(other Deadline) bool {
return time.Time(d).After(time.Time(other))
}
// Equal reports whether d and e represent the same deadline.
func (d Deadline) Equal(e Deadline) bool {
return time.Time(d).Equal(time.Time(e))
}
// String returns the deadline formatted for debugging.
func (d Deadline) String() string {
// Like time.Time.String, but without the monotonic clock reading.
return time.Time(d).Round(0).String()
}

View file

@ -0,0 +1,3 @@
// Package ratelimit provides tools to work with rate limits imposed by Sentry's
// data ingestion pipeline.
package ratelimit

View file

@ -0,0 +1,64 @@
package ratelimit
import (
"net/http"
"time"
)
// Map maps categories to rate limit deadlines.
//
// A rate limit is in effect for a given category if either the category's
// deadline or the deadline for the special CategoryAll has not yet expired.
//
// Use IsRateLimited to check whether a category is rate-limited.
type Map map[Category]Deadline
// IsRateLimited returns true if the category is currently rate limited.
func (m Map) IsRateLimited(c Category) bool {
return m.isRateLimited(c, time.Now())
}
func (m Map) isRateLimited(c Category, now time.Time) bool {
return m.Deadline(c).After(Deadline(now))
}
// Deadline returns the deadline when the rate limit for the given category or
// the special CategoryAll expire, whichever is furthest into the future.
func (m Map) Deadline(c Category) Deadline {
categoryDeadline := m[c]
allDeadline := m[CategoryAll]
if categoryDeadline.After(allDeadline) {
return categoryDeadline
}
return allDeadline
}
// Merge merges the other map into m.
//
// If a category appears in both maps, the deadline that is furthest into the
// future is preserved.
func (m Map) Merge(other Map) {
for c, d := range other {
if d.After(m[c]) {
m[c] = d
}
}
}
// FromResponse returns a rate limit map from an HTTP response.
func FromResponse(r *http.Response) Map {
return fromResponse(r, time.Now())
}
func fromResponse(r *http.Response, now time.Time) Map {
s := r.Header.Get("X-Sentry-Rate-Limits")
if s != "" {
return parseXSentryRateLimits(s, now)
}
if r.StatusCode == http.StatusTooManyRequests {
s := r.Header.Get("Retry-After")
deadline, _ := parseRetryAfter(s, now)
return Map{CategoryAll: deadline}
}
return Map{}
}

View file

@ -0,0 +1,76 @@
package ratelimit
import (
"errors"
"math"
"strconv"
"strings"
"time"
)
var errInvalidXSRLRetryAfter = errors.New("invalid retry-after value")
// parseXSentryRateLimits returns a RateLimits map by parsing an input string in
// the format of the X-Sentry-Rate-Limits header.
//
// Example
//
// X-Sentry-Rate-Limits: 60:transaction, 2700:default;error;security
//
// This will rate limit transactions for the next 60 seconds and errors for the
// next 2700 seconds.
//
// Limits for unknown categories are ignored.
func parseXSentryRateLimits(s string, now time.Time) Map {
// https://github.com/getsentry/relay/blob/0424a2e017d193a93918053c90cdae9472d164bf/relay-server/src/utils/rate_limits.rs#L44-L82
m := make(Map, len(knownCategories))
for _, limit := range strings.Split(s, ",") {
limit = strings.TrimSpace(limit)
if limit == "" {
continue
}
components := strings.Split(limit, ":")
if len(components) == 0 {
continue
}
retryAfter, err := parseXSRLRetryAfter(strings.TrimSpace(components[0]), now)
if err != nil {
continue
}
categories := ""
if len(components) > 1 {
categories = components[1]
}
for _, category := range strings.Split(categories, ";") {
c := Category(strings.ToLower(strings.TrimSpace(category)))
if _, ok := knownCategories[c]; !ok {
// skip unknown categories, keep m small
continue
}
// always keep the deadline furthest into the future
if retryAfter.After(m[c]) {
m[c] = retryAfter
}
}
}
return m
}
// parseXSRLRetryAfter parses a string into a retry-after rate limit deadline.
//
// Valid input is a number, possibly signed and possibly floating-point,
// indicating the number of seconds to wait before sending another request.
// Negative values are treated as zero. Fractional values are rounded to the
// next integer.
func parseXSRLRetryAfter(s string, now time.Time) (Deadline, error) {
// https://github.com/getsentry/relay/blob/0424a2e017d193a93918053c90cdae9472d164bf/relay-quotas/src/rate_limit.rs#L88-L96
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return Deadline{}, errInvalidXSRLRetryAfter
}
d := time.Duration(math.Ceil(math.Max(f, 0.0))) * time.Second
if d < 0 {
d = 0
}
return Deadline(now.Add(d)), nil
}

View file

@ -0,0 +1,40 @@
package ratelimit
import (
"errors"
"strconv"
"time"
)
const defaultRetryAfter = 1 * time.Minute
var errInvalidRetryAfter = errors.New("invalid input")
// parseRetryAfter parses a string s as in the standard Retry-After HTTP header
// and returns a deadline until when requests are rate limited and therefore new
// requests should not be sent. The input may be either a date or a non-negative
// integer number of seconds.
//
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
//
// parseRetryAfter always returns a usable deadline, even in case of an error.
//
// This is the original rate limiting mechanism used by Sentry, superseeded by
// the X-Sentry-Rate-Limits response header.
func parseRetryAfter(s string, now time.Time) (Deadline, error) {
if s == "" {
goto invalid
}
if n, err := strconv.Atoi(s); err == nil {
if n < 0 {
goto invalid
}
d := time.Duration(n) * time.Second
return Deadline(now.Add(d)), nil
}
if date, err := time.Parse(time.RFC1123, s); err == nil {
return Deadline(date), nil
}
invalid:
return Deadline(now.Add(defaultRetryAfter)), errInvalidRetryAfter
}

View file

@ -0,0 +1,15 @@
## Benchmark results
```
goos: windows
goarch: amd64
pkg: github.com/getsentry/sentry-go/internal/trace
cpu: 12th Gen Intel(R) Core(TM) i7-12700K
BenchmarkEqualBytes-20 44323621 26.08 ns/op
BenchmarkStringEqual-20 60980257 18.27 ns/op
BenchmarkEqualPrefix-20 41369181 31.12 ns/op
BenchmarkFullParse-20 702012 1507 ns/op 1353.42 MB/s 1024 B/op 6 allocs/op
BenchmarkFramesIterator-20 1229971 969.3 ns/op 896 B/op 5 allocs/op
BenchmarkFramesReversedIterator-20 1271061 944.5 ns/op 896 B/op 5 allocs/op
BenchmarkSplitOnly-20 2250800 534.0 ns/op 3818.23 MB/s 128 B/op 1 allocs/op
```

View file

@ -0,0 +1,217 @@
package traceparser
import (
"bytes"
"strconv"
)
var blockSeparator = []byte("\n\n")
var lineSeparator = []byte("\n")
// Parses multi-stacktrace text dump produced by runtime.Stack([]byte, all=true).
// The parser prioritizes performance but requires the input to be well-formed in order to return correct data.
// See https://github.com/golang/go/blob/go1.20.4/src/runtime/mprof.go#L1191
func Parse(data []byte) TraceCollection {
var it = TraceCollection{}
if len(data) > 0 {
it.blocks = bytes.Split(data, blockSeparator)
}
return it
}
type TraceCollection struct {
blocks [][]byte
}
func (it TraceCollection) Length() int {
return len(it.blocks)
}
// Returns the stacktrace item at the given index.
func (it *TraceCollection) Item(i int) Trace {
// The first item may have a leading data separator and the last one may have a trailing one.
// Note: Trim() doesn't make a copy for single-character cutset under 0x80. It will just slice the original.
var data []byte
switch {
case i == 0:
data = bytes.TrimLeft(it.blocks[i], "\n")
case i == len(it.blocks)-1:
data = bytes.TrimRight(it.blocks[i], "\n")
default:
data = it.blocks[i]
}
var splitAt = bytes.IndexByte(data, '\n')
if splitAt < 0 {
return Trace{header: data}
}
return Trace{
header: data[:splitAt],
data: data[splitAt+1:],
}
}
// Trace represents a single stacktrace block, identified by a Goroutine ID and a sequence of Frames.
type Trace struct {
header []byte
data []byte
}
var goroutinePrefix = []byte("goroutine ")
// GoID parses the Goroutine ID from the header.
func (t *Trace) GoID() (id uint64) {
if bytes.HasPrefix(t.header, goroutinePrefix) {
var line = t.header[len(goroutinePrefix):]
var splitAt = bytes.IndexByte(line, ' ')
if splitAt >= 0 {
id, _ = strconv.ParseUint(string(line[:splitAt]), 10, 64)
}
}
return id
}
// UniqueIdentifier can be used as a map key to identify the trace.
func (t *Trace) UniqueIdentifier() []byte {
return t.data
}
func (t *Trace) Frames() FrameIterator {
var lines = bytes.Split(t.data, lineSeparator)
return FrameIterator{lines: lines, i: 0, len: len(lines)}
}
func (t *Trace) FramesReversed() ReverseFrameIterator {
var lines = bytes.Split(t.data, lineSeparator)
return ReverseFrameIterator{lines: lines, i: len(lines)}
}
const framesElided = "...additional frames elided..."
// FrameIterator iterates over stack frames.
type FrameIterator struct {
lines [][]byte
i int
len int
}
// Next returns the next frame, or nil if there are none.
func (it *FrameIterator) Next() Frame {
return Frame{it.popLine(), it.popLine()}
}
func (it *FrameIterator) popLine() []byte {
switch {
case it.i >= it.len:
return nil
case string(it.lines[it.i]) == framesElided:
it.i++
return it.popLine()
default:
it.i++
return it.lines[it.i-1]
}
}
// HasNext return true if there are values to be read.
func (it *FrameIterator) HasNext() bool {
return it.i < it.len
}
// LengthUpperBound returns the maximum number of elements this stacks may contain.
// The actual number may be lower because of elided frames. As such, the returned value
// cannot be used to iterate over the frames but may be used to reserve capacity.
func (it *FrameIterator) LengthUpperBound() int {
return it.len / 2
}
// ReverseFrameIterator iterates over stack frames in reverse order.
type ReverseFrameIterator struct {
lines [][]byte
i int
}
// Next returns the next frame, or nil if there are none.
func (it *ReverseFrameIterator) Next() Frame {
var line2 = it.popLine()
return Frame{it.popLine(), line2}
}
func (it *ReverseFrameIterator) popLine() []byte {
it.i--
switch {
case it.i < 0:
return nil
case string(it.lines[it.i]) == framesElided:
return it.popLine()
default:
return it.lines[it.i]
}
}
// HasNext return true if there are values to be read.
func (it *ReverseFrameIterator) HasNext() bool {
return it.i > 1
}
// LengthUpperBound returns the maximum number of elements this stacks may contain.
// The actual number may be lower because of elided frames. As such, the returned value
// cannot be used to iterate over the frames but may be used to reserve capacity.
func (it *ReverseFrameIterator) LengthUpperBound() int {
return len(it.lines) / 2
}
type Frame struct {
line1 []byte
line2 []byte
}
// UniqueIdentifier can be used as a map key to identify the frame.
func (f *Frame) UniqueIdentifier() []byte {
// line2 contains file path, line number and program-counter offset from the beginning of a function
// e.g. C:/Users/name/scoop/apps/go/current/src/testing/testing.go:1906 +0x63a
return f.line2
}
var createdByPrefix = []byte("created by ")
func (f *Frame) Func() []byte {
if bytes.HasPrefix(f.line1, createdByPrefix) {
// Since go1.21, the line ends with " in goroutine X", saying which goroutine created this one.
// We currently don't have use for that so just remove it.
var line = f.line1[len(createdByPrefix):]
var spaceAt = bytes.IndexByte(line, ' ')
if spaceAt < 0 {
return line
}
return line[:spaceAt]
}
var end = bytes.LastIndexByte(f.line1, '(')
if end >= 0 {
return f.line1[:end]
}
return f.line1
}
func (f *Frame) File() (path []byte, lineNumber int) {
var line = f.line2
if len(line) > 0 && line[0] == '\t' {
line = line[1:]
}
var splitAt = bytes.IndexByte(line, ' ')
if splitAt >= 0 {
line = line[:splitAt]
}
splitAt = bytes.LastIndexByte(line, ':')
if splitAt < 0 {
return line, 0
}
lineNumber, _ = strconv.Atoi(string(line[splitAt+1:]))
return line[:splitAt], lineNumber
}