Update dependencies

This commit is contained in:
eleksir
2026-02-10 20:54:39 +03:00
parent f2fc168abe
commit 170d0d0ed4
559 changed files with 66718 additions and 72415 deletions

View File

@@ -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...)
}

View File

@@ -0,0 +1,572 @@
package http
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
"github.com/getsentry/sentry-go/internal/debuglog"
"github.com/getsentry/sentry-go/internal/protocol"
"github.com/getsentry/sentry-go/internal/ratelimit"
)
const (
apiVersion = 7
defaultTimeout = time.Second * 30
defaultQueueSize = 1000
// maxDrainResponseBytes is the maximum number of bytes that transport
// implementations will read from response bodies when draining them.
maxDrainResponseBytes = 16 << 10
)
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
}
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 {
sdkName := envelope.Header.Sdk.Name
sdkVersion := envelope.Header.Sdk.Version
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
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()
}
transport := &SyncTransport{
Timeout: defaultTimeout,
limits: make(ratelimit.Map),
dsn: dsn,
}
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) {
return nil
}
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
if err != nil {
debuglog.Printf("There was an issue creating the request: %v", err)
return err
}
debuglog.Printf(
"Sending %s [%s] to %s project: %s",
envelope.Items[0].Header.Type,
envelope.Header.EventID,
t.dsn.GetHost(),
t.dsn.GetProjectID(),
)
response, err := t.client.Do(request)
if err != nil {
debuglog.Printf("There was an issue with sending an event: %v", err)
return err
}
if response.StatusCode >= 400 && response.StatusCode <= 599 {
b, err := io.ReadAll(response.Body)
if err != nil {
debuglog.Printf("Error while reading response body: %v", err)
}
debuglog.Printf("Sending %s failed with the following error: %s", envelope.Header.EventID, string(b))
}
t.mu.Lock()
if t.limits == nil {
t.limits = make(ratelimit.Map)
}
t.limits.Merge(ratelimit.FromResponse(response))
t.mu.Unlock()
_, _ = io.CopyN(io.Discard, response.Body, maxDrainResponseBytes)
return response.Body.Close()
}
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
queue chan *protocol.Envelope
mu sync.RWMutex
limits ratelimit.Map
done chan struct{}
wg sync.WaitGroup
flushRequest chan chan struct{}
sentCount int64
droppedCount int64
errorCount int64
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()
}
transport := &AsyncTransport{
QueueSize: defaultQueueSize,
Timeout: defaultTimeout,
done: make(chan struct{}),
limits: make(ratelimit.Map),
dsn: dsn,
}
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() {
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 {
select {
case <-t.done:
return ErrTransportClosed
default:
}
if envelope == nil || len(envelope.Items) == 0 {
return ErrEmptyEnvelope
}
category := categoryFromEnvelope(envelope)
if t.isRateLimited(category) {
return nil
}
select {
case t.queue <- envelope:
debuglog.Printf(
"Sending %s [%s] to %s project: %s",
envelope.Items[0].Header.Type,
envelope.Header.EventID,
t.dsn.GetHost(),
t.dsn.GetProjectID(),
)
return nil
default:
atomic.AddInt64(&t.droppedCount, 1)
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 {
flushResponse := make(chan struct{})
select {
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() {
close(t.done)
close(t.queue)
close(t.flushRequest)
t.wg.Wait()
})
}
func (t *AsyncTransport) IsRateLimited(category ratelimit.Category) bool {
return t.isRateLimited(category)
}
func (t *AsyncTransport) worker() {
defer t.wg.Done()
for {
select {
case <-t.done:
return
case envelope, open := <-t.queue:
if !open {
return
}
t.processEnvelope(envelope)
case flushResponse, open := <-t.flushRequest:
if !open {
return
}
t.drainQueue()
close(flushResponse)
}
}
}
func (t *AsyncTransport) drainQueue() {
for {
select {
case envelope, open := <-t.queue:
if !open {
return
}
t.processEnvelope(envelope)
default:
return
}
}
}
func (t *AsyncTransport) processEnvelope(envelope *protocol.Envelope) {
if t.sendEnvelopeHTTP(envelope) {
atomic.AddInt64(&t.sentCount, 1)
} else {
atomic.AddInt64(&t.errorCount, 1)
}
}
func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool {
category := categoryFromEnvelope(envelope)
if t.isRateLimited(category) {
return false
}
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)
return false
}
response, err := t.client.Do(request)
if err != nil {
debuglog.Printf("HTTP request failed: %v", err)
return false
}
defer response.Body.Close()
success := t.handleResponse(response)
t.mu.Lock()
if t.limits == nil {
t.limits = make(ratelimit.Map)
}
t.limits.Merge(ratelimit.FromResponse(response))
t.mu.Unlock()
_, _ = io.CopyN(io.Discard, response.Body, maxDrainResponseBytes)
return success
}
func (t *AsyncTransport) handleResponse(response *http.Response) bool {
if response.StatusCode >= 200 && response.StatusCode < 300 {
return true
}
if response.StatusCode >= 400 && response.StatusCode < 500 {
if body, err := io.ReadAll(io.LimitReader(response.Body, maxDrainResponseBytes)); err == nil {
debuglog.Printf("Client error %d: %s", response.StatusCode, string(body))
}
return false
}
if response.StatusCode >= 500 {
debuglog.Printf("Server error %d", response.StatusCode)
return false
}
debuglog.Printf("Unexpected status code %d", response.StatusCode)
return false
}
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 }

View File

@@ -0,0 +1,236 @@
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
}
// 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"}
}
// 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,
}, 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
}
// 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
}

View File

@@ -0,0 +1,231 @@
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,omitempty"`
// 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 string `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"
)
// 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"`
}
// EnvelopeItem represents a single item 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
}
// MarshalJSON converts the EnvelopeHeader to JSON.
func (h *EnvelopeHeader) MarshalJSON() ([]byte, error) {
type header EnvelopeHeader
return json.Marshal((*header)(h))
}
// 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,
}
}
// 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,
}
}

View File

@@ -0,0 +1,56 @@
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
}
// 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)
}
// 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()
}

View File

@@ -0,0 +1,48 @@
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 }

View File

@@ -0,0 +1,41 @@
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 }

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -8,18 +8,21 @@ import (
)
// Reference:
// https://github.com/getsentry/relay/blob/0424a2e017d193a93918053c90cdae9472d164bf/relay-common/src/constants.rs#L116-L127
// 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. As a special case, the CategoryAll applies to
// all known payload types.
// Known rate limit categories that are specified in rate limit headers.
const (
CategoryAll Category = ""
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"
CategoryLog Category = "log_item"
CategoryMonitor Category = "monitor"
CategoryTraceMetric Category = "trace_metric"
)
// knownCategories is the set of currently known categories. Other categories
@@ -28,14 +31,28 @@ 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 "":
case CategoryAll:
return "CategoryAll"
case CategoryError:
return "CategoryError"
case CategoryTransaction:
return "CategoryTransaction"
case CategoryLog:
return "CategoryLog"
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)) {
@@ -44,3 +61,49 @@ func (c Category) String() string {
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
}
}

View File

@@ -0,0 +1,398 @@
package telemetry
import (
"sync"
"sync/atomic"
"time"
"github.com/getsentry/sentry-go/internal/ratelimit"
)
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
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,
) *BucketedBuffer[T] {
if capacity <= 0 {
capacity = defaultBucketedCapacity
}
if batchSize <= 0 {
batchSize = 1
}
if timeout < 0 {
timeout = 0
}
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,
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 {
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))
if b.onDropped != nil {
for _, di := range oldestBucket.items {
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)
if b.onDropped != nil {
b.onDropped(item, "buffer_full_drop_newest")
}
return false
default:
atomic.AddInt64(&b.dropped, 1)
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()
}

View File

@@ -0,0 +1,378 @@
package telemetry
import (
"sync"
"sync/atomic"
"time"
"github.com/getsentry/sentry-go/internal/ratelimit"
)
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
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) *RingBuffer[T] {
if capacity <= 0 {
capacity = defaultCapacity
}
if batchSize <= 0 {
batchSize = 1
}
if timeout < 0 {
timeout = 0
}
return &RingBuffer[T]{
items: make([]T, capacity),
capacity: capacity,
category: category,
priority: category.GetPriority(),
overflowPolicy: overflowPolicy,
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)
if b.onDropped != nil {
b.onDropped(oldItem, "buffer_full_drop_oldest")
}
return true
case OverflowPolicyDropNewest:
atomic.AddInt64(&b.dropped, 1)
if b.onDropped != nil {
b.onDropped(item, "buffer_full_drop_newest")
}
return false
default:
atomic.AddInt64(&b.dropped, 1)
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
}
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"
}
}

View File

@@ -0,0 +1,49 @@
package telemetry
import (
"context"
"time"
"github.com/getsentry/sentry-go/internal/protocol"
"github.com/getsentry/sentry-go/internal/ratelimit"
)
// 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 *protocol.SdkInfo,
) *Processor {
scheduler := NewScheduler(buffers, transport, dsn, sdkInfo)
scheduler.Start()
return &Processor{
scheduler: scheduler,
}
}
// Add adds a TelemetryItem to the appropriate buffer based on its category.
func (b *Processor) Add(item protocol.TelemetryItem) bool {
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)
}

View File

@@ -0,0 +1,301 @@
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"
)
// 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 *protocol.SdkInfo
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 *protocol.SdkInfo,
) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
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,
currentCycle: currentCycle,
ctx: ctx,
cancel: cancel,
}
s.cond = sync.NewCond(&s.mu)
return s
}
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()
}
// drop the current batch if rate-limited or if transport is full
if len(items) == 0 || s.isRateLimited(category) || !s.transport.HasCapacity() {
return
}
switch category {
case ratelimit.CategoryLog:
logs := protocol.Logs(items)
header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Sdk: s.sdkInfo}
if s.dsn != nil {
header.Dsn = s.dsn.String()
}
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(), Sdk: s.sdkInfo}
if s.dsn != nil {
header.Dsn = s.dsn.String()
}
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(),
Trace: item.GetDynamicSamplingContext(),
Sdk: s.sdkInfo,
}
if header.EventID == "" {
header.EventID = protocol.GenerateEventID()
}
if s.dsn != nil {
header.Dsn = s.dsn.String()
}
envelope := protocol.NewEnvelope(header)
envItem, err := item.ToEnvelopeItem()
if err != nil {
debuglog.Printf("error while converting to envelope item: %v", err)
return
}
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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

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

View File

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