fix(go.sum): update ResolveSpec dependency to v1.0.87
This commit is contained in:
+79
@@ -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
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package debuglog
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
// logger is the global debug logger instance.
|
||||
var logger = log.New(io.Discard, "[Sentry] ", log.LstdFlags)
|
||||
|
||||
// SetOutput changes the output destination of the logger.
|
||||
func SetOutput(w io.Writer) {
|
||||
logger.SetOutput(w)
|
||||
}
|
||||
|
||||
// GetLogger returns the current logger instance.
|
||||
// This function is thread-safe and can be called concurrently.
|
||||
func GetLogger() *log.Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
// Printf calls Printf on the underlying logger.
|
||||
func Printf(format string, args ...interface{}) {
|
||||
logger.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Println calls Println on the underlying logger.
|
||||
func Println(args ...interface{}) {
|
||||
logger.Println(args...)
|
||||
}
|
||||
|
||||
// Print calls Print on the underlying logger.
|
||||
func Print(args ...interface{}) {
|
||||
logger.Print(args...)
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/debuglog"
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/internal/util"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
const (
|
||||
apiVersion = 7
|
||||
|
||||
defaultTimeout = time.Second * 30
|
||||
defaultQueueSize = 1000
|
||||
defaultClientReportsTick = time.Second * 30
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTransportQueueFull = errors.New("transport queue full")
|
||||
ErrTransportClosed = errors.New("transport is closed")
|
||||
ErrEmptyEnvelope = errors.New("empty envelope provided")
|
||||
)
|
||||
|
||||
type TransportOptions struct {
|
||||
Dsn string
|
||||
HTTPClient *http.Client
|
||||
HTTPTransport http.RoundTripper
|
||||
HTTPProxy string
|
||||
HTTPSProxy string
|
||||
CaCerts *x509.CertPool
|
||||
Recorder report.ClientReportRecorder
|
||||
Provider report.ClientReportProvider
|
||||
SdkInfo func() *protocol.SdkInfo
|
||||
}
|
||||
|
||||
func getProxyConfig(options TransportOptions) func(*http.Request) (*url.URL, error) {
|
||||
if len(options.HTTPSProxy) > 0 {
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
return url.Parse(options.HTTPSProxy)
|
||||
}
|
||||
}
|
||||
|
||||
if len(options.HTTPProxy) > 0 {
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
return url.Parse(options.HTTPProxy)
|
||||
}
|
||||
}
|
||||
|
||||
return http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
func getTLSConfig(options TransportOptions) *tls.Config {
|
||||
if options.CaCerts != nil {
|
||||
return &tls.Config{
|
||||
RootCAs: options.CaCerts,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSentryRequestFromEnvelope(ctx context.Context, dsn *protocol.Dsn, envelope *protocol.Envelope) (r *http.Request, err error) {
|
||||
defer func() {
|
||||
if r != nil {
|
||||
var sdkName, sdkVersion string
|
||||
if envelope.Header.Sdk != nil {
|
||||
sdkVersion = envelope.Header.Sdk.Version
|
||||
sdkName = envelope.Header.Sdk.Name
|
||||
}
|
||||
|
||||
r.Header.Set("User-Agent", fmt.Sprintf("%s/%s", sdkName, sdkVersion))
|
||||
r.Header.Set("Content-Type", "application/x-sentry-envelope")
|
||||
|
||||
auth := fmt.Sprintf("Sentry sentry_version=%d, "+
|
||||
"sentry_client=%s/%s, sentry_key=%s", apiVersion, sdkName, sdkVersion, dsn.GetPublicKey())
|
||||
|
||||
if dsn.GetSecretKey() != "" {
|
||||
auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey())
|
||||
}
|
||||
|
||||
r.Header.Set("X-Sentry-Auth", auth)
|
||||
}
|
||||
}()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = envelope.WriteTo(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
dsn.GetAPIURL().String(),
|
||||
&buf,
|
||||
)
|
||||
}
|
||||
|
||||
func categoryFromEnvelope(envelope *protocol.Envelope) ratelimit.Category {
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return ratelimit.CategoryAll
|
||||
}
|
||||
|
||||
for _, item := range envelope.Items {
|
||||
if item == nil || item.Header == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch item.Header.Type {
|
||||
case protocol.EnvelopeItemTypeEvent:
|
||||
return ratelimit.CategoryError
|
||||
case protocol.EnvelopeItemTypeTransaction:
|
||||
return ratelimit.CategoryTransaction
|
||||
case protocol.EnvelopeItemTypeCheckIn:
|
||||
return ratelimit.CategoryMonitor
|
||||
case protocol.EnvelopeItemTypeLog:
|
||||
return ratelimit.CategoryLog
|
||||
case protocol.EnvelopeItemTypeAttachment:
|
||||
continue
|
||||
default:
|
||||
return ratelimit.CategoryAll
|
||||
}
|
||||
}
|
||||
|
||||
return ratelimit.CategoryAll
|
||||
}
|
||||
|
||||
// SyncTransport is a blocking implementation of Transport.
|
||||
//
|
||||
// Clients using this transport will send requests to Sentry sequentially and
|
||||
// block until a response is returned.
|
||||
//
|
||||
// The blocking behavior is useful in a limited set of use cases. For example,
|
||||
// use it when deploying code to a Function as a Service ("Serverless")
|
||||
// platform, where any work happening in a background goroutine is not
|
||||
// guaranteed to execute.
|
||||
//
|
||||
// For most cases, prefer AsyncTransport.
|
||||
type SyncTransport struct {
|
||||
dsn *protocol.Dsn
|
||||
client *http.Client
|
||||
transport http.RoundTripper
|
||||
recorder report.ClientReportRecorder
|
||||
provider report.ClientReportProvider
|
||||
sdkInfo func() *protocol.SdkInfo
|
||||
|
||||
mu sync.Mutex
|
||||
limits ratelimit.Map
|
||||
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func NewSyncTransport(options TransportOptions) protocol.TelemetryTransport {
|
||||
dsn, err := protocol.NewDsn(options.Dsn)
|
||||
if err != nil || dsn == nil {
|
||||
debuglog.Printf("Transport is disabled: invalid dsn: %v\n", err)
|
||||
return NewNoopTransport()
|
||||
}
|
||||
|
||||
recorder := options.Recorder
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
provider := options.Provider
|
||||
if provider == nil {
|
||||
provider = report.NoopProvider()
|
||||
}
|
||||
|
||||
transport := &SyncTransport{
|
||||
Timeout: defaultTimeout,
|
||||
limits: make(ratelimit.Map),
|
||||
dsn: dsn,
|
||||
recorder: recorder,
|
||||
provider: provider,
|
||||
sdkInfo: options.SdkInfo,
|
||||
}
|
||||
|
||||
if options.HTTPTransport != nil {
|
||||
transport.transport = options.HTTPTransport
|
||||
} else {
|
||||
transport.transport = &http.Transport{
|
||||
Proxy: getProxyConfig(options),
|
||||
TLSClientConfig: getTLSConfig(options),
|
||||
}
|
||||
}
|
||||
|
||||
if options.HTTPClient != nil {
|
||||
transport.client = options.HTTPClient
|
||||
} else {
|
||||
transport.client = &http.Client{
|
||||
Transport: transport.transport,
|
||||
Timeout: transport.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
func (t *SyncTransport) SendEnvelope(envelope *protocol.Envelope) error {
|
||||
return t.SendEnvelopeWithContext(context.Background(), envelope)
|
||||
}
|
||||
|
||||
func (t *SyncTransport) Close() {}
|
||||
|
||||
func (t *SyncTransport) IsRateLimited(category ratelimit.Category) bool {
|
||||
return t.disabled(category)
|
||||
}
|
||||
|
||||
func (t *SyncTransport) HasCapacity() bool { return true }
|
||||
|
||||
func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *protocol.Envelope) error {
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return ErrEmptyEnvelope
|
||||
}
|
||||
|
||||
category := categoryFromEnvelope(envelope)
|
||||
if t.disabled(category) {
|
||||
t.recorder.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope)
|
||||
return nil
|
||||
}
|
||||
// the sync transport needs to attach client reports when available
|
||||
t.provider.AttachToEnvelope(envelope)
|
||||
|
||||
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
|
||||
if err != nil {
|
||||
debuglog.Printf("There was an issue creating the request: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonInternalError, envelope)
|
||||
return err
|
||||
}
|
||||
identifier := util.EnvelopeIdentifier(envelope)
|
||||
debuglog.Printf(
|
||||
"Sending %s to %s project: %s",
|
||||
identifier,
|
||||
t.dsn.GetHost(),
|
||||
t.dsn.GetProjectID(),
|
||||
)
|
||||
|
||||
result, err := util.DoSendRequest(t.client, request, identifier)
|
||||
if err != nil {
|
||||
debuglog.Printf("There was an issue with sending an event: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonNetworkError, envelope)
|
||||
return err
|
||||
}
|
||||
if result.IsSendError() {
|
||||
t.recorder.RecordForEnvelope(report.ReasonSendError, envelope)
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.limits.Merge(result.Limits)
|
||||
t.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *SyncTransport) Flush(_ time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *SyncTransport) FlushWithContext(_ context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *SyncTransport) disabled(c ratelimit.Category) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
disabled := t.limits.IsRateLimited(c)
|
||||
if disabled {
|
||||
debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
// AsyncTransport is the default, non-blocking, implementation of Transport.
|
||||
//
|
||||
// Clients using this transport will enqueue requests in a queue and return to
|
||||
// the caller before any network communication has happened. Requests are sent
|
||||
// to Sentry sequentially from a background goroutine.
|
||||
type AsyncTransport struct {
|
||||
dsn *protocol.Dsn
|
||||
client *http.Client
|
||||
transport http.RoundTripper
|
||||
recorder report.ClientReportRecorder
|
||||
provider report.ClientReportProvider
|
||||
sdkInfo func() *protocol.SdkInfo
|
||||
|
||||
queue chan *protocol.Envelope
|
||||
|
||||
mu sync.RWMutex
|
||||
limits ratelimit.Map
|
||||
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
flushRequest chan chan struct{}
|
||||
|
||||
closeMu sync.RWMutex
|
||||
|
||||
QueueSize int
|
||||
Timeout time.Duration
|
||||
|
||||
startOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewAsyncTransport(options TransportOptions) protocol.TelemetryTransport {
|
||||
dsn, err := protocol.NewDsn(options.Dsn)
|
||||
if err != nil || dsn == nil {
|
||||
debuglog.Printf("Transport is disabled: invalid dsn: %v", err)
|
||||
return NewNoopTransport()
|
||||
}
|
||||
|
||||
recorder := options.Recorder
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
provider := options.Provider
|
||||
if provider == nil {
|
||||
provider = report.NoopProvider()
|
||||
}
|
||||
|
||||
transport := &AsyncTransport{
|
||||
QueueSize: defaultQueueSize,
|
||||
Timeout: defaultTimeout,
|
||||
done: make(chan struct{}),
|
||||
limits: make(ratelimit.Map),
|
||||
dsn: dsn,
|
||||
recorder: recorder,
|
||||
provider: provider,
|
||||
sdkInfo: options.SdkInfo,
|
||||
}
|
||||
|
||||
transport.queue = make(chan *protocol.Envelope, transport.QueueSize)
|
||||
transport.flushRequest = make(chan chan struct{})
|
||||
|
||||
if options.HTTPTransport != nil {
|
||||
transport.transport = options.HTTPTransport
|
||||
} else {
|
||||
transport.transport = &http.Transport{
|
||||
Proxy: getProxyConfig(options),
|
||||
TLSClientConfig: getTLSConfig(options),
|
||||
}
|
||||
}
|
||||
|
||||
if options.HTTPClient != nil {
|
||||
transport.client = options.HTTPClient
|
||||
} else {
|
||||
transport.client = &http.Client{
|
||||
Transport: transport.transport,
|
||||
Timeout: transport.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
transport.start()
|
||||
return transport
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) start() {
|
||||
t.startOnce.Do(func() {
|
||||
if t.recorder == nil {
|
||||
t.recorder = report.NoopRecorder()
|
||||
}
|
||||
if t.provider == nil {
|
||||
t.provider = report.NoopProvider()
|
||||
}
|
||||
t.wg.Add(1)
|
||||
go t.worker()
|
||||
})
|
||||
}
|
||||
|
||||
// HasCapacity reports whether the async transport queue appears to have space
|
||||
// for at least one more envelope. This is a best-effort, non-blocking check.
|
||||
func (t *AsyncTransport) HasCapacity() bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
select {
|
||||
case <-t.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
return len(t.queue) < cap(t.queue)
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error {
|
||||
t.closeMu.RLock()
|
||||
defer t.closeMu.RUnlock()
|
||||
|
||||
select {
|
||||
case <-t.done:
|
||||
return ErrTransportClosed
|
||||
default:
|
||||
}
|
||||
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return ErrEmptyEnvelope
|
||||
}
|
||||
|
||||
category := categoryFromEnvelope(envelope)
|
||||
if t.isRateLimited(category) {
|
||||
t.recorder.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope)
|
||||
return nil
|
||||
}
|
||||
|
||||
identifier := util.EnvelopeIdentifier(envelope)
|
||||
|
||||
select {
|
||||
case <-t.done:
|
||||
return ErrTransportClosed
|
||||
case t.queue <- envelope:
|
||||
debuglog.Printf(
|
||||
"Sending %s to %s project: %s",
|
||||
identifier,
|
||||
t.dsn.GetHost(),
|
||||
t.dsn.GetProjectID(),
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
t.recorder.RecordForEnvelope(report.ReasonQueueOverflow, envelope)
|
||||
return ErrTransportQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) Flush(timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return t.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) FlushWithContext(ctx context.Context) bool {
|
||||
t.closeMu.RLock()
|
||||
defer t.closeMu.RUnlock()
|
||||
|
||||
flushResponse := make(chan struct{})
|
||||
select {
|
||||
case <-t.done:
|
||||
debuglog.Println("Failed to flush, transport is closed.")
|
||||
return false
|
||||
case t.flushRequest <- flushResponse:
|
||||
select {
|
||||
case <-flushResponse:
|
||||
debuglog.Println("Buffer flushed successfully.")
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
debuglog.Println("Failed to flush, buffer timed out.")
|
||||
return false
|
||||
}
|
||||
case <-ctx.Done():
|
||||
debuglog.Println("Failed to flush, buffer timed out.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) Close() {
|
||||
t.closeOnce.Do(func() {
|
||||
t.closeMu.Lock()
|
||||
defer t.closeMu.Unlock()
|
||||
|
||||
close(t.done)
|
||||
t.wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) IsRateLimited(category ratelimit.Category) bool {
|
||||
return t.isRateLimited(category)
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) resolveSdkInfo() *protocol.SdkInfo {
|
||||
if t.sdkInfo == nil {
|
||||
return &protocol.SdkInfo{}
|
||||
}
|
||||
return t.sdkInfo()
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) worker() {
|
||||
defer t.wg.Done()
|
||||
|
||||
crTicker := time.NewTicker(defaultClientReportsTick)
|
||||
defer crTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case <-crTicker.C:
|
||||
t.sendClientReport()
|
||||
case envelope, open := <-t.queue:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
t.sendEnvelopeHTTP(envelope)
|
||||
case flushResponse, open := <-t.flushRequest:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
t.drainQueue()
|
||||
close(flushResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendClientReport sends a standalone envelope containing only a client report.
|
||||
func (t *AsyncTransport) sendClientReport() {
|
||||
r := t.provider.TakeReport()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
item, err := r.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to serialize client report: %v", err)
|
||||
return
|
||||
}
|
||||
header := &protocol.EnvelopeHeader{
|
||||
SentAt: time.Now(),
|
||||
Dsn: t.dsn,
|
||||
Sdk: t.resolveSdkInfo(),
|
||||
}
|
||||
envelope := protocol.NewEnvelope(header)
|
||||
envelope.AddItem(item)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to create client report request: %v", err)
|
||||
return
|
||||
}
|
||||
result, err := util.DoSendRequest(t.client, request, "client report")
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to send client report: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.limits.Merge(result.Limits)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) drainQueue() {
|
||||
for {
|
||||
select {
|
||||
case envelope, open := <-t.queue:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
t.sendEnvelopeHTTP(envelope)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { //nolint: unparam
|
||||
category := categoryFromEnvelope(envelope)
|
||||
if t.isRateLimited(category) {
|
||||
t.recorder.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope)
|
||||
return false
|
||||
}
|
||||
// attach to envelope after rate-limit check
|
||||
t.provider.AttachToEnvelope(envelope)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to create request from envelope: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonInternalError, envelope)
|
||||
return false
|
||||
}
|
||||
|
||||
identifier := util.EnvelopeIdentifier(envelope)
|
||||
result, err := util.DoSendRequest(t.client, request, identifier)
|
||||
if err != nil {
|
||||
debuglog.Printf("HTTP request failed: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonNetworkError, envelope)
|
||||
return false
|
||||
}
|
||||
if result.IsSendError() {
|
||||
t.recorder.RecordForEnvelope(report.ReasonSendError, envelope)
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.limits.Merge(result.Limits)
|
||||
t.mu.Unlock()
|
||||
|
||||
return result.Success
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) isRateLimited(category ratelimit.Category) bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
limited := t.limits.IsRateLimited(category)
|
||||
if limited {
|
||||
debuglog.Printf("Rate limited for category %q until %v", category, t.limits.Deadline(category))
|
||||
}
|
||||
return limited
|
||||
}
|
||||
|
||||
// NoopTransport is a transport implementation that drops all events.
|
||||
// Used internally when an empty or invalid DSN is provided.
|
||||
type NoopTransport struct{}
|
||||
|
||||
func NewNoopTransport() *NoopTransport {
|
||||
debuglog.Println("Transport initialized with invalid DSN. Using NoopTransport. No events will be delivered.")
|
||||
return &NoopTransport{}
|
||||
}
|
||||
|
||||
func (t *NoopTransport) SendEnvelope(_ *protocol.Envelope) error {
|
||||
debuglog.Println("Envelope dropped due to NoopTransport usage.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *NoopTransport) IsRateLimited(_ ratelimit.Category) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *NoopTransport) Flush(_ time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *NoopTransport) FlushWithContext(_ context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *NoopTransport) Close() {
|
||||
// Nothing to close
|
||||
}
|
||||
|
||||
func (t *NoopTransport) HasCapacity() bool { return true }
|
||||
+12
@@ -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
|
||||
+604
@@ -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)
|
||||
}
|
||||
Generated
Vendored
+45
@@ -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
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiVersion is the version of the Sentry API.
|
||||
const apiVersion = "7"
|
||||
|
||||
type scheme string
|
||||
|
||||
const (
|
||||
SchemeHTTP scheme = "http"
|
||||
SchemeHTTPS scheme = "https"
|
||||
)
|
||||
|
||||
func (scheme scheme) defaultPort() int {
|
||||
switch scheme {
|
||||
case SchemeHTTPS:
|
||||
return 443
|
||||
case SchemeHTTP:
|
||||
return 80
|
||||
default:
|
||||
return 80
|
||||
}
|
||||
}
|
||||
|
||||
// DsnParseError represents an error that occurs if a Sentry
|
||||
// DSN cannot be parsed.
|
||||
type DsnParseError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e DsnParseError) Error() string {
|
||||
return "[Sentry] DsnParseError: " + e.Message
|
||||
}
|
||||
|
||||
// Dsn is used as the remote address source to client transport.
|
||||
type Dsn struct {
|
||||
scheme scheme
|
||||
publicKey string
|
||||
secretKey string
|
||||
host string
|
||||
port int
|
||||
path string
|
||||
projectID string
|
||||
orgID uint64
|
||||
}
|
||||
|
||||
// NewDsn creates a Dsn by parsing rawURL. Most users will never call this
|
||||
// function directly. It is provided for use in custom Transport
|
||||
// implementations.
|
||||
func NewDsn(rawURL string) (*Dsn, error) {
|
||||
// Parse
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, &DsnParseError{fmt.Sprintf("invalid url: %v", err)}
|
||||
}
|
||||
|
||||
// Scheme
|
||||
var scheme scheme
|
||||
switch parsedURL.Scheme {
|
||||
case "http":
|
||||
scheme = SchemeHTTP
|
||||
case "https":
|
||||
scheme = SchemeHTTPS
|
||||
default:
|
||||
return nil, &DsnParseError{"invalid scheme"}
|
||||
}
|
||||
|
||||
// PublicKey
|
||||
publicKey := parsedURL.User.Username()
|
||||
if publicKey == "" {
|
||||
return nil, &DsnParseError{"empty username"}
|
||||
}
|
||||
|
||||
// SecretKey
|
||||
var secretKey string
|
||||
if parsedSecretKey, ok := parsedURL.User.Password(); ok {
|
||||
secretKey = parsedSecretKey
|
||||
}
|
||||
|
||||
// Host
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return nil, &DsnParseError{"empty host"}
|
||||
}
|
||||
|
||||
// OrgID (optional)
|
||||
var orgID uint64
|
||||
parts := strings.Split(host, ".")
|
||||
orgPart := parts[0]
|
||||
if len(orgPart) >= 2 && orgPart[0] == 'o' {
|
||||
parsedOrgID, err := strconv.ParseUint(orgPart[1:], 10, 64)
|
||||
if err == nil {
|
||||
orgID = parsedOrgID
|
||||
}
|
||||
}
|
||||
|
||||
// Port
|
||||
var port int
|
||||
if p := parsedURL.Port(); p != "" {
|
||||
port, err = strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil, &DsnParseError{"invalid port"}
|
||||
}
|
||||
} else {
|
||||
port = scheme.defaultPort()
|
||||
}
|
||||
|
||||
// ProjectID
|
||||
if parsedURL.Path == "" || parsedURL.Path == "/" {
|
||||
return nil, &DsnParseError{"empty project id"}
|
||||
}
|
||||
pathSegments := strings.Split(parsedURL.Path[1:], "/")
|
||||
projectID := pathSegments[len(pathSegments)-1]
|
||||
|
||||
if projectID == "" {
|
||||
return nil, &DsnParseError{"empty project id"}
|
||||
}
|
||||
|
||||
// Path
|
||||
var path string
|
||||
if len(pathSegments) > 1 {
|
||||
path = "/" + strings.Join(pathSegments[0:len(pathSegments)-1], "/")
|
||||
}
|
||||
|
||||
return &Dsn{
|
||||
scheme: scheme,
|
||||
publicKey: publicKey,
|
||||
secretKey: secretKey,
|
||||
host: host,
|
||||
port: port,
|
||||
path: path,
|
||||
projectID: projectID,
|
||||
orgID: orgID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// String formats Dsn struct into a valid string url.
|
||||
func (dsn Dsn) String() string {
|
||||
var url string
|
||||
url += fmt.Sprintf("%s://%s", dsn.scheme, dsn.publicKey)
|
||||
if dsn.secretKey != "" {
|
||||
url += fmt.Sprintf(":%s", dsn.secretKey)
|
||||
}
|
||||
url += fmt.Sprintf("@%s", dsn.host)
|
||||
if dsn.port != dsn.scheme.defaultPort() {
|
||||
url += fmt.Sprintf(":%d", dsn.port)
|
||||
}
|
||||
if dsn.path != "" {
|
||||
url += dsn.path
|
||||
}
|
||||
url += fmt.Sprintf("/%s", dsn.projectID)
|
||||
return url
|
||||
}
|
||||
|
||||
// Get the scheme of the DSN.
|
||||
func (dsn Dsn) GetScheme() string {
|
||||
return string(dsn.scheme)
|
||||
}
|
||||
|
||||
// Get the public key of the DSN.
|
||||
func (dsn Dsn) GetPublicKey() string {
|
||||
return dsn.publicKey
|
||||
}
|
||||
|
||||
// Get the secret key of the DSN.
|
||||
func (dsn Dsn) GetSecretKey() string {
|
||||
return dsn.secretKey
|
||||
}
|
||||
|
||||
// Get the host of the DSN.
|
||||
func (dsn Dsn) GetHost() string {
|
||||
return dsn.host
|
||||
}
|
||||
|
||||
// Get the port of the DSN.
|
||||
func (dsn Dsn) GetPort() int {
|
||||
return dsn.port
|
||||
}
|
||||
|
||||
// Get the path of the DSN.
|
||||
func (dsn Dsn) GetPath() string {
|
||||
return dsn.path
|
||||
}
|
||||
|
||||
// Get the project ID of the DSN.
|
||||
func (dsn Dsn) GetProjectID() string {
|
||||
return dsn.projectID
|
||||
}
|
||||
|
||||
// GetOrgID returns the orgID that was parsed from the DSN.
|
||||
func (dsn Dsn) GetOrgID() uint64 {
|
||||
return dsn.orgID
|
||||
}
|
||||
|
||||
// SetOrgID sets the orgID used for trace continuation.
|
||||
//
|
||||
// This function is used for overriding the orgID parsed from the DSN.
|
||||
func (dsn *Dsn) SetOrgID(orgID uint64) {
|
||||
dsn.orgID = orgID
|
||||
}
|
||||
|
||||
// GetAPIURL returns the URL of the envelope endpoint of the project
|
||||
// associated with the DSN.
|
||||
func (dsn Dsn) GetAPIURL() *url.URL {
|
||||
var rawURL string
|
||||
rawURL += fmt.Sprintf("%s://%s", dsn.scheme, dsn.host)
|
||||
if dsn.port != dsn.scheme.defaultPort() {
|
||||
rawURL += fmt.Sprintf(":%d", dsn.port)
|
||||
}
|
||||
if dsn.path != "" {
|
||||
rawURL += dsn.path
|
||||
}
|
||||
rawURL += fmt.Sprintf("/api/%s/%s/", dsn.projectID, "envelope")
|
||||
parsedURL, _ := url.Parse(rawURL)
|
||||
return parsedURL
|
||||
}
|
||||
|
||||
// RequestHeaders returns all the necessary headers that have to be used in the transport when sending events
|
||||
// to the /store endpoint.
|
||||
//
|
||||
// Deprecated: This method shall only be used if you want to implement your own transport that sends events to
|
||||
// the /store endpoint. If you're using the transport provided by the SDK, all necessary headers to authenticate
|
||||
// against the /envelope endpoint are added automatically.
|
||||
func (dsn Dsn) RequestHeaders(sdkVersion string) map[string]string {
|
||||
auth := fmt.Sprintf("Sentry sentry_version=%s, sentry_timestamp=%d, "+
|
||||
"sentry_client=sentry.go/%s, sentry_key=%s", apiVersion, time.Now().Unix(), sdkVersion, dsn.publicKey)
|
||||
|
||||
if dsn.secretKey != "" {
|
||||
auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.secretKey)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-Sentry-Auth": auth,
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON converts the Dsn struct to JSON.
|
||||
func (dsn Dsn) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(dsn.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON converts JSON data to the Dsn struct.
|
||||
func (dsn *Dsn) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
_ = json.Unmarshal(data, &str)
|
||||
newDsn, err := NewDsn(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dsn = *newDsn
|
||||
return nil
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Envelope represents a Sentry envelope containing headers and items.
|
||||
type Envelope struct {
|
||||
Header *EnvelopeHeader `json:"-"`
|
||||
Items []*EnvelopeItem `json:"-"`
|
||||
}
|
||||
|
||||
// EnvelopeHeader represents the header of a Sentry envelope.
|
||||
type EnvelopeHeader struct {
|
||||
// EventID is the unique identifier for this event
|
||||
EventID string `json:"event_id"`
|
||||
|
||||
// SentAt is the timestamp when the event was sent from the SDK as string in RFC 3339 format.
|
||||
// Used for clock drift correction of the event timestamp. The time zone must be UTC.
|
||||
SentAt time.Time `json:"sent_at,omitzero"`
|
||||
|
||||
// Dsn can be used for self-authenticated envelopes.
|
||||
// This means that the envelope has all the information necessary to be sent to sentry.
|
||||
// In this case the full DSN must be stored in this key.
|
||||
Dsn *Dsn `json:"dsn,omitempty"`
|
||||
|
||||
// Sdk carries the same payload as the sdk interface in the event payload but can be carried for all events.
|
||||
// This means that SDK information can be carried for minidumps, session data and other submissions.
|
||||
Sdk *SdkInfo `json:"sdk,omitempty"`
|
||||
|
||||
// Trace contains the [Dynamic Sampling Context](https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/)
|
||||
Trace map[string]string `json:"trace,omitempty"`
|
||||
}
|
||||
|
||||
// EnvelopeItemType represents the type of envelope item.
|
||||
type EnvelopeItemType string
|
||||
|
||||
// Constants for envelope item types as defined in the Sentry documentation.
|
||||
const (
|
||||
EnvelopeItemTypeEvent EnvelopeItemType = "event"
|
||||
EnvelopeItemTypeTransaction EnvelopeItemType = "transaction"
|
||||
EnvelopeItemTypeCheckIn EnvelopeItemType = "check_in"
|
||||
EnvelopeItemTypeAttachment EnvelopeItemType = "attachment"
|
||||
EnvelopeItemTypeLog EnvelopeItemType = "log"
|
||||
EnvelopeItemTypeTraceMetric EnvelopeItemType = "trace_metric"
|
||||
EnvelopeItemTypeClientReport EnvelopeItemType = "client_report"
|
||||
)
|
||||
|
||||
// EnvelopeItemHeader represents the header of an envelope item.
|
||||
type EnvelopeItemHeader struct {
|
||||
// Type specifies the type of this Item and its contents.
|
||||
// Based on the Item type, more headers may be required.
|
||||
Type EnvelopeItemType `json:"type"`
|
||||
|
||||
// Length is the length of the payload in bytes.
|
||||
// If no length is specified, the payload implicitly goes to the next newline.
|
||||
// For payloads containing newline characters, the length must be specified.
|
||||
Length *int `json:"length,omitempty"`
|
||||
|
||||
// Filename is the name of the attachment file (used for attachments)
|
||||
Filename string `json:"filename,omitempty"`
|
||||
|
||||
// ContentType is the MIME type of the item payload (used for attachments and some other item types)
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
|
||||
// ItemCount is the number of items in a batch (used for logs)
|
||||
ItemCount *int `json:"item_count,omitempty"`
|
||||
|
||||
// SpanCount is the number of spans in a transaction (used for client reports)
|
||||
SpanCount int `json:"-"`
|
||||
}
|
||||
|
||||
// EnvelopeItem represents a single item or batch within an envelope.
|
||||
type EnvelopeItem struct {
|
||||
Header *EnvelopeItemHeader `json:"-"`
|
||||
Payload []byte `json:"-"`
|
||||
}
|
||||
|
||||
// NewEnvelope creates a new envelope with the given header.
|
||||
func NewEnvelope(header *EnvelopeHeader) *Envelope {
|
||||
return &Envelope{
|
||||
Header: header,
|
||||
Items: make([]*EnvelopeItem, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddItem adds an item to the envelope.
|
||||
func (e *Envelope) AddItem(item *EnvelopeItem) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
e.Items = append(e.Items, item)
|
||||
}
|
||||
|
||||
// Serialize serializes the envelope to the Sentry envelope format.
|
||||
//
|
||||
// Format: Headers "\n" { Item } [ "\n" ]
|
||||
// Item: Headers "\n" Payload "\n".
|
||||
func (e *Envelope) Serialize() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
headerBytes, err := json.Marshal(e.Header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal envelope header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.Write(headerBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to write envelope header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.WriteString("\n"); err != nil {
|
||||
return nil, fmt.Errorf("failed to write newline after envelope header: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range e.Items {
|
||||
if err := e.writeItem(&buf, item); err != nil {
|
||||
return nil, fmt.Errorf("failed to write envelope item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// WriteTo writes the envelope to the given writer in the Sentry envelope format.
|
||||
func (e *Envelope) WriteTo(w io.Writer) (int64, error) {
|
||||
data, err := e.Serialize()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err := w.Write(data)
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// writeItem writes a single envelope item to the buffer.
|
||||
func (e *Envelope) writeItem(buf *bytes.Buffer, item *EnvelopeItem) error {
|
||||
headerBytes, err := json.Marshal(item.Header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal item header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.Write(headerBytes); err != nil {
|
||||
return fmt.Errorf("failed to write item header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.WriteString("\n"); err != nil {
|
||||
return fmt.Errorf("failed to write newline after item header: %w", err)
|
||||
}
|
||||
|
||||
if len(item.Payload) > 0 {
|
||||
if _, err := buf.Write(item.Payload); err != nil {
|
||||
return fmt.Errorf("failed to write item payload: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := buf.WriteString("\n"); err != nil {
|
||||
return fmt.Errorf("failed to write newline after item payload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the total size of the envelope when serialized.
|
||||
func (e *Envelope) Size() (int, error) {
|
||||
data, err := e.Serialize()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
// NewEnvelopeItem creates a new envelope item with the specified type and payload.
|
||||
func NewEnvelopeItem(itemType EnvelopeItemType, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: itemType,
|
||||
Length: &length,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTransactionItem creates a new envelope item including the span count of the transaction.
|
||||
func NewTransactionItem(spanCount int, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeTransaction,
|
||||
Length: &length,
|
||||
SpanCount: spanCount,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAttachmentItem creates a new envelope item for an attachment.
|
||||
// Parameters: filename, contentType, payload.
|
||||
func NewAttachmentItem(filename, contentType string, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeAttachment,
|
||||
Length: &length,
|
||||
ContentType: contentType,
|
||||
Filename: filename,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLogItem creates a new envelope item for logs.
|
||||
func NewLogItem(itemCount int, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeLog,
|
||||
Length: &length,
|
||||
ItemCount: &itemCount,
|
||||
ContentType: "application/vnd.sentry.items.log+json",
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTraceMetricItem creates a new envelope item for trace metrics.
|
||||
func NewTraceMetricItem(itemCount int, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeTraceMetric,
|
||||
Length: &length,
|
||||
ItemCount: &itemCount,
|
||||
ContentType: "application/vnd.sentry.items.trace-metric+json",
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientReportItem creates a new envelope item for client reports.
|
||||
func NewClientReportItem(payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeClientReport,
|
||||
Length: &length,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// TelemetryItem represents any telemetry data that can be stored in buffers and sent to Sentry.
|
||||
// This is the base interface that all telemetry items must implement.
|
||||
type TelemetryItem interface {
|
||||
// GetCategory returns the rate limit category for this item.
|
||||
GetCategory() ratelimit.Category
|
||||
|
||||
// GetEventID returns the event ID for this item.
|
||||
GetEventID() string
|
||||
|
||||
// GetSdkInfo returns SDK information for the envelope header.
|
||||
GetSdkInfo() *SdkInfo
|
||||
|
||||
// GetDynamicSamplingContext returns trace context for the envelope header.
|
||||
GetDynamicSamplingContext() map[string]string
|
||||
|
||||
// MakeSerializationSafe prevents serialization races, by serializing user mutable data on the foreground.
|
||||
// Should be used before passing telemetry to the processor.
|
||||
MakeSerializationSafe()
|
||||
}
|
||||
|
||||
// EnvelopeItemConvertible represents items that can be converted directly to envelope items.
|
||||
type EnvelopeItemConvertible interface {
|
||||
TelemetryItem
|
||||
|
||||
// ToEnvelopeItem converts the item to a Sentry envelope item.
|
||||
ToEnvelopeItem() (*EnvelopeItem, error)
|
||||
}
|
||||
|
||||
// EnvelopeConvertible represents items that can convert themselves to complete envelopes.
|
||||
type EnvelopeConvertible interface {
|
||||
TelemetryItem
|
||||
|
||||
// ToEnvelope converts the item to a Sentry envelope using the provided header.
|
||||
ToEnvelope(*EnvelopeHeader) (*Envelope, error)
|
||||
}
|
||||
|
||||
// TelemetryTransport represents the envelope-first transport interface.
|
||||
// This interface is designed for the telemetry buffer system and provides
|
||||
// non-blocking sends with backpressure signals.
|
||||
type TelemetryTransport interface {
|
||||
// SendEnvelope sends an envelope to Sentry. Returns immediately with
|
||||
// backpressure error if the queue is full.
|
||||
SendEnvelope(envelope *Envelope) error
|
||||
|
||||
// HasCapacity reports whether the transport has capacity to accept at least one more envelope.
|
||||
HasCapacity() bool
|
||||
|
||||
// IsRateLimited checks if a specific category is currently rate limited
|
||||
IsRateLimited(category ratelimit.Category) bool
|
||||
|
||||
// Flush waits for all pending envelopes to be sent, with timeout
|
||||
Flush(timeout time.Duration) bool
|
||||
|
||||
// FlushWithContext waits for all pending envelopes to be sent
|
||||
FlushWithContext(ctx context.Context) bool
|
||||
|
||||
// Close shuts down the transport gracefully
|
||||
Close()
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// LogAttribute is the JSON representation for a single log attribute value.
|
||||
type LogAttribute struct {
|
||||
Value any `json:"value"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Logs is a container for multiple log items which knows how to convert
|
||||
// itself into a single batched log envelope item.
|
||||
type Logs []TelemetryItem
|
||||
|
||||
func (ls Logs) ToEnvelopeItem() (*EnvelopeItem, error) {
|
||||
// Convert each log to its JSON representation
|
||||
items := make([]json.RawMessage, 0, len(ls))
|
||||
for _, log := range ls {
|
||||
logPayload, err := json.Marshal(log)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, logPayload)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
wrapper := struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}{Items: items}
|
||||
|
||||
payload, err := json.Marshal(wrapper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewLogItem(len(ls), payload), nil
|
||||
}
|
||||
|
||||
func (Logs) GetCategory() ratelimit.Category { return ratelimit.CategoryLog }
|
||||
func (Logs) GetEventID() string { return "" }
|
||||
func (Logs) GetSdkInfo() *SdkInfo { return nil }
|
||||
func (Logs) GetDynamicSamplingContext() map[string]string { return nil }
|
||||
func (Logs) MakeSerializationSafe() {}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
type Metrics []TelemetryItem
|
||||
|
||||
func (ms Metrics) ToEnvelopeItem() (*EnvelopeItem, error) {
|
||||
// Convert each metric to its JSON representation
|
||||
items := make([]json.RawMessage, 0, len(ms))
|
||||
for _, metric := range ms {
|
||||
metricPayload, err := json.Marshal(metric)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, metricPayload)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
wrapper := struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}{Items: items}
|
||||
|
||||
payload, err := json.Marshal(wrapper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewTraceMetricItem(len(items), payload), nil
|
||||
}
|
||||
|
||||
func (Metrics) GetCategory() ratelimit.Category { return ratelimit.CategoryTraceMetric }
|
||||
func (Metrics) GetEventID() string { return "" }
|
||||
func (Metrics) GetSdkInfo() *SdkInfo { return nil }
|
||||
func (Metrics) GetDynamicSamplingContext() map[string]string { return nil }
|
||||
func (Metrics) MakeSerializationSafe() {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package protocol
|
||||
|
||||
// SdkInfo contains SDK metadata.
|
||||
type SdkInfo struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Integrations []string `json:"integrations,omitempty"`
|
||||
Packages []SdkPackage `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// SdkPackage describes a package that was installed.
|
||||
type SdkPackage struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateEventID generates a random UUID v4 for use as a Sentry event ID.
|
||||
func GenerateEventID() string {
|
||||
id := make([]byte, 16)
|
||||
// Prefer rand.Read over rand.Reader, see https://go-review.googlesource.com/c/go/+/272326/.
|
||||
_, _ = rand.Read(id)
|
||||
id[6] &= 0x0F // clear version
|
||||
id[6] |= 0x40 // set version to 4 (random uuid)
|
||||
id[8] &= 0x3F // clear variant
|
||||
id[8] |= 0x80 // set to IETF variant
|
||||
return hex.EncodeToString(id)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Reference:
|
||||
// https://github.com/getsentry/relay/blob/46dfaa850b8717a6e22c3e9a275ba17fe673b9da/relay-base-schema/src/data_category.rs#L231-L271
|
||||
|
||||
// Category classifies supported payload types that can be ingested by Sentry
|
||||
// and, therefore, rate limited.
|
||||
type Category string
|
||||
|
||||
// Known rate limit categories that are specified in rate limit headers.
|
||||
const (
|
||||
CategoryUnknown Category = "unknown" // Unknown category should not get rate limited
|
||||
CategoryAll Category = "" // Special category for empty categories (applies to all)
|
||||
CategoryError Category = "error"
|
||||
CategoryTransaction Category = "transaction"
|
||||
CategorySpan Category = "span"
|
||||
CategoryLog Category = "log_item"
|
||||
CategoryLogByte Category = "log_byte"
|
||||
CategoryMonitor Category = "monitor"
|
||||
CategoryTraceMetric Category = "trace_metric"
|
||||
)
|
||||
|
||||
// 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: {},
|
||||
CategoryLog: {},
|
||||
CategoryMonitor: {},
|
||||
CategoryTraceMetric: {},
|
||||
}
|
||||
|
||||
// String returns the category formatted for debugging.
|
||||
func (c Category) String() string {
|
||||
switch c {
|
||||
case CategoryAll:
|
||||
return "CategoryAll"
|
||||
case CategoryError:
|
||||
return "CategoryError"
|
||||
case CategoryTransaction:
|
||||
return "CategoryTransaction"
|
||||
case CategorySpan:
|
||||
return "CategorySpan"
|
||||
case CategoryLog:
|
||||
return "CategoryLog"
|
||||
case CategoryLogByte:
|
||||
return "CategoryLogByte"
|
||||
case CategoryMonitor:
|
||||
return "CategoryMonitor"
|
||||
case CategoryTraceMetric:
|
||||
return "CategoryTraceMetric"
|
||||
default:
|
||||
// For unknown categories, use the original formatting logic
|
||||
caser := cases.Title(language.English)
|
||||
rv := "Category"
|
||||
for _, w := range strings.Fields(string(c)) {
|
||||
rv += caser.String(w)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
}
|
||||
|
||||
// Priority represents the importance level of a category for buffer management.
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
PriorityCritical Priority = iota + 1
|
||||
PriorityHigh
|
||||
PriorityMedium
|
||||
PriorityLow
|
||||
PriorityLowest
|
||||
)
|
||||
|
||||
func (p Priority) String() string {
|
||||
switch p {
|
||||
case PriorityCritical:
|
||||
return "critical"
|
||||
case PriorityHigh:
|
||||
return "high"
|
||||
case PriorityMedium:
|
||||
return "medium"
|
||||
case PriorityLow:
|
||||
return "low"
|
||||
case PriorityLowest:
|
||||
return "lowest"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// GetPriority returns the priority level for this category.
|
||||
func (c Category) GetPriority() Priority {
|
||||
switch c {
|
||||
case CategoryError:
|
||||
return PriorityCritical
|
||||
case CategoryMonitor:
|
||||
return PriorityHigh
|
||||
case CategoryLog:
|
||||
return PriorityLow
|
||||
case CategoryTransaction:
|
||||
return PriorityMedium
|
||||
case CategoryTraceMetric:
|
||||
return PriorityLow
|
||||
default:
|
||||
return PriorityMedium
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Package ratelimit provides tools to work with rate limits imposed by Sentry's
|
||||
// data ingestion pipeline.
|
||||
package ratelimit
|
||||
+64
@@ -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{}
|
||||
}
|
||||
+76
@@ -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
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBucketedCapacity = 100
|
||||
perBucketItemLimit = 100
|
||||
)
|
||||
|
||||
type Bucket[T any] struct {
|
||||
traceID string
|
||||
items []T
|
||||
createdAt time.Time
|
||||
lastUpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BucketedBuffer groups items by trace id, flushing per bucket.
|
||||
type BucketedBuffer[T any] struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
buckets []*Bucket[T]
|
||||
traceIndex map[string]int
|
||||
|
||||
head int
|
||||
tail int
|
||||
|
||||
itemCapacity int
|
||||
bucketCapacity int
|
||||
|
||||
totalItems int
|
||||
bucketCount int
|
||||
|
||||
category ratelimit.Category
|
||||
priority ratelimit.Priority
|
||||
overflowPolicy OverflowPolicy
|
||||
recorder report.ClientReportRecorder
|
||||
batchSize int
|
||||
timeout time.Duration
|
||||
lastFlushTime time.Time
|
||||
|
||||
offered int64
|
||||
dropped int64
|
||||
onDropped func(item T, reason string)
|
||||
}
|
||||
|
||||
func NewBucketedBuffer[T any](
|
||||
category ratelimit.Category,
|
||||
capacity int,
|
||||
overflowPolicy OverflowPolicy,
|
||||
batchSize int,
|
||||
timeout time.Duration,
|
||||
recorder report.ClientReportRecorder,
|
||||
) *BucketedBuffer[T] {
|
||||
if capacity <= 0 {
|
||||
capacity = defaultBucketedCapacity
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
|
||||
bucketCapacity := capacity / 10
|
||||
if bucketCapacity < 10 {
|
||||
bucketCapacity = 10
|
||||
}
|
||||
|
||||
return &BucketedBuffer[T]{
|
||||
buckets: make([]*Bucket[T], bucketCapacity),
|
||||
traceIndex: make(map[string]int),
|
||||
itemCapacity: capacity,
|
||||
bucketCapacity: bucketCapacity,
|
||||
category: category,
|
||||
priority: category.GetPriority(),
|
||||
overflowPolicy: overflowPolicy,
|
||||
recorder: recorder,
|
||||
batchSize: batchSize,
|
||||
timeout: timeout,
|
||||
lastFlushTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Offer(item T) bool {
|
||||
atomic.AddInt64(&b.offered, 1)
|
||||
|
||||
traceID := ""
|
||||
if ta, ok := any(item).(TraceAware); ok {
|
||||
if tid, hasTrace := ta.GetTraceID(); hasTrace {
|
||||
traceID = tid
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.offerToBucket(item, traceID)
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) offerToBucket(item T, traceID string) bool {
|
||||
if traceID != "" {
|
||||
if idx, exists := b.traceIndex[traceID]; exists {
|
||||
bucket := b.buckets[idx]
|
||||
if len(bucket.items) >= perBucketItemLimit {
|
||||
delete(b.traceIndex, traceID)
|
||||
} else {
|
||||
bucket.items = append(bucket.items, item)
|
||||
bucket.lastUpdatedAt = time.Now()
|
||||
b.totalItems++
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b.totalItems >= b.itemCapacity {
|
||||
return b.handleOverflow(item, traceID)
|
||||
}
|
||||
if b.bucketCount >= b.bucketCapacity {
|
||||
return b.handleOverflow(item, traceID)
|
||||
}
|
||||
|
||||
bucket := &Bucket[T]{
|
||||
traceID: traceID,
|
||||
items: []T{item},
|
||||
createdAt: time.Now(),
|
||||
lastUpdatedAt: time.Now(),
|
||||
}
|
||||
b.buckets[b.tail] = bucket
|
||||
if traceID != "" {
|
||||
b.traceIndex[traceID] = b.tail
|
||||
}
|
||||
b.tail = (b.tail + 1) % b.bucketCapacity
|
||||
b.bucketCount++
|
||||
b.totalItems++
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool {
|
||||
switch b.overflowPolicy {
|
||||
case OverflowPolicyDropOldest:
|
||||
oldestBucket := b.buckets[b.head]
|
||||
if oldestBucket == nil {
|
||||
b.recordDroppedItem(item)
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "buffer_full_invalid_state")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if oldestBucket.traceID != "" {
|
||||
delete(b.traceIndex, oldestBucket.traceID)
|
||||
}
|
||||
droppedCount := len(oldestBucket.items)
|
||||
atomic.AddInt64(&b.dropped, int64(droppedCount))
|
||||
for _, di := range oldestBucket.items {
|
||||
b.recordDroppedItem(di)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(di, "buffer_full_drop_oldest_bucket")
|
||||
}
|
||||
}
|
||||
b.totalItems -= droppedCount
|
||||
b.bucketCount--
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
// add new bucket
|
||||
bucket := &Bucket[T]{traceID: traceID, items: []T{item}, createdAt: time.Now(), lastUpdatedAt: time.Now()}
|
||||
b.buckets[b.tail] = bucket
|
||||
if traceID != "" {
|
||||
b.traceIndex[traceID] = b.tail
|
||||
}
|
||||
b.tail = (b.tail + 1) % b.bucketCapacity
|
||||
b.bucketCount++
|
||||
b.totalItems++
|
||||
return true
|
||||
case OverflowPolicyDropNewest:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "buffer_full_drop_newest")
|
||||
}
|
||||
return false
|
||||
default:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "unknown_overflow_policy")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Poll() (T, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var zero T
|
||||
if b.bucketCount == 0 {
|
||||
return zero, false
|
||||
}
|
||||
bucket := b.buckets[b.head]
|
||||
if bucket == nil || len(bucket.items) == 0 {
|
||||
return zero, false
|
||||
}
|
||||
item := bucket.items[0]
|
||||
bucket.items = bucket.items[1:]
|
||||
b.totalItems--
|
||||
if len(bucket.items) == 0 {
|
||||
if bucket.traceID != "" {
|
||||
delete(b.traceIndex, bucket.traceID)
|
||||
}
|
||||
b.buckets[b.head] = nil
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
b.bucketCount--
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) PollBatch(maxItems int) []T {
|
||||
if maxItems <= 0 {
|
||||
return nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.bucketCount == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]T, 0, maxItems)
|
||||
for len(res) < maxItems && b.bucketCount > 0 {
|
||||
bucket := b.buckets[b.head]
|
||||
if bucket == nil {
|
||||
break
|
||||
}
|
||||
n := maxItems - len(res)
|
||||
if n > len(bucket.items) {
|
||||
n = len(bucket.items)
|
||||
}
|
||||
res = append(res, bucket.items[:n]...)
|
||||
bucket.items = bucket.items[n:]
|
||||
b.totalItems -= n
|
||||
if len(bucket.items) == 0 {
|
||||
if bucket.traceID != "" {
|
||||
delete(b.traceIndex, bucket.traceID)
|
||||
}
|
||||
b.buckets[b.head] = nil
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
b.bucketCount--
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) PollIfReady() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.bucketCount == 0 {
|
||||
return nil
|
||||
}
|
||||
ready := b.totalItems >= b.batchSize || (b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout)
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
oldest := b.buckets[b.head]
|
||||
if oldest == nil {
|
||||
return nil
|
||||
}
|
||||
items := oldest.items
|
||||
if oldest.traceID != "" {
|
||||
delete(b.traceIndex, oldest.traceID)
|
||||
}
|
||||
b.buckets[b.head] = nil
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
b.totalItems -= len(items)
|
||||
b.bucketCount--
|
||||
b.lastFlushTime = time.Now()
|
||||
return items
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Drain() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.bucketCount == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]T, 0, b.totalItems)
|
||||
for i := 0; i < b.bucketCount; i++ {
|
||||
idx := (b.head + i) % b.bucketCapacity
|
||||
bucket := b.buckets[idx]
|
||||
if bucket != nil {
|
||||
res = append(res, bucket.items...)
|
||||
b.buckets[idx] = nil
|
||||
}
|
||||
}
|
||||
b.traceIndex = make(map[string]int)
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.totalItems = 0
|
||||
b.bucketCount = 0
|
||||
return res
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Peek() (T, bool) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
var zero T
|
||||
if b.bucketCount == 0 {
|
||||
return zero, false
|
||||
}
|
||||
bucket := b.buckets[b.head]
|
||||
if bucket == nil || len(bucket.items) == 0 {
|
||||
return zero, false
|
||||
}
|
||||
return bucket.items[0], true
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Size() int { b.mu.RLock(); defer b.mu.RUnlock(); return b.totalItems }
|
||||
func (b *BucketedBuffer[T]) Capacity() int { b.mu.RLock(); defer b.mu.RUnlock(); return b.itemCapacity }
|
||||
func (b *BucketedBuffer[T]) Category() ratelimit.Category {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.category
|
||||
}
|
||||
func (b *BucketedBuffer[T]) Priority() ratelimit.Priority {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.priority
|
||||
}
|
||||
func (b *BucketedBuffer[T]) IsEmpty() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.bucketCount == 0
|
||||
}
|
||||
func (b *BucketedBuffer[T]) IsFull() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.totalItems >= b.itemCapacity
|
||||
}
|
||||
func (b *BucketedBuffer[T]) Utilization() float64 {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if b.itemCapacity == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(b.totalItems) / float64(b.itemCapacity)
|
||||
}
|
||||
func (b *BucketedBuffer[T]) OfferedCount() int64 { return atomic.LoadInt64(&b.offered) }
|
||||
func (b *BucketedBuffer[T]) DroppedCount() int64 { return atomic.LoadInt64(&b.dropped) }
|
||||
func (b *BucketedBuffer[T]) AcceptedCount() int64 { return b.OfferedCount() - b.DroppedCount() }
|
||||
func (b *BucketedBuffer[T]) DropRate() float64 {
|
||||
off := b.OfferedCount()
|
||||
if off == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(b.DroppedCount()) / float64(off)
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) GetMetrics() BufferMetrics {
|
||||
b.mu.RLock()
|
||||
size := b.totalItems
|
||||
util := 0.0
|
||||
if b.itemCapacity > 0 {
|
||||
util = float64(b.totalItems) / float64(b.itemCapacity)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
return BufferMetrics{Category: b.category, Priority: b.priority, Capacity: b.itemCapacity, Size: size, Utilization: util, OfferedCount: b.OfferedCount(), DroppedCount: b.DroppedCount(), AcceptedCount: b.AcceptedCount(), DropRate: b.DropRate(), LastUpdated: time.Now()}
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) SetDroppedCallback(callback func(item T, reason string)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.onDropped = callback
|
||||
}
|
||||
func (b *BucketedBuffer[T]) Clear() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for i := 0; i < b.bucketCapacity; i++ {
|
||||
b.buckets[i] = nil
|
||||
}
|
||||
b.traceIndex = make(map[string]int)
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.totalItems = 0
|
||||
b.bucketCount = 0
|
||||
}
|
||||
func (b *BucketedBuffer[T]) IsReadyToFlush() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if b.bucketCount == 0 {
|
||||
return false
|
||||
}
|
||||
if b.totalItems >= b.batchSize {
|
||||
return true
|
||||
}
|
||||
if b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (b *BucketedBuffer[T]) MarkFlushed() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.lastFlushTime = time.Now()
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) recordDroppedItem(item T) {
|
||||
if ti, ok := any(item).(protocol.TelemetryItem); ok {
|
||||
b.recorder.RecordItem(report.ReasonBufferOverflow, ti)
|
||||
} else {
|
||||
b.recorder.RecordOne(report.ReasonBufferOverflow, b.category)
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// Buffer defines the common interface for all buffer implementations.
|
||||
type Buffer[T any] interface {
|
||||
// Core operations
|
||||
Offer(item T) bool
|
||||
Poll() (T, bool)
|
||||
PollBatch(maxItems int) []T
|
||||
PollIfReady() []T
|
||||
Drain() []T
|
||||
Peek() (T, bool)
|
||||
|
||||
// State queries
|
||||
Size() int
|
||||
Capacity() int
|
||||
IsEmpty() bool
|
||||
IsFull() bool
|
||||
Utilization() float64
|
||||
|
||||
// Flush management
|
||||
IsReadyToFlush() bool
|
||||
MarkFlushed()
|
||||
|
||||
// Category/Priority
|
||||
Category() ratelimit.Category
|
||||
Priority() ratelimit.Priority
|
||||
|
||||
// Metrics
|
||||
OfferedCount() int64
|
||||
DroppedCount() int64
|
||||
AcceptedCount() int64
|
||||
DropRate() float64
|
||||
GetMetrics() BufferMetrics
|
||||
|
||||
// Configuration
|
||||
SetDroppedCallback(callback func(item T, reason string))
|
||||
Clear()
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
// Processor is the top-level object that wraps the scheduler and buffers.
|
||||
type Processor struct {
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
// NewProcessor creates a new Processor with the given configuration.
|
||||
func NewProcessor(
|
||||
buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem],
|
||||
transport protocol.TelemetryTransport,
|
||||
dsn *protocol.Dsn,
|
||||
sdkInfo func() *protocol.SdkInfo,
|
||||
recorder report.ClientReportRecorder,
|
||||
) *Processor {
|
||||
scheduler := NewScheduler(buffers, transport, dsn, sdkInfo, recorder)
|
||||
scheduler.Start()
|
||||
|
||||
return &Processor{
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a TelemetryItem to the appropriate buffer based on its category.
|
||||
//
|
||||
// The processor should call MakeSerializationSafe to eliminate any race on user mutable fields,
|
||||
// since the serialization happens on a background goroutine.
|
||||
func (b *Processor) Add(item protocol.TelemetryItem) bool {
|
||||
item.MakeSerializationSafe()
|
||||
return b.scheduler.Add(item)
|
||||
}
|
||||
|
||||
// Flush forces all buffers to flush within the given timeout.
|
||||
func (b *Processor) Flush(timeout time.Duration) bool {
|
||||
return b.scheduler.Flush(timeout)
|
||||
}
|
||||
|
||||
// FlushWithContext flushes with a custom context for cancellation.
|
||||
func (b *Processor) FlushWithContext(ctx context.Context) bool {
|
||||
return b.scheduler.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
// Close stops the buffer, flushes remaining data, and releases resources.
|
||||
func (b *Processor) Close(timeout time.Duration) {
|
||||
b.scheduler.Stop(timeout)
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
const defaultCapacity = 100
|
||||
|
||||
// RingBuffer is a thread-safe ring buffer with overflow policies.
|
||||
type RingBuffer[T any] struct {
|
||||
mu sync.RWMutex
|
||||
items []T
|
||||
head int
|
||||
tail int
|
||||
size int
|
||||
capacity int
|
||||
|
||||
category ratelimit.Category
|
||||
priority ratelimit.Priority
|
||||
overflowPolicy OverflowPolicy
|
||||
recorder report.ClientReportRecorder
|
||||
|
||||
batchSize int
|
||||
timeout time.Duration
|
||||
lastFlushTime time.Time
|
||||
|
||||
offered int64
|
||||
dropped int64
|
||||
onDropped func(item T, reason string)
|
||||
}
|
||||
|
||||
func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration, recorder report.ClientReportRecorder) *RingBuffer[T] {
|
||||
if capacity <= 0 {
|
||||
capacity = defaultCapacity
|
||||
}
|
||||
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
|
||||
return &RingBuffer[T]{
|
||||
items: make([]T, capacity),
|
||||
capacity: capacity,
|
||||
category: category,
|
||||
priority: category.GetPriority(),
|
||||
overflowPolicy: overflowPolicy,
|
||||
recorder: recorder,
|
||||
batchSize: batchSize,
|
||||
timeout: timeout,
|
||||
lastFlushTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) SetDroppedCallback(callback func(item T, reason string)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.onDropped = callback
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Offer(item T) bool {
|
||||
atomic.AddInt64(&b.offered, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size < b.capacity {
|
||||
b.items[b.tail] = item
|
||||
b.tail = (b.tail + 1) % b.capacity
|
||||
b.size++
|
||||
return true
|
||||
}
|
||||
|
||||
switch b.overflowPolicy {
|
||||
case OverflowPolicyDropOldest:
|
||||
oldItem := b.items[b.head]
|
||||
b.items[b.head] = item
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.tail = (b.tail + 1) % b.capacity
|
||||
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(oldItem)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(oldItem, "buffer_full_drop_oldest")
|
||||
}
|
||||
return true
|
||||
|
||||
case OverflowPolicyDropNewest:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "buffer_full_drop_newest")
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "unknown_overflow_policy")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Poll() (T, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var zero T
|
||||
if b.size == 0 {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
item := b.items[b.head]
|
||||
b.items[b.head] = zero
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.size--
|
||||
|
||||
return item, true
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) PollBatch(maxItems int) []T {
|
||||
if maxItems <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemCount := maxItems
|
||||
if itemCount > b.size {
|
||||
itemCount = b.size
|
||||
}
|
||||
|
||||
result := make([]T, itemCount)
|
||||
var zero T
|
||||
|
||||
for i := 0; i < itemCount; i++ {
|
||||
result[i] = b.items[b.head]
|
||||
b.items[b.head] = zero
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.size--
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Drain() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]T, b.size)
|
||||
index := 0
|
||||
var zero T
|
||||
|
||||
for i := 0; i < b.size; i++ {
|
||||
pos := (b.head + i) % b.capacity
|
||||
result[index] = b.items[pos]
|
||||
b.items[pos] = zero
|
||||
index++
|
||||
}
|
||||
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.size = 0
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Peek() (T, bool) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
var zero T
|
||||
if b.size == 0 {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return b.items[b.head], true
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Size() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.size
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Capacity() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.capacity
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Category() ratelimit.Category {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.category
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Priority() ratelimit.Priority {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.priority
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) IsEmpty() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.size == 0
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) IsFull() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.size == b.capacity
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Utilization() float64 {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return float64(b.size) / float64(b.capacity)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) OfferedCount() int64 {
|
||||
return atomic.LoadInt64(&b.offered)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) DroppedCount() int64 {
|
||||
return atomic.LoadInt64(&b.dropped)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) AcceptedCount() int64 {
|
||||
return b.OfferedCount() - b.DroppedCount()
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) DropRate() float64 {
|
||||
offered := b.OfferedCount()
|
||||
if offered == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(b.DroppedCount()) / float64(offered)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Clear() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var zero T
|
||||
for i := 0; i < b.capacity; i++ {
|
||||
b.items[i] = zero
|
||||
}
|
||||
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.size = 0
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) GetMetrics() BufferMetrics {
|
||||
b.mu.RLock()
|
||||
size := b.size
|
||||
util := float64(b.size) / float64(b.capacity)
|
||||
b.mu.RUnlock()
|
||||
|
||||
return BufferMetrics{
|
||||
Category: b.category,
|
||||
Priority: b.priority,
|
||||
Capacity: b.capacity,
|
||||
Size: size,
|
||||
Utilization: util,
|
||||
OfferedCount: b.OfferedCount(),
|
||||
DroppedCount: b.DroppedCount(),
|
||||
AcceptedCount: b.AcceptedCount(),
|
||||
DropRate: b.DropRate(),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) IsReadyToFlush() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if b.size >= b.batchSize {
|
||||
return true
|
||||
}
|
||||
|
||||
if b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) MarkFlushed() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.lastFlushTime = time.Now()
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) PollIfReady() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ready := b.size >= b.batchSize ||
|
||||
(b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout)
|
||||
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemCount := b.batchSize
|
||||
if itemCount > b.size {
|
||||
itemCount = b.size
|
||||
}
|
||||
|
||||
result := make([]T, itemCount)
|
||||
var zero T
|
||||
|
||||
for i := 0; i < itemCount; i++ {
|
||||
result[i] = b.items[b.head]
|
||||
b.items[b.head] = zero
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.size--
|
||||
}
|
||||
|
||||
b.lastFlushTime = time.Now()
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) recordDroppedItem(item T) {
|
||||
if ti, ok := any(item).(protocol.TelemetryItem); ok {
|
||||
b.recorder.RecordItem(report.ReasonBufferOverflow, ti)
|
||||
} else {
|
||||
b.recorder.RecordOne(report.ReasonBufferOverflow, b.category)
|
||||
}
|
||||
}
|
||||
|
||||
type BufferMetrics struct {
|
||||
Category ratelimit.Category `json:"category"`
|
||||
Priority ratelimit.Priority `json:"priority"`
|
||||
Capacity int `json:"capacity"`
|
||||
Size int `json:"size"`
|
||||
Utilization float64 `json:"utilization"`
|
||||
OfferedCount int64 `json:"offered_count"`
|
||||
DroppedCount int64 `json:"dropped_count"`
|
||||
AcceptedCount int64 `json:"accepted_count"`
|
||||
DropRate float64 `json:"drop_rate"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// OverflowPolicy defines how the ring buffer handles overflow.
|
||||
type OverflowPolicy int
|
||||
|
||||
const (
|
||||
OverflowPolicyDropOldest OverflowPolicy = iota
|
||||
OverflowPolicyDropNewest
|
||||
)
|
||||
|
||||
func (op OverflowPolicy) String() string {
|
||||
switch op {
|
||||
case OverflowPolicyDropOldest:
|
||||
return "drop_oldest"
|
||||
case OverflowPolicyDropNewest:
|
||||
return "drop_newest"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/debuglog"
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
// Scheduler implements a weighted round-robin scheduler for processing buffered events.
|
||||
type Scheduler struct {
|
||||
buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem]
|
||||
transport protocol.TelemetryTransport
|
||||
dsn *protocol.Dsn
|
||||
sdkInfo func() *protocol.SdkInfo
|
||||
recorder report.ClientReportRecorder
|
||||
|
||||
currentCycle []ratelimit.Priority
|
||||
cyclePos int
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processingWg sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
startOnce sync.Once
|
||||
finishOnce sync.Once
|
||||
}
|
||||
|
||||
func NewScheduler(
|
||||
buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem],
|
||||
transport protocol.TelemetryTransport,
|
||||
dsn *protocol.Dsn,
|
||||
sdkInfo func() *protocol.SdkInfo,
|
||||
recorder report.ClientReportRecorder,
|
||||
) *Scheduler {
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored in s.cancel and called in Shutdown()
|
||||
|
||||
priorityWeights := map[ratelimit.Priority]int{
|
||||
ratelimit.PriorityCritical: 5,
|
||||
ratelimit.PriorityHigh: 4,
|
||||
ratelimit.PriorityMedium: 3,
|
||||
ratelimit.PriorityLow: 2,
|
||||
ratelimit.PriorityLowest: 1,
|
||||
}
|
||||
|
||||
var currentCycle []ratelimit.Priority
|
||||
for priority, weight := range priorityWeights {
|
||||
hasBuffers := false
|
||||
for _, buffer := range buffers {
|
||||
if buffer.Priority() == priority {
|
||||
hasBuffers = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasBuffers {
|
||||
for i := 0; i < weight; i++ {
|
||||
currentCycle = append(currentCycle, priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s := &Scheduler{
|
||||
buffers: buffers,
|
||||
transport: transport,
|
||||
dsn: dsn,
|
||||
sdkInfo: sdkInfo,
|
||||
recorder: recorder,
|
||||
currentCycle: currentCycle,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.cond = sync.NewCond(&s.mu)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Scheduler) resolveSdkInfo() *protocol.SdkInfo {
|
||||
if s.sdkInfo == nil {
|
||||
return &protocol.SdkInfo{}
|
||||
}
|
||||
return s.sdkInfo()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.startOnce.Do(func() {
|
||||
s.processingWg.Add(1)
|
||||
go s.run()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop(timeout time.Duration) {
|
||||
s.finishOnce.Do(func() {
|
||||
s.Flush(timeout)
|
||||
|
||||
s.cancel()
|
||||
s.cond.Broadcast()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
s.processingWg.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(timeout):
|
||||
debuglog.Printf("scheduler stop timed out after %v", timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scheduler) Signal() {
|
||||
s.cond.Signal()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Add(item protocol.TelemetryItem) bool {
|
||||
category := item.GetCategory()
|
||||
buffer, exists := s.buffers[category]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
accepted := buffer.Offer(item)
|
||||
if accepted {
|
||||
s.Signal()
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func (s *Scheduler) Flush(timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return s.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Scheduler) FlushWithContext(ctx context.Context) bool {
|
||||
s.flushBuffers()
|
||||
return s.transport.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Scheduler) run() {
|
||||
defer s.processingWg.Done()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.cond.Broadcast()
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
s.mu.Lock()
|
||||
|
||||
for !s.hasWork() && s.ctx.Err() == nil {
|
||||
s.cond.Wait()
|
||||
}
|
||||
|
||||
if s.ctx.Err() != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
s.processNextBatch()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) hasWork() bool {
|
||||
for _, buffer := range s.buffers {
|
||||
if buffer.IsReadyToFlush() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Scheduler) processNextBatch() {
|
||||
if len(s.currentCycle) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
priority := s.currentCycle[s.cyclePos]
|
||||
s.cyclePos = (s.cyclePos + 1) % len(s.currentCycle)
|
||||
|
||||
var bufferToProcess Buffer[protocol.TelemetryItem]
|
||||
var categoryToProcess ratelimit.Category
|
||||
for category, buffer := range s.buffers {
|
||||
if buffer.Priority() == priority && buffer.IsReadyToFlush() {
|
||||
bufferToProcess = buffer
|
||||
categoryToProcess = category
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if bufferToProcess != nil {
|
||||
s.processItems(bufferToProcess, categoryToProcess, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category ratelimit.Category, force bool) {
|
||||
var items []protocol.TelemetryItem
|
||||
|
||||
if force {
|
||||
items = buffer.Drain()
|
||||
} else {
|
||||
items = buffer.PollIfReady()
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if s.isRateLimited(category) {
|
||||
for _, item := range items {
|
||||
s.recorder.RecordItem(report.ReasonRateLimitBackoff, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !s.transport.HasCapacity() {
|
||||
for _, item := range items {
|
||||
s.recorder.RecordItem(report.ReasonQueueOverflow, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch category {
|
||||
case ratelimit.CategoryLog:
|
||||
logs := protocol.Logs(items)
|
||||
header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Dsn: s.dsn, Sdk: s.resolveSdkInfo()}
|
||||
envelope := protocol.NewEnvelope(header)
|
||||
item, err := logs.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("error creating log batch envelope item: %v", err)
|
||||
return
|
||||
}
|
||||
envelope.AddItem(item)
|
||||
if err := s.transport.SendEnvelope(envelope); err != nil {
|
||||
debuglog.Printf("error sending envelope: %v", err)
|
||||
}
|
||||
return
|
||||
case ratelimit.CategoryTraceMetric:
|
||||
metrics := protocol.Metrics(items)
|
||||
header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Dsn: s.dsn, Sdk: s.resolveSdkInfo()}
|
||||
envelope := protocol.NewEnvelope(header)
|
||||
item, err := metrics.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("error creating trace metric batch envelope item: %v", err)
|
||||
return
|
||||
}
|
||||
envelope.AddItem(item)
|
||||
if err := s.transport.SendEnvelope(envelope); err != nil {
|
||||
debuglog.Printf("error sending envelope: %v", err)
|
||||
}
|
||||
return
|
||||
default:
|
||||
// if the buffers are properly configured, buffer.PollIfReady should return a single item for every category
|
||||
// other than logs. We still iterate over the items just in case, because we don't want to send broken envelopes.
|
||||
for _, it := range items {
|
||||
convertible, ok := it.(protocol.EnvelopeItemConvertible)
|
||||
if !ok {
|
||||
debuglog.Printf("item does not implement EnvelopeItemConvertible: %T", it)
|
||||
continue
|
||||
}
|
||||
s.sendItem(convertible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) sendItem(item protocol.EnvelopeItemConvertible) {
|
||||
header := &protocol.EnvelopeHeader{
|
||||
EventID: item.GetEventID(),
|
||||
SentAt: time.Now(),
|
||||
Dsn: s.dsn,
|
||||
Trace: item.GetDynamicSamplingContext(),
|
||||
Sdk: s.resolveSdkInfo(),
|
||||
}
|
||||
if header.EventID == "" {
|
||||
header.EventID = protocol.GenerateEventID()
|
||||
}
|
||||
|
||||
// TODO: need to restructure the abstraction layer to actually return envelopes instead of
|
||||
// envelope items. The problem with envelope items, is that for events and batches we might
|
||||
// need envelopes (eg. attachments need to be added separately and splitting the `Add` path
|
||||
// is problematic). Currently this is a workaround for adding attachments to events without
|
||||
// adding a circular dependency on telemetry/scheduler.
|
||||
var envelope *protocol.Envelope
|
||||
if convertible, ok := item.(protocol.EnvelopeConvertible); ok {
|
||||
var err error
|
||||
envelope, err = convertible.ToEnvelope(header)
|
||||
if err != nil {
|
||||
debuglog.Printf("error while converting to envelope: %v", err)
|
||||
s.recorder.RecordItem(report.ReasonInternalError, item)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
envItem, err := item.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("error while converting to envelope item: %v", err)
|
||||
s.recorder.RecordItem(report.ReasonInternalError, item)
|
||||
return
|
||||
}
|
||||
envelope = protocol.NewEnvelope(header)
|
||||
envelope.AddItem(envItem)
|
||||
}
|
||||
|
||||
if err := s.transport.SendEnvelope(envelope); err != nil {
|
||||
debuglog.Printf("error sending envelope: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) flushBuffers() {
|
||||
for category, buffer := range s.buffers {
|
||||
if !buffer.IsEmpty() {
|
||||
s.processItems(buffer, category, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) isRateLimited(category ratelimit.Category) bool {
|
||||
return s.transport.IsRateLimited(category)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package telemetry
|
||||
|
||||
// TraceAware is implemented by items that can expose a trace ID.
|
||||
// BucketedBuffer uses this to group items by trace.
|
||||
type TraceAware interface {
|
||||
GetTraceID() (string, bool)
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package util
|
||||
|
||||
import "sync"
|
||||
|
||||
type SyncMap[K comparable, V any] struct {
|
||||
m sync.Map
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Store(key K, value V) {
|
||||
s.m.Store(key, value)
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) CompareAndDelete(key K, value V) {
|
||||
s.m.CompareAndDelete(key, value)
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Load(key K) (V, bool) {
|
||||
v, ok := s.m.Load(key)
|
||||
if !ok {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
return v.(V), true
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Delete(key K) {
|
||||
s.m.Delete(key)
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
|
||||
actual, loaded := s.m.LoadOrStore(key, value)
|
||||
return actual.(V), loaded
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Clear() {
|
||||
s.m.Clear()
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Range(f func(key K, value V) bool) {
|
||||
s.m.Range(func(key, value any) bool {
|
||||
return f(key.(K), value.(V))
|
||||
})
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/debuglog"
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// MaxDrainResponseBytes is the maximum number of bytes that transport
|
||||
// implementations will read from response bodies when draining them.
|
||||
const MaxDrainResponseBytes = 16 << 10
|
||||
|
||||
// HandleHTTPResponse is a helper method that reads the HTTP response and handles debug output.
|
||||
func HandleHTTPResponse(response *http.Response, identifier string) bool {
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return true
|
||||
}
|
||||
|
||||
if response.StatusCode >= 400 && response.StatusCode <= 599 {
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, MaxDrainResponseBytes))
|
||||
if err != nil {
|
||||
debuglog.Printf("Error while reading response body: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case response.StatusCode == http.StatusRequestEntityTooLarge:
|
||||
debuglog.Printf("Sending %s failed because the request was too large: %s", identifier, string(body))
|
||||
case response.StatusCode >= 500:
|
||||
debuglog.Printf("Sending %s failed with server error %d: %s", identifier, response.StatusCode, string(body))
|
||||
default:
|
||||
debuglog.Printf("Sending %s failed with client error %d: %s", identifier, response.StatusCode, string(body))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
debuglog.Printf("Unexpected status code %d for event %s", response.StatusCode, identifier)
|
||||
return false
|
||||
}
|
||||
|
||||
// EnvelopeIdentifier returns a human-readable identifier for the event to be used in log messages.
|
||||
// Format: "<description> [<event-id>]".
|
||||
func EnvelopeIdentifier(envelope *protocol.Envelope) string {
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return "empty envelope"
|
||||
}
|
||||
|
||||
var description string
|
||||
// we don't currently support mixed envelope types, so all event types would have the same type.
|
||||
itemType := envelope.Items[0].Header.Type
|
||||
|
||||
switch itemType {
|
||||
case protocol.EnvelopeItemTypeEvent:
|
||||
description = "error"
|
||||
case protocol.EnvelopeItemTypeTransaction:
|
||||
description = "transaction"
|
||||
case protocol.EnvelopeItemTypeCheckIn:
|
||||
description = "check-in"
|
||||
case protocol.EnvelopeItemTypeLog:
|
||||
logCount := 0
|
||||
for _, item := range envelope.Items {
|
||||
if item != nil && item.Header != nil && item.Header.Type == protocol.EnvelopeItemTypeLog && item.Header.ItemCount != nil {
|
||||
logCount += *item.Header.ItemCount
|
||||
}
|
||||
}
|
||||
description = fmt.Sprintf("%d log events", logCount)
|
||||
case protocol.EnvelopeItemTypeTraceMetric:
|
||||
metricCount := 0
|
||||
for _, item := range envelope.Items {
|
||||
if item != nil && item.Header != nil && item.Header.Type == protocol.EnvelopeItemTypeTraceMetric && item.Header.ItemCount != nil {
|
||||
metricCount += *item.Header.ItemCount
|
||||
}
|
||||
}
|
||||
description = fmt.Sprintf("%d metric events", metricCount)
|
||||
default:
|
||||
description = fmt.Sprintf("%s event", itemType)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s [%s]", description, envelope.Header.EventID)
|
||||
}
|
||||
|
||||
// SendResult holds the outcome of an HTTP request sent to Sentry.
|
||||
type SendResult struct {
|
||||
Success bool
|
||||
StatusCode int
|
||||
Limits ratelimit.Map
|
||||
}
|
||||
|
||||
// IsSendError returns true if the response indicates a server/client error that should be recorded
|
||||
// as send_error for client report outcomes (non-429 failures).
|
||||
func (r *SendResult) IsSendError() bool {
|
||||
return !r.Success && r.StatusCode != http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
// DoSendRequest executes an HTTP request, handles response logging, extracts rate limits, and
|
||||
// drains+closes the response body.
|
||||
func DoSendRequest(client *http.Client, request *http.Request, identifier string) (*SendResult, error) {
|
||||
response, err := client.Do(request) //nolint:gosec // G704: request is constructed internally, not from user input
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
success := HandleHTTPResponse(response, identifier)
|
||||
limits := ratelimit.FromResponse(response)
|
||||
|
||||
// Drain body up to a limit and close it, allowing the transport to reuse TCP connections.
|
||||
_, _ = io.CopyN(io.Discard, response.Body, MaxDrainResponseBytes)
|
||||
response.Body.Close()
|
||||
|
||||
return &SendResult{
|
||||
Success: success,
|
||||
StatusCode: response.StatusCode,
|
||||
Limits: limits,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user