fix(go.sum): update ResolveSpec dependency to v1.0.87
CI / build-and-test (push) Failing after 1s
Release / release (push) Failing after 19m26s

This commit is contained in:
Hein
2026-06-23 13:17:16 +02:00
parent 0227912325
commit 1adf50e3db
2436 changed files with 1078758 additions and 114 deletions
+12
View File
@@ -0,0 +1,12 @@
coverage:
status:
project:
default:
# Do not fail the commit status if the coverage was reduced up to this value
threshold: 0.5%
patch:
default:
informational: true
ignore:
- "log_fallback.go"
- "internal/testutils"
+53
View File
@@ -0,0 +1,53 @@
minVersion: 2.24.1
changelog:
policy: auto
versioning:
policy: auto
artifactProvider:
name: none
preReleaseCommand: bash scripts/bump-version.sh
targets:
- name: github
tagPrefix: v
- name: github
tagPrefix: otel/v
tagOnly: true
- name: github
tagPrefix: otel/otlp/v
tagOnly: true
- name: github
tagPrefix: echo/v
tagOnly: true
- name: github
tagPrefix: fasthttp/v
tagOnly: true
- name: github
tagPrefix: fiber/v
tagOnly: true
- name: github
tagPrefix: gin/v
tagOnly: true
- name: github
tagPrefix: grpc/v
tagOnly: true
- name: github
tagPrefix: iris/v
tagOnly: true
- name: github
tagPrefix: negroni/v
tagOnly: true
- name: github
tagPrefix: logrus/v
tagOnly: true
- name: github
tagPrefix: slog/v
tagOnly: true
- name: github
tagPrefix: zerolog/v
tagOnly: true
- name: github
tagPrefix: zap/v
tagOnly: true
- name: registry
sdks:
github:getsentry/sentry-go:
+5
View File
@@ -0,0 +1,5 @@
# Tell Git to use LF for line endings on all platforms.
# Required to have correct test data on Windows.
# https://github.com/mvdan/github-actions-golang#caveats
# https://github.com/actions/checkout/issues/135#issuecomment-613361104
* text eol=lf
+21
View File
@@ -0,0 +1,21 @@
# Code coverage artifacts
coverage.txt
coverage.out
coverage.html
.coverage/
# Just my personal way of tracking stuff — Kamil
FIXME.md
TODO.md
!NOTES.md
# IDE system files
.idea
.vscode
# Local Claude Code settings that should not be committed
.claude/settings.local.json
# .agents/.gitignore is generated by dotagents — don't commit it.
.agents/.gitignore
# Auto-generated by dotagents — do not commit these files.
agents.lock
+66
View File
@@ -0,0 +1,66 @@
version: "2"
linters:
default: none
enable:
- bodyclose
- dogsled
- dupl
- errcheck
- gochecknoinits
- goconst
- gocritic
- gocyclo
- godot
- gosec
- govet
- ineffassign
- misspell
- nakedret
- prealloc
- revive
- staticcheck
- unconvert
- unparam
- unused
- whitespace
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- goconst
- prealloc
path: _test\.go
- linters:
- gosec
path: _test\.go
text: "G306:"
- linters:
- staticcheck
path: _test\.go
text: "SA5011"
- linters:
- unused
path: errors_test\.go
- linters:
- bodyclose
- errcheck
path: http/example_test\.go
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+107
View File
@@ -0,0 +1,107 @@
# Sentry Go SDK
Single-module Go SDK with integration sub-modules in `github.com/getsentry/sentry-go`.
## Commit Attribution
AI commits MUST include:
```
Co-Authored-By: <agent model name> <agent-email-or-noreply@example.com>
```
## Before Every Commit
1. `make fmt` 2. `make lint` 3. `make vet` 4. `make test-race`
## Architecture
### Core (`/`)
The root package `sentry` contains the entire public API.
### Attribute Package (`/attribute/`)
Type-safe key-value builders used by structured logging and metrics:
```go
attribute.String("key", "value")
attribute.Int("count", 42)
attribute.Float64("ratio", 0.5)
attribute.Bool("flag", true)
```
### Integration Sub-Modules
Each lives in its own directory with a separate `go.mod`:
- **HTTP middleware** — `http/`, `gin/`, `echo/`, `fiber/`, `fasthttp/`, `iris/`, `negroni/`
- **Logging hooks** — `logrus/`, `zerolog/`, `zap/`, `slog/`
- **Instrumentation** — `httpclient/`, `otel/`
When adding a new integration, mirror an existing one.
### Transport Architecture
**Current: `transport.go` (active)**`HTTPTransport` is the default implementation of an async transport. `HTTPSyncTransport` is the blocking variant for serverless.
**Next: `internal/telemetry/` + `internal/http/` (not yet enabled)** — Processor/buffer/scheduler architecture. Wired up in `client.go` (`setupTelemetryProcessor`) but **commented out** behind `DisableTelemetryBuffer`. Key parts:
- `internal/telemetry/processor.go` — orchestrator; routes items to category-specific buffers
- `internal/telemetry/scheduler.go` — weighted round-robin; errors get 5x priority over logs
- `internal/telemetry/ring_buffer.go` — circular buffer with overflow policies and batch/timeout flushing
- `internal/telemetry/bucketed_buffer.go` — groups items by trace ID
- `internal/http/transport.go``AsyncTransport` with `HasCapacity()` backpressure
- `internal/protocol/``Envelope`, `TelemetryItem` interfaces; log/metric batch types
The `internalAsyncTransportAdapter` in `transport.go` bridges old `Transport` to new `TelemetryTransport`.
## Coding Standards
- Follow existing conventions — check neighboring files first
- Maintain existing Go versions and dependencies unless explicitly asked to change them
- `gofmt -s` formatting, doc comments on exports
- Public API in root package; internals in `/internal`
- Thread safety required — guard shared state with mutexes
- Update tests when modifying behavior
## Testing
Test tier preference (use the highest tier that covers what you need):
1. **Integration tests** (default) — Prefer `internal/sentrytest` with `sentrytest.Run` or `sentrytest.NewFixture`, plus real routers / `httptest` requests where needed. Prefer tests that use the public API.
2. **Context-level tests** — Prefer `sentrytest.NewContext` or `fixture.NewContext(parent)` for tracing / context propagation tests. Prefer `sentrytest.NewFixture` for isolated client + hub setup when no HTTP server is needed.
3. **Unit tests** (sparingly) — Direct `NewClient` + `MockScope` only for self-contained logic where `sentrytest` would add unnecessary indirection.
Conventions:
- Table-driven tests for multiple inputs through the same code path
- `t.Parallel()` for tests that don't share global state
- `cmp.Diff` with `cmpopts.IgnoreFields` for `*Event` comparison — ignore `EventID`, `Timestamp`, `Sdk`, `sdkMetaData`
- Prefer `fixture.Flush()` over direct `sentry.Flush(...)` in tests built on `internal/sentrytest`
- Prefer `fixture.Events()` as the captured event stream; inspect `event.Type` in assertions instead of relying on separate fixture streams
- `testify` for assertions, `internal/testutils/` for non-assert test helpers like mocks and flush timing
- All tests must pass `make test-race`
What to test:
- Behavior users observe: Does middleware capture panics? Does `Flush` deliver events? Do trace headers propagate?
- Edge cases at system boundaries: malformed DSN, nil `Hub`, concurrent captures, context cancellation
- Regressions: reproduce the failure before applying the fix
Thread safety:
- The SDK is used concurrently. Any test touching shared state (`Hub`, `Scope`, `CurrentHub`) must either use `t.Parallel()` with isolated instances, or explicitly verify safety with goroutines and `sync.WaitGroup`.
## Reference
- [SDK Development Guide](https://develop.sentry.dev/sdk/)
- [Commit Guidelines](https://develop.sentry.dev/engineering-practices/commit-messages/)
- [Hubs & Scopes](https://develop.sentry.dev/sdk/unified-api/#hub)
## Skills
- `/commit` — Commit with Sentry conventional format
- `/create-pr` — Create PRs following Sentry conventions
- `/code-review` — Review PRs following Sentry practices
- `/find-bugs` — Audit local changes for bugs and security issues
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
# Contributing to sentry-go
Hey, thank you if you're reading this, we welcome your contribution!
## Sending a Pull Request
Please help us save time when reviewing your PR by following this simple
process:
1. Is your PR a simple typo fix? Read no further, **click that green "Create
pull request" button**!
2. For more complex PRs that involve behavior changes or new APIs, please
consider [opening an **issue**][new-issue] describing the problem you're
trying to solve if there's not one already.
A PR is often one specific solution to a problem and sometimes talking about
the problem unfolds new possible solutions. Remember we will be responsible
for maintaining the changes later.
3. Fixing a bug and changing a behavior? Please add automated tests to prevent
future regression.
4. Practice writing good commit messages. We have [commit
guidelines][commit-guide].
5. We have [guidelines for PR submitters][pr-guide]. A short summary:
- Good PR descriptions are very helpful and most of the time they include
**why** something is done and why done in this particular way. Also list
other possible solutions that were considered and discarded.
- Be your own first reviewer. Make sure your code compiles and passes the
existing tests.
[new-issue]: https://github.com/getsentry/sentry-go/issues/new/choose
[commit-guide]: https://develop.sentry.dev/code-review/#commit-guidelines
[pr-guide]: https://develop.sentry.dev/code-review/#guidelines-for-submitters
Please also read through our [SDK Development docs](https://develop.sentry.dev/sdk/).
It contains information about SDK features, expected payloads and best practices for
contributing to Sentry SDKs.
## Community
The public-facing channels for support and development of Sentry SDKs can be found on [Discord](https://discord.gg/Ww9hbqr).
## Testing
```console
$ go test
```
### Watch mode
Use: https://github.com/cespare/reflex
```console
$ reflex -g '*.go' -d "none" -- sh -c 'printf "\n"; go test'
```
### With data race detection
```console
$ go test -race
```
### Coverage
```console
$ go test -race -coverprofile=coverage.txt -covermode=atomic && go tool cover -html coverage.txt
```
## Linting
Lint with [`golangci-lint`](https://github.com/golangci/golangci-lint):
```console
$ golangci-lint run
```
## Release
1. Update `CHANGELOG.md` with new version in `vX.X.X` format title and list of changes.
The command below can be used to get a list of changes since the last tag, with the format used in `CHANGELOG.md`:
```console
$ git log --no-merges --format=%s $(git describe --abbrev=0).. | sed 's/^/- /'
```
2. Commit with `misc: vX.X.X changelog` commit message and push to `master`.
3. Let [`craft`](https://github.com/getsentry/craft) do the rest:
```console
$ craft prepare X.X.X
$ craft publish X.X.X
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Functional Software, Inc. dba Sentry
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3
View File
@@ -0,0 +1,3 @@
# `raven-go` to `sentry-go` Migration Guide
A [`raven-go` to `sentry-go` migration guide](https://docs.sentry.io/platforms/go/migration/) is available at the official Sentry documentation site.
+67
View File
@@ -0,0 +1,67 @@
.DEFAULT_GOAL := help
GO = go
ALL_GO_MOD_DIRS := $(shell $(GO) work edit -json | jq -r '.Use[].DiskPath')
WORK_LINT_TARGETS := $(patsubst %, %/..., $(ALL_GO_MOD_DIRS))
TIMEOUT = 300
help: ## Show help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: help
build: ## Build all workspace modules
$(GO) build work
.PHONY: build
test: ## Run tests across all workspace modules
$(GO) test -count=1 -timeout $(TIMEOUT)s work
.PHONY: test
test-race: ## Run tests with race detection
$(GO) test -count=1 -timeout $(TIMEOUT)s -race work
.PHONY: test-race
COVERAGE_MODE = atomic
COVERAGE_DIR = .coverage
COVERAGE_PROFILE = $(COVERAGE_DIR)/coverage.out
$(COVERAGE_DIR):
mkdir -p $(COVERAGE_DIR)
test-coverage: $(COVERAGE_DIR) ## Test with coverage enabled
rm -f $(COVERAGE_DIR)/*
$(GO) test -count=1 -timeout $(TIMEOUT)s -covermode=$(COVERAGE_MODE) -coverprofile=$(COVERAGE_PROFILE) work
.PHONY: test-coverage
test-race-coverage: $(COVERAGE_DIR) ## Run tests with race detection and coverage
rm -f $(COVERAGE_DIR)/*
$(GO) test -count=1 -timeout $(TIMEOUT)s -race -covermode=$(COVERAGE_MODE) -coverprofile=$(COVERAGE_PROFILE) work
.PHONY: test-race-coverage
vet: ## Run "go vet" across all workspace modules
$(GO) vet work
.PHONY: vet
mod-tidy: ## Check go.mod tidiness
@set -e ; \
for dir in $(ALL_GO_MOD_DIRS); do \
MOD_GO=$$(sed -n 's/^go \([0-9.]*\)/\1/p' "$${dir}/go.mod"); \
echo ">>> Running 'go mod tidy' for module: $${dir} (go $${MOD_GO})"; \
(cd "$${dir}" && GOTOOLCHAIN=local $(GO) mod tidy -go=$${MOD_GO} -compat=$${MOD_GO}); \
done; \
git diff --exit-code
.PHONY: mod-tidy
gotidy: $(ALL_GO_MOD_DIRS:%=gotidy/%) ## Run go mod tidy across all modules
gotidy/%: DIR=$*
gotidy/%:
@echo "==> $(DIR)" && (cd "$(DIR)" && $(GO) mod tidy)
.PHONY: gotidy
lint: ## Lint (using "golangci-lint")
golangci-lint run $(WORK_LINT_TARGETS)
.PHONY: lint
fmt: ## Format all Go files
gofmt -l -w -s .
.PHONY: fmt
+108
View File
@@ -0,0 +1,108 @@
<p align="center">
<a href="https://sentry.io/?utm_source=github&utm_medium=logo" target="_blank">
<picture>
<source srcset="https://sentry-brand.storage.googleapis.com/sentry-logo-white.png" media="(prefers-color-scheme: dark)" />
<source srcset="https://sentry-brand.storage.googleapis.com/sentry-logo-black.png" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" />
<img src="https://sentry-brand.storage.googleapis.com/sentry-logo-black.png" alt="Sentry" width="280">
</picture>
</a>
</p>
# Official Sentry SDK for Go
[![Build Status](https://github.com/getsentry/sentry-go/actions/workflows/test.yml/badge.svg)](https://github.com/getsentry/sentry-go/actions/workflows/test.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/getsentry/sentry-go)](https://goreportcard.com/report/github.com/getsentry/sentry-go)
[![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr)
[![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry)
[![go.dev](https://img.shields.io/badge/go.dev-pkg-007d9c.svg?style=flat)](https://pkg.go.dev/github.com/getsentry/sentry-go)
`sentry-go` provides a Sentry client implementation for the Go programming
language. This is the next generation of the Go SDK for [Sentry](https://sentry.io/),
intended to replace the `raven-go` package.
> Looking for the old `raven-go` SDK documentation? See the Legacy client section [here](https://docs.sentry.io/clients/go/).
> If you want to start using `sentry-go` instead, check out the [migration guide](https://docs.sentry.io/platforms/go/migration/).
## Requirements
The only requirement is a Go compiler.
We follow Go's [official release policy](https://go.dev/doc/devel/release#policy),
supporting the two most recent Go releases. Each major Go release is supported
until there are two newer major releases. The exact versions are defined in
[`GitHub workflow`](.github/workflows/test.yml).
In addition, we run tests against the current master branch of the Go toolchain,
though support for this configuration is best-effort.
## Installation
`sentry-go` can be installed like any other Go library through `go get`:
```console
$ go get github.com/getsentry/sentry-go@latest
```
Check out the [list of released versions](https://github.com/getsentry/sentry-go/releases).
## Configuration
To use `sentry-go`, youll need to import the `sentry-go` package and initialize
it with your DSN and other [options](https://pkg.go.dev/github.com/getsentry/sentry-go#ClientOptions).
If not specified in the SDK initialization, the
[DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/),
[Release](https://docs.sentry.io/product/releases/) and
[Environment](https://docs.sentry.io/product/sentry-basics/environments/)
are read from the environment variables `SENTRY_DSN`, `SENTRY_RELEASE` and
`SENTRY_ENVIRONMENT`, respectively.
More on this in the [Configuration section of the official Sentry Go SDK documentation](https://docs.sentry.io/platforms/go/configuration/).
## Usage
The SDK supports reporting errors and tracking application performance.
To get started, have a look at one of our [examples](_examples/):
- [Basic error instrumentation](_examples/basic/main.go)
- [Error and tracing for HTTP servers](_examples/http/main.go)
We also provide a [complete API reference](https://pkg.go.dev/github.com/getsentry/sentry-go).
For more detailed information about how to get the most out of `sentry-go`,
check out the official documentation:
- [Sentry Go SDK documentation](https://docs.sentry.io/platforms/go/)
- Guides:
- [net/http](https://docs.sentry.io/platforms/go/guides/http/)
- [echo](https://docs.sentry.io/platforms/go/guides/echo/)
- [fasthttp](https://docs.sentry.io/platforms/go/guides/fasthttp/)
- [fiber](https://docs.sentry.io/platforms/go/guides/fiber/)
- [gin](https://docs.sentry.io/platforms/go/guides/gin/)
- [iris](https://docs.sentry.io/platforms/go/guides/iris/)
- [logrus](https://docs.sentry.io/platforms/go/guides/logrus/)
- [negroni](https://docs.sentry.io/platforms/go/guides/negroni/)
- [slog](https://docs.sentry.io/platforms/go/guides/slog/)
- [zerolog](https://docs.sentry.io/platforms/go/guides/zerolog/)
## Resources
- [Bug Tracker](https://github.com/getsentry/sentry-go/issues)
- [GitHub Project](https://github.com/getsentry/sentry-go)
- [![go.dev](https://img.shields.io/badge/go.dev-pkg-007d9c.svg?style=flat)](https://pkg.go.dev/github.com/getsentry/sentry-go)
- [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/go/)
- [![Discussions](https://img.shields.io/github/discussions/getsentry/sentry-go.svg)](https://github.com/getsentry/sentry-go/discussions)
- [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr)
- [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry)
- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry)
## License
Licensed under
[The MIT License](https://opensource.org/licenses/mit/), see
[`LICENSE`](LICENSE).
## Community
Join Sentry's [`#go` channel on Discord](https://discord.gg/Ww9hbqr) to get
involved and help us improve the SDK!
+38
View File
@@ -0,0 +1,38 @@
version = 1
agents = ["claude", "codex"]
[trust]
github_orgs = [ "getsentry" ]
[[skills]]
name = "dotagents"
source = "getsentry/dotagents"
[[skills]]
name = "find-bugs"
source = "getsentry/skills"
[[skills]]
name = "code-review"
source = "getsentry/skills"
[[skills]]
name = "commit"
source = "getsentry/skills"
[[skills]]
name = "create-pr"
source = "getsentry/skills"
[[skills]]
name = "code-simplifier"
source = "getsentry/skills"
[[skills]]
name = "iterate-pr"
source = "getsentry/skills"
[[skills]]
name = "security-review"
source = "getsentry/skills"
+61
View File
@@ -0,0 +1,61 @@
package attribute
type Builder struct {
Key string
Value Value
}
// String returns a Builder for a string value.
func String(key, value string) Builder {
return Builder{key, StringValue(value)}
}
// Int64 returns a Builder for an int64.
func Int64(key string, value int64) Builder {
return Builder{key, Int64Value(value)}
}
// Int returns a Builder for an int64.
func Int(key string, value int) Builder {
return Builder{key, IntValue(value)}
}
// Float64 returns a Builder for a float64.
func Float64(key string, v float64) Builder {
return Builder{key, Float64Value(v)}
}
// Bool returns a Builder for a boolean.
func Bool(key string, v bool) Builder {
return Builder{key, BoolValue(v)}
}
// BoolSlice returns a Builder for a bool slice.
func BoolSlice(key string, v []bool) Builder {
return Builder{key, BoolSliceValue(v)}
}
// IntSlice returns a Builder for an int slice.
func IntSlice(key string, v []int) Builder {
return Builder{key, IntSliceValue(v)}
}
// Int64Slice returns a Builder for an int64 slice.
func Int64Slice(key string, v []int64) Builder {
return Builder{key, Int64SliceValue(v)}
}
// Float64Slice returns a Builder for a float64 slice.
func Float64Slice(key string, v []float64) Builder {
return Builder{key, Float64SliceValue(v)}
}
// StringSlice returns a Builder for a string slice.
func StringSlice(key string, v []string) Builder {
return Builder{key, StringSliceValue(v)}
}
// Valid checks for valid key and type.
func (b *Builder) Valid() bool {
return len(b.Key) > 0 && b.Value.Type() != INVALID
}
+49
View File
@@ -0,0 +1,49 @@
// Copied from https://github.com/open-telemetry/opentelemetry-go/blob/cc43e01c27892252aac9a8f20da28cdde957a289/attribute/rawhelpers.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 attribute
import (
"math"
)
func boolToRaw(b bool) uint64 { // b is not a control flag.
if b {
return 1
}
return 0
}
func rawToBool(r uint64) bool {
return r != 0
}
func int64ToRaw(i int64) uint64 {
// Assumes original was a valid int64 (overflow not checked).
return uint64(i) // nolint: gosec
}
func rawToInt64(r uint64) int64 {
// Assumes original was a valid int64 (overflow not checked).
return int64(r) // nolint: gosec
}
func float64ToRaw(f float64) uint64 {
return math.Float64bits(f)
}
func rawToFloat64(r uint64) float64 {
return math.Float64frombits(r)
}
+15
View File
@@ -0,0 +1,15 @@
package attribute
import "reflect"
func asSlice[T any](v any) []T {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Array {
return nil
}
cpy := make([]T, rv.Len())
if len(cpy) > 0 {
_ = reflect.Copy(reflect.ValueOf(cpy), rv)
}
return cpy
}
+321
View File
@@ -0,0 +1,321 @@
// Adapted from https://github.com/open-telemetry/opentelemetry-go/blob/cc43e01c27892252aac9a8f20da28cdde957a289/attribute/value.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 attribute
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
)
// Type describes the type of the data Value holds.
type Type int // redefines builtin Type.
// Value represents the value part in key-value pairs.
type Value struct {
vtype Type
numeric uint64
stringly string
slice any
}
const (
// INVALID is used for a Value with no value set.
INVALID Type = iota
// BOOL is a boolean Type Value.
BOOL
// INT64 is a 64-bit signed integral Type Value.
INT64
// FLOAT64 is a 64-bit floating point Type Value.
FLOAT64
// STRING is a string Type Value.
STRING
// BOOLSLICE is a slice of booleans Type Value.
BOOLSLICE
// INT64SLICE is a slice of 64-bit signed integral numbers Type Value.
INT64SLICE
// FLOAT64SLICE is a slice of 64-bit floating point numbers Type Value.
FLOAT64SLICE
// STRINGSLICE is a slice of strings Type Value.
STRINGSLICE
// UINT64 is a 64-bit unsigned integral Type Value.
//
// This type is intentionally not exposed through the Builder API.
UINT64
)
// BoolValue creates a BOOL Value.
func BoolValue(v bool) Value {
return Value{
vtype: BOOL,
numeric: boolToRaw(v),
}
}
// BoolSliceValue creates a BOOLSLICE Value.
func BoolSliceValue(v []bool) Value {
cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[bool]())).Elem()
reflect.Copy(cp, reflect.ValueOf(v))
return Value{vtype: BOOLSLICE, slice: cp.Interface()}
}
// IntValue creates an INT64 Value.
func IntValue(v int) Value {
return Int64Value(int64(v))
}
// IntSliceValue creates an INTSLICE Value.
func IntSliceValue(v []int) Value {
cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[int64]()))
for i, val := range v {
cp.Elem().Index(i).SetInt(int64(val))
}
return Value{
vtype: INT64SLICE,
slice: cp.Elem().Interface(),
}
}
// Int64Value creates an INT64 Value.
func Int64Value(v int64) Value {
return Value{
vtype: INT64,
numeric: int64ToRaw(v),
}
}
// Int64SliceValue creates an INT64SLICE Value.
func Int64SliceValue(v []int64) Value {
cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[int64]())).Elem()
reflect.Copy(cp, reflect.ValueOf(v))
return Value{vtype: INT64SLICE, slice: cp.Interface()}
}
// Float64Value creates a FLOAT64 Value.
func Float64Value(v float64) Value {
return Value{
vtype: FLOAT64,
numeric: float64ToRaw(v),
}
}
// Float64SliceValue creates a FLOAT64SLICE Value.
func Float64SliceValue(v []float64) Value {
cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[float64]())).Elem()
reflect.Copy(cp, reflect.ValueOf(v))
return Value{vtype: FLOAT64SLICE, slice: cp.Interface()}
}
// StringValue creates a STRING Value.
func StringValue(v string) Value {
return Value{
vtype: STRING,
stringly: v,
}
}
// StringSliceValue creates a STRINGSLICE Value.
func StringSliceValue(v []string) Value {
cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[string]())).Elem()
reflect.Copy(cp, reflect.ValueOf(v))
return Value{vtype: STRINGSLICE, slice: cp.Interface()}
}
// Uint64Value creates a UINT64 Value.
//
// This constructor is intentionally not exposed through the Builder API.
func Uint64Value(v uint64) Value {
return Value{
vtype: UINT64,
numeric: v,
}
}
// Type returns a type of the Value.
func (v Value) Type() Type {
return v.vtype
}
// AsBool returns the bool value. Make sure that the Value's type is
// BOOL.
func (v Value) AsBool() bool {
return rawToBool(v.numeric)
}
// AsBoolSlice returns the []bool value. Make sure that the Value's type is
// BOOLSLICE.
func (v Value) AsBoolSlice() []bool {
if v.vtype != BOOLSLICE {
return nil
}
return asSlice[bool](v.slice)
}
// AsInt64 returns the int64 value. Make sure that the Value's type is
// INT64.
func (v Value) AsInt64() int64 {
return rawToInt64(v.numeric)
}
// AsInt64Slice returns the []int64 value. Make sure that the Value's type is
// INT64SLICE.
func (v Value) AsInt64Slice() []int64 {
if v.vtype != INT64SLICE {
return nil
}
return asSlice[int64](v.slice)
}
// AsFloat64 returns the float64 value. Make sure that the Value's
// type is FLOAT64.
func (v Value) AsFloat64() float64 {
return rawToFloat64(v.numeric)
}
// AsFloat64Slice returns the []float64 value. Make sure that the Value's type is
// FLOAT64SLICE.
func (v Value) AsFloat64Slice() []float64 {
if v.vtype != FLOAT64SLICE {
return nil
}
return asSlice[float64](v.slice)
}
// AsString returns the string value. Make sure that the Value's type
// is STRING.
func (v Value) AsString() string {
return v.stringly
}
// AsStringSlice returns the []string value. Make sure that the Value's type is
// STRINGSLICE.
func (v Value) AsStringSlice() []string {
if v.vtype != STRINGSLICE {
return nil
}
return asSlice[string](v.slice)
}
// AsUint64 returns the uint64 value. Make sure that the Value's type is
// UINT64.
func (v Value) AsUint64() uint64 {
return v.numeric
}
type unknownValueType struct{}
// AsInterface returns Value's data as interface{}.
func (v Value) AsInterface() interface{} {
switch v.Type() {
case BOOL:
return v.AsBool()
case BOOLSLICE:
return v.AsBoolSlice()
case INT64:
return v.AsInt64()
case INT64SLICE:
return v.AsInt64Slice()
case FLOAT64:
return v.AsFloat64()
case FLOAT64SLICE:
return v.AsFloat64Slice()
case STRING:
return v.stringly
case STRINGSLICE:
return v.AsStringSlice()
case UINT64:
return v.numeric
}
return unknownValueType{}
}
// String returns a string representation of Value's data.
func (v Value) String() string {
switch v.Type() {
case BOOLSLICE:
return fmt.Sprint(v.AsBoolSlice())
case BOOL:
return strconv.FormatBool(v.AsBool())
case INT64SLICE:
return fmt.Sprint(v.AsInt64Slice())
case INT64:
return strconv.FormatInt(v.AsInt64(), 10)
case FLOAT64SLICE:
return fmt.Sprint(v.AsFloat64Slice())
case FLOAT64:
return fmt.Sprint(v.AsFloat64())
case STRINGSLICE:
return fmt.Sprint(v.AsStringSlice())
case STRING:
return v.stringly
case UINT64:
return strconv.FormatUint(v.numeric, 10)
default:
return "unknown"
}
}
// MarshalJSON returns the JSON encoding of the Value.
func (v Value) MarshalJSON() ([]byte, error) {
var jsonVal struct {
Value any `json:"value"`
Type string `json:"type"`
}
jsonVal.Type = mapTypesToStr[v.Type()]
jsonVal.Value = v.AsInterface()
return json.Marshal(jsonVal)
}
func (t Type) String() string {
switch t {
case BOOL:
return "bool"
case BOOLSLICE:
return "boolslice"
case INT64:
return "int64"
case INT64SLICE:
return "int64slice"
case FLOAT64:
return "float64"
case FLOAT64SLICE:
return "float64slice"
case STRING:
return "string"
case STRINGSLICE:
return "stringslice"
case UINT64:
return "uint64"
}
return "invalid"
}
// mapTypesToStr is a map from attribute.Type to the primitive types the server understands.
// https://develop.sentry.dev/sdk/foundations/data-model/attributes/#primitive-types
var mapTypesToStr = map[Type]string{
INVALID: "",
BOOL: "boolean",
INT64: "integer",
FLOAT64: "double",
STRING: "string",
BOOLSLICE: "array",
INT64SLICE: "array",
FLOAT64SLICE: "array",
STRINGSLICE: "array",
UINT64: "integer", // wire format: same "integer" type
}
+52
View File
@@ -0,0 +1,52 @@
package sentry
import (
"fmt"
"strings"
"github.com/getsentry/sentry-go/internal/debuglog"
"github.com/getsentry/sentry-go/internal/otel/baggage"
)
// MergeBaggage merges an existing baggage header with a Sentry-generated one.
//
// Existing third-party members are preserved. If both baggage strings contain
// the same member key, the Sentry-generated member wins. The helper is best-effort
// and only keeps the sentry baggage in case the existing one is malformed.
func MergeBaggage(existingHeader, sentryHeader string) (string, error) {
// TODO: we are reparsing the headers here, because we currently don't
// expose a method to get only DSC or its baggage members.
sentryBaggage, err := baggage.Parse(sentryHeader)
if err != nil {
return "", fmt.Errorf("cannot parse sentryHeader: %w", err)
}
existingBaggage, err := baggage.Parse(existingHeader)
if err != nil {
if sentryBaggage.Len() == 0 {
return "", fmt.Errorf("cannot parse existingHeader: %w", err)
}
// in case that the incoming header is malformed we should only
// care about merging sentry related baggage information for distributed tracing.
debuglog.Printf("malformed incoming header: %v", err)
return sentryBaggage.String(), nil
}
sentryKeys := make(map[string]struct{}, sentryBaggage.Len())
for _, member := range sentryBaggage.Members() {
sentryKeys[member.Key()] = struct{}{}
}
parts := make([]string, 0, sentryBaggage.Len()+existingBaggage.Len())
if s := sentryBaggage.String(); s != "" {
parts = append(parts, s)
}
for _, member := range existingBaggage.Members() {
if _, collides := sentryKeys[member.Key()]; collides {
continue
}
parts = append(parts, member.String())
}
return strings.Join(parts, ","), nil
}
+136
View File
@@ -0,0 +1,136 @@
package sentry
import (
"context"
"sync"
"time"
)
const (
batchSize = 100
defaultBatchTimeout = 5 * time.Second
)
type batchProcessor[T any] struct {
sendBatch func([]T)
itemCh chan T
flushCh chan chan struct{}
cancel context.CancelFunc
wg sync.WaitGroup
startOnce sync.Once
shutdownOnce sync.Once
batchTimeout time.Duration
}
func newBatchProcessor[T any](sendBatch func([]T)) *batchProcessor[T] {
return &batchProcessor[T]{
itemCh: make(chan T, batchSize),
flushCh: make(chan chan struct{}),
sendBatch: sendBatch,
batchTimeout: defaultBatchTimeout,
}
}
// WithBatchTimeout sets a custom batch timeout for the processor.
// This is useful for testing or when different timing behavior is needed.
func (p *batchProcessor[T]) WithBatchTimeout(timeout time.Duration) *batchProcessor[T] {
p.batchTimeout = timeout
return p
}
func (p *batchProcessor[T]) Send(item T) bool {
select {
case p.itemCh <- item:
return true
default:
return false
}
}
func (p *batchProcessor[T]) Start() {
p.startOnce.Do(func() {
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored in p.cancel and called in Shutdown()
p.cancel = cancel
p.wg.Add(1)
go p.run(ctx)
})
}
func (p *batchProcessor[T]) Flush(timeout <-chan struct{}) {
done := make(chan struct{})
select {
case p.flushCh <- done:
select {
case <-done:
case <-timeout:
}
case <-timeout:
}
}
func (p *batchProcessor[T]) Shutdown() {
p.shutdownOnce.Do(func() {
if p.cancel != nil {
p.cancel()
p.wg.Wait()
}
})
}
func (p *batchProcessor[T]) run(ctx context.Context) {
defer p.wg.Done()
var items []T
timer := time.NewTimer(0)
timer.Stop()
defer timer.Stop()
for {
select {
case item := <-p.itemCh:
if len(items) == 0 {
timer.Reset(p.batchTimeout)
}
items = append(items, item)
if len(items) >= batchSize {
p.sendBatch(items)
items = nil
}
case <-timer.C:
if len(items) > 0 {
p.sendBatch(items)
items = nil
}
case done := <-p.flushCh:
flushDrain:
for {
select {
case item := <-p.itemCh:
items = append(items, item)
default:
break flushDrain
}
}
if len(items) > 0 {
p.sendBatch(items)
items = nil
}
close(done)
case <-ctx.Done():
drain:
for {
select {
case item := <-p.itemCh:
items = append(items, item)
default:
break drain
}
}
if len(items) > 0 {
p.sendBatch(items)
}
return
}
}
}
+121
View File
@@ -0,0 +1,121 @@
package sentry
import "time"
type CheckInStatus string
const (
CheckInStatusInProgress CheckInStatus = "in_progress"
CheckInStatusOK CheckInStatus = "ok"
CheckInStatusError CheckInStatus = "error"
)
type checkInScheduleType string
const (
checkInScheduleTypeCrontab checkInScheduleType = "crontab"
checkInScheduleTypeInterval checkInScheduleType = "interval"
)
type MonitorSchedule interface {
// scheduleType is a private method that must be implemented for monitor schedule
// implementation. It should never be called. This method is made for having
// specific private implementation of MonitorSchedule interface.
scheduleType() checkInScheduleType
}
type crontabSchedule struct {
Type string `json:"type"`
Value string `json:"value"`
}
func (c crontabSchedule) scheduleType() checkInScheduleType {
return checkInScheduleTypeCrontab
}
// CrontabSchedule defines the MonitorSchedule with a cron format.
// Example: "8 * * * *".
func CrontabSchedule(scheduleString string) MonitorSchedule {
return crontabSchedule{
Type: string(checkInScheduleTypeCrontab),
Value: scheduleString,
}
}
type intervalSchedule struct {
Type string `json:"type"`
Value int64 `json:"value"`
Unit string `json:"unit"`
}
func (i intervalSchedule) scheduleType() checkInScheduleType {
return checkInScheduleTypeInterval
}
type MonitorScheduleUnit string
const (
MonitorScheduleUnitMinute MonitorScheduleUnit = "minute"
MonitorScheduleUnitHour MonitorScheduleUnit = "hour"
MonitorScheduleUnitDay MonitorScheduleUnit = "day"
MonitorScheduleUnitWeek MonitorScheduleUnit = "week"
MonitorScheduleUnitMonth MonitorScheduleUnit = "month"
MonitorScheduleUnitYear MonitorScheduleUnit = "year"
)
// IntervalSchedule defines the MonitorSchedule with an interval format.
//
// Example:
//
// IntervalSchedule(1, sentry.MonitorScheduleUnitDay)
func IntervalSchedule(value int64, unit MonitorScheduleUnit) MonitorSchedule {
return intervalSchedule{
Type: string(checkInScheduleTypeInterval),
Value: value,
Unit: string(unit),
}
}
type MonitorConfig struct { //nolint: maligned // prefer readability over optimal memory layout
Schedule MonitorSchedule `json:"schedule,omitempty"`
// The allowed margin of minutes after the expected check-in time that
// the monitor will not be considered missed for.
CheckInMargin int64 `json:"checkin_margin,omitempty"`
// The allowed duration in minutes that the monitor may be `in_progress`
// for before being considered failed due to timeout.
MaxRuntime int64 `json:"max_runtime,omitempty"`
// A tz database string representing the timezone which the monitor's execution schedule is in.
// See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
Timezone string `json:"timezone,omitempty"`
// The number of consecutive failed check-ins it takes before an issue is created.
FailureIssueThreshold int64 `json:"failure_issue_threshold,omitempty"`
// The number of consecutive OK check-ins it takes before an issue is resolved.
RecoveryThreshold int64 `json:"recovery_threshold,omitempty"`
}
type CheckIn struct { //nolint: maligned // prefer readability over optimal memory layout
// Check-In ID (unique and client generated)
ID EventID `json:"check_in_id"`
// The distinct slug of the monitor.
MonitorSlug string `json:"monitor_slug"`
// The status of the check-in.
Status CheckInStatus `json:"status"`
// The duration of the check-in. Will only take effect if the status is ok or error.
Duration time.Duration `json:"duration,omitempty"`
}
// serializedCheckIn is used by checkInMarshalJSON method on Event struct.
// See https://develop.sentry.dev/sdk/check-ins/
type serializedCheckIn struct { //nolint: maligned
// Check-In ID (unique and client generated).
CheckInID string `json:"check_in_id"`
// The distinct slug of the monitor.
MonitorSlug string `json:"monitor_slug"`
// The status of the check-in.
Status CheckInStatus `json:"status"`
// The duration of the check-in in seconds. Will only take effect if the status is ok or error.
Duration float64 `json:"duration,omitempty"`
Release string `json:"release,omitempty"`
Environment string `json:"environment,omitempty"`
MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
}
+1056
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
/*
Package repository: https://github.com/getsentry/sentry-go/
For more information about Sentry and SDK features, please have a look at the official documentation site: https://docs.sentry.io/platforms/go/
*/
package sentry
+37
View File
@@ -0,0 +1,37 @@
package sentry
import (
"github.com/getsentry/sentry-go/internal/protocol"
)
// Re-export protocol types to maintain public API compatibility
// Dsn is used as the remote address source to client transport.
type Dsn struct {
protocol.Dsn
}
// DsnParseError represents an error that occurs if a Sentry
// DSN cannot be parsed.
type DsnParseError = protocol.DsnParseError
// 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) {
protocolDsn, err := protocol.NewDsn(rawURL)
if err != nil {
return nil, err
}
return &Dsn{Dsn: *protocolDsn}, nil
}
// 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() map[string]string {
return dsn.Dsn.RequestHeaders(SDKVersion)
}
+160
View File
@@ -0,0 +1,160 @@
package sentry
import (
"strconv"
"strings"
"github.com/getsentry/sentry-go/internal/otel/baggage"
)
const (
sentryPrefix = "sentry-"
)
// DynamicSamplingContext holds information about the current event that can be used to make dynamic sampling decisions.
type DynamicSamplingContext struct {
Entries map[string]string
Frozen bool
}
func DynamicSamplingContextFromHeader(header []byte) (DynamicSamplingContext, error) {
bag, err := baggage.Parse(string(header))
if err != nil {
return DynamicSamplingContext{}, err
}
entries := map[string]string{}
for _, member := range bag.Members() {
// We only store baggage members if their key starts with "sentry-".
if k, v := member.Key(), member.Value(); strings.HasPrefix(k, sentryPrefix) {
entries[strings.TrimPrefix(k, sentryPrefix)] = v
}
}
return DynamicSamplingContext{
Entries: entries,
// If there's at least one Sentry value, we consider the DSC frozen
Frozen: len(entries) > 0,
}, nil
}
func DynamicSamplingContextFromTransaction(span *Span) DynamicSamplingContext {
hub := hubFromContext(span.Context())
scope := hub.Scope()
client := hub.Client()
if client == nil || scope == nil {
return DynamicSamplingContext{
Entries: map[string]string{},
Frozen: false,
}
}
entries := make(map[string]string)
if traceID := span.TraceID.String(); traceID != "" {
entries["trace_id"] = traceID
}
if sampleRate := span.sampleRate; sampleRate != 0 {
entries["sample_rate"] = strconv.FormatFloat(sampleRate, 'f', -1, 64)
}
if dsn := client.dsn; dsn != nil {
if publicKey := dsn.GetPublicKey(); publicKey != "" {
entries["public_key"] = publicKey
}
if orgID := dsn.GetOrgID(); orgID != 0 {
entries["org_id"] = strconv.FormatUint(orgID, 10)
}
}
if release := client.options.Release; release != "" {
entries["release"] = release
}
if environment := client.options.Environment; environment != "" {
entries["environment"] = environment
}
// Only include the transaction name if it's of good quality (not empty and not SourceURL)
if span.Source != "" && span.Source != SourceURL {
if span.IsTransaction() {
entries["transaction"] = span.Name
}
}
entries["sampled"] = strconv.FormatBool(span.Sampled.Bool())
return DynamicSamplingContext{Entries: entries, Frozen: true}
}
func (d DynamicSamplingContext) HasEntries() bool {
return len(d.Entries) > 0
}
func (d DynamicSamplingContext) IsFrozen() bool {
return d.Frozen
}
func (d DynamicSamplingContext) String() string {
members := []baggage.Member{}
for k, entry := range d.Entries {
member, err := baggage.NewMember(sentryPrefix+k, entry)
if err != nil {
continue
}
members = append(members, member)
}
if len(members) == 0 {
return ""
}
baggage, err := baggage.New(members...)
if err != nil {
return ""
}
return baggage.String()
}
// DynamicSamplingContextFromScope Constructs a new DynamicSamplingContext using a scope and client. Accessing
// fields on the scope are not thread safe, and this function should only be
// called within scope methods.
func DynamicSamplingContextFromScope(scope *Scope, client *Client) DynamicSamplingContext {
entries := map[string]string{}
if client == nil || scope == nil {
return DynamicSamplingContext{
Entries: entries,
Frozen: false,
}
}
propagationContext := scope.propagationContext
if traceID := propagationContext.TraceID.String(); traceID != "" {
entries["trace_id"] = traceID
}
if sampleRate := client.options.TracesSampleRate; sampleRate != 0 {
entries["sample_rate"] = strconv.FormatFloat(sampleRate, 'f', -1, 64)
}
if dsn := client.dsn; dsn != nil {
if publicKey := dsn.GetPublicKey(); publicKey != "" {
entries["public_key"] = publicKey
}
if orgID := dsn.GetOrgID(); orgID != 0 {
entries["org_id"] = strconv.FormatUint(orgID, 10)
}
}
if release := client.options.Release; release != "" {
entries["release"] = release
}
if environment := client.options.Environment; environment != "" {
entries["environment"] = environment
}
return DynamicSamplingContext{
Entries: entries,
Frozen: true,
}
}
+129
View File
@@ -0,0 +1,129 @@
package sentry
import (
"fmt"
"reflect"
"slices"
)
const (
MechanismTypeGeneric string = "generic"
MechanismTypeChained string = "chained"
MechanismTypeUnwrap string = "unwrap"
MechanismSourceCause string = "cause"
)
type visited struct {
ptrs map[uintptr]struct{}
msgs map[string]struct{}
}
func (v *visited) seenError(err error) bool {
t := reflect.ValueOf(err)
if t.Kind() == reflect.Ptr && !t.IsNil() {
ptr := t.Pointer()
if _, ok := v.ptrs[ptr]; ok {
return true
}
v.ptrs[ptr] = struct{}{}
return false
}
key := t.String() + err.Error()
if _, ok := v.msgs[key]; ok {
return true
}
v.msgs[key] = struct{}{}
return false
}
func convertErrorToExceptions(err error, maxErrorDepth int) []Exception {
var exceptions []Exception
vis := &visited{
ptrs: make(map[uintptr]struct{}),
msgs: make(map[string]struct{}),
}
convertErrorDFS(err, &exceptions, nil, "", vis, maxErrorDepth, 0)
// mechanism type is used for debugging purposes, but since we can't really distinguish the origin of who invoked
// captureException, we set it to nil if the error is not chained.
if len(exceptions) == 1 {
exceptions[0].Mechanism = nil
}
slices.Reverse(exceptions)
// Add a trace of the current stack to the top level(outermost) error in a chain if
// it doesn't have a stack trace yet.
// We only add to the most recent error to avoid duplication and because the
// current stack is most likely unrelated to errors deeper in the chain.
if len(exceptions) > 0 && exceptions[len(exceptions)-1].Stacktrace == nil {
exceptions[len(exceptions)-1].Stacktrace = NewStacktrace()
}
return exceptions
}
func convertErrorDFS(err error, exceptions *[]Exception, parentID *int, source string, visited *visited, maxErrorDepth int, currentDepth int) {
if err == nil {
return
}
if visited.seenError(err) {
return
}
_, isExceptionGroup := err.(interface{ Unwrap() []error })
exception := Exception{
Value: err.Error(),
Type: reflect.TypeOf(err).String(),
Stacktrace: ExtractStacktrace(err),
}
currentID := len(*exceptions)
var mechanismType string
if parentID == nil {
mechanismType = MechanismTypeGeneric
source = ""
} else {
mechanismType = MechanismTypeChained
}
exception.Mechanism = &Mechanism{
Type: mechanismType,
ExceptionID: currentID,
ParentID: parentID,
Source: source,
IsExceptionGroup: isExceptionGroup,
}
*exceptions = append(*exceptions, exception)
if maxErrorDepth >= 0 && currentDepth >= maxErrorDepth {
return
}
switch v := err.(type) {
case interface{ Unwrap() []error }:
unwrapped := v.Unwrap()
for i := range unwrapped {
if unwrapped[i] != nil {
childSource := fmt.Sprintf("errors[%d]", i)
convertErrorDFS(unwrapped[i], exceptions, &currentID, childSource, visited, maxErrorDepth, currentDepth+1)
}
}
case interface{ Unwrap() error }:
unwrapped := v.Unwrap()
if unwrapped != nil {
convertErrorDFS(unwrapped, exceptions, &currentID, MechanismTypeUnwrap, visited, maxErrorDepth, currentDepth+1)
}
case interface{ Cause() error }:
cause := v.Cause()
if cause != nil {
convertErrorDFS(cause, exceptions, &currentID, MechanismSourceCause, visited, maxErrorDepth, currentDepth+1)
}
}
}
+450
View File
@@ -0,0 +1,450 @@
package sentry
import (
"context"
"fmt"
"sync"
"time"
"github.com/getsentry/sentry-go/internal/debuglog"
)
type contextKey int
// Keys used to store values in a Context. Use with Context.Value to access
// values stored by the SDK.
const (
// HubContextKey is the key used to store the current Hub.
HubContextKey = contextKey(1)
// RequestContextKey is the key used to store the current http.Request.
RequestContextKey = contextKey(2)
)
// currentHub is the initial Hub with no Client bound and an empty Scope.
var currentHub = NewHub(nil, NewScope())
// Hub is the central object that manages scopes and clients.
//
// This can be used to capture events and manage the scope.
// The default hub that is available automatically.
//
// In most situations developers do not need to interface the hub. Instead
// toplevel convenience functions are exposed that will automatically dispatch
// to global (CurrentHub) hub. In some situations this might not be
// possible in which case it might become necessary to manually work with the
// hub. This is for instance the case when working with async code.
type Hub struct {
mu sync.RWMutex
stack *stack
lastEventID EventID
}
type layer struct {
// mu protects concurrent reads and writes to client.
mu sync.RWMutex
client *Client
// scope is read-only, not protected by mu.
scope *Scope
}
// Client returns the layer's client. Safe for concurrent use.
func (l *layer) Client() *Client {
l.mu.RLock()
defer l.mu.RUnlock()
return l.client
}
// SetClient sets the layer's client. Safe for concurrent use.
func (l *layer) SetClient(c *Client) {
l.mu.Lock()
defer l.mu.Unlock()
l.client = c
}
type stack []*layer
// NewHub returns an instance of a Hub with provided Client and Scope bound.
func NewHub(client *Client, scope *Scope) *Hub {
hub := Hub{
stack: &stack{{
client: client,
scope: scope,
}},
}
return &hub
}
// CurrentHub returns an instance of previously initialized Hub stored in the global namespace.
func CurrentHub() *Hub {
return currentHub
}
// LastEventID returns the ID of the last event (error or message) captured
// through the hub and sent to the underlying transport.
//
// Transactions and events dropped by sampling or event processors do not change
// the last event ID.
//
// LastEventID is a convenience method to cover use cases in which errors are
// captured indirectly and the ID is needed. For example, it can be used as part
// of an HTTP middleware to log the ID of the last error, if any.
//
// For more flexibility, consider instead using the ClientOptions.BeforeSend
// function or event processors.
func (hub *Hub) LastEventID() EventID {
hub.mu.RLock()
defer hub.mu.RUnlock()
return hub.lastEventID
}
// stackTop returns the top layer of the hub stack. Valid hubs always have at
// least one layer, therefore stackTop always return a non-nil pointer.
func (hub *Hub) stackTop() *layer {
hub.mu.RLock()
defer hub.mu.RUnlock()
stack := hub.stack
stackLen := len(*stack)
top := (*stack)[stackLen-1]
return top
}
// Clone returns a copy of the current Hub with top-most scope and client copied over.
func (hub *Hub) Clone() *Hub {
top := hub.stackTop()
scope := top.scope
if scope != nil {
scope = scope.Clone()
}
return NewHub(top.Client(), scope)
}
// Scope returns top-level Scope of the current Hub or nil if no Scope is bound.
func (hub *Hub) Scope() *Scope {
top := hub.stackTop()
return top.scope
}
// Client returns top-level Client of the current Hub or nil if no Client is bound.
func (hub *Hub) Client() *Client {
top := hub.stackTop()
return top.Client()
}
// PushScope pushes a new scope for the current Hub and reuses previously bound Client.
func (hub *Hub) PushScope() *Scope {
top := hub.stackTop()
var scope *Scope
if top.scope != nil {
scope = top.scope.Clone()
} else {
scope = NewScope()
}
hub.mu.Lock()
defer hub.mu.Unlock()
*hub.stack = append(*hub.stack, &layer{
client: top.Client(),
scope: scope,
})
return scope
}
// PopScope drops the most recent scope.
//
// Calls to PopScope must be coordinated with PushScope. For most cases, using
// WithScope should be more convenient.
//
// Calls to PopScope that do not match previous calls to PushScope are silently
// ignored.
func (hub *Hub) PopScope() {
hub.mu.Lock()
defer hub.mu.Unlock()
stack := *hub.stack
stackLen := len(stack)
if stackLen > 1 {
// Never pop the last item off the stack, the stack should always have
// at least one item.
*hub.stack = stack[0 : stackLen-1]
}
}
// BindClient binds a new Client for the current Hub.
func (hub *Hub) BindClient(client *Client) {
top := hub.stackTop()
top.SetClient(client)
}
// WithScope runs f in an isolated temporary scope.
//
// It is useful when extra data should be sent with a single capture call, for
// instance a different level or tags.
//
// The scope passed to f starts as a clone of the current scope and can be
// freely modified without affecting the current scope.
//
// It is a shorthand for PushScope followed by PopScope.
func (hub *Hub) WithScope(f func(scope *Scope)) {
scope := hub.PushScope()
defer hub.PopScope()
f(scope)
}
// ConfigureScope runs f in the current scope.
//
// It is useful to set data that applies to all events that share the current
// scope.
//
// Modifying the scope affects all references to the current scope.
//
// See also WithScope for making isolated temporary changes.
func (hub *Hub) ConfigureScope(f func(scope *Scope)) {
scope := hub.Scope()
f(scope)
}
// CaptureEvent calls the method of a same name on currently bound Client instance
// passing it a top-level Scope.
// Returns EventID if successfully, or nil if there's no Scope or Client available.
func (hub *Hub) CaptureEvent(event *Event) *EventID {
return hub.CaptureEventWithHint(event, nil)
}
// CaptureEventWithHint is like CaptureEvent but additionally accepts an EventHint.
func (hub *Hub) CaptureEventWithHint(event *Event, hint *EventHint) *EventID {
client, scope := hub.Client(), hub.Scope()
if client == nil || scope == nil {
return nil
}
eventID := client.CaptureEvent(event, hint, scope)
if event.Type != transactionType && eventID != nil {
hub.mu.Lock()
hub.lastEventID = *eventID
hub.mu.Unlock()
}
return eventID
}
// CaptureMessage calls the method of a same name on currently bound Client instance
// passing it a top-level Scope.
// Returns EventID if successfully, or nil if there's no Scope or Client available.
func (hub *Hub) CaptureMessage(message string) *EventID {
client, scope := hub.Client(), hub.Scope()
if client == nil || scope == nil {
return nil
}
eventID := client.CaptureMessage(message, nil, scope)
if eventID != nil {
hub.mu.Lock()
hub.lastEventID = *eventID
hub.mu.Unlock()
}
return eventID
}
// CaptureException calls the method of a same name on currently bound Client instance
// passing it a top-level Scope.
// Returns EventID if successfully, or nil if there's no Scope or Client available.
func (hub *Hub) CaptureException(exception error) *EventID {
client, scope := hub.Client(), hub.Scope()
if client == nil || scope == nil {
return nil
}
eventID := client.CaptureException(exception, &EventHint{OriginalException: exception}, scope)
if eventID != nil {
hub.mu.Lock()
hub.lastEventID = *eventID
hub.mu.Unlock()
}
return eventID
}
// CaptureCheckIn calls the method of the same name on currently bound Client instance
// passing it a top-level Scope.
// Returns CheckInID if the check-in was captured successfully, or nil otherwise.
func (hub *Hub) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *EventID {
client, scope := hub.Client(), hub.Scope()
if client == nil {
return nil
}
return client.CaptureCheckIn(checkIn, monitorConfig, scope)
}
// AddBreadcrumb records a new breadcrumb.
//
// The total number of breadcrumbs that can be recorded are limited by the
// configuration on the client.
func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *BreadcrumbHint) {
client := hub.Client()
// If there's no client, just store it on the scope straight away
if client == nil {
hub.Scope().AddBreadcrumb(breadcrumb, defaultMaxBreadcrumbs)
return
}
limit := client.options.MaxBreadcrumbs
switch {
case limit < 0:
return
case limit == 0:
limit = defaultMaxBreadcrumbs
}
if client.options.BeforeBreadcrumb != nil {
if hint == nil {
hint = &BreadcrumbHint{}
}
if breadcrumb = client.options.BeforeBreadcrumb(breadcrumb, hint); breadcrumb == nil {
debuglog.Println("breadcrumb dropped due to BeforeBreadcrumb callback.")
return
}
}
hub.Scope().AddBreadcrumb(breadcrumb, limit)
}
// Recover calls the method of a same name on currently bound Client instance
// passing it a top-level Scope.
// Returns EventID if successfully, or nil if there's no Scope or Client available.
func (hub *Hub) Recover(err interface{}) *EventID {
if err == nil {
err = recover()
}
client, scope := hub.Client(), hub.Scope()
if client == nil || scope == nil {
return nil
}
return client.Recover(err, &EventHint{RecoveredException: err}, scope)
}
// RecoverWithContext calls the method of a same name on currently bound Client instance
// passing it a top-level Scope.
// Returns EventID if successfully, or nil if there's no Scope or Client available.
func (hub *Hub) RecoverWithContext(ctx context.Context, err interface{}) *EventID {
if err == nil {
err = recover()
}
client, scope := hub.Client(), hub.Scope()
if client == nil || scope == nil {
return nil
}
return client.RecoverWithContext(ctx, err, &EventHint{RecoveredException: err}, scope)
}
// Flush waits until the underlying Transport sends any buffered events to the
// Sentry server, blocking for at most the given timeout. It returns false if
// the timeout was reached. In that case, some events may not have been sent.
//
// Flush should be called before terminating the program to avoid
// unintentionally dropping events.
//
// Do not call Flush indiscriminately after every call to CaptureEvent,
// CaptureException or CaptureMessage. Instead, to have the SDK send events over
// the network synchronously, configure it to use the HTTPSyncTransport in the
// call to Init.
func (hub *Hub) Flush(timeout time.Duration) bool {
client := hub.Client()
if client == nil {
return false
}
return client.Flush(timeout)
}
// FlushWithContext waits until the underlying Transport sends any buffered events
// to the Sentry server, blocking for at most the duration specified by the context.
// It returns false if the context is canceled before the events are sent. In such a case,
// some events may not be delivered.
//
// FlushWithContext should be called before terminating the program to ensure no
// events are unintentionally dropped.
//
// Avoid calling FlushWithContext indiscriminately after each call to CaptureEvent,
// CaptureException, or CaptureMessage. To send events synchronously over the network,
// configure the SDK to use HTTPSyncTransport during initialization with Init.
func (hub *Hub) FlushWithContext(ctx context.Context) bool {
client := hub.Client()
if client == nil {
return false
}
return client.FlushWithContext(ctx)
}
// GetTraceparent returns the current Sentry traceparent string, to be used as a HTTP header value
// or HTML meta tag value.
// This function is context aware, as in it either returns the traceparent based
// on the current span, or the scope's propagation context.
func (hub *Hub) GetTraceparent() string {
scope := hub.Scope()
if span := scope.GetSpan(); span != nil {
return span.ToSentryTrace()
}
propagationContext := scope.propagationContextSnapshot()
return fmt.Sprintf("%s-%s", propagationContext.TraceID, propagationContext.SpanID)
}
// GetTraceparentW3C returns the current traceparent string in W3C format.
// This is intended for propagation to downstream services that expect the W3C header.
func (hub *Hub) GetTraceparentW3C() string {
scope := hub.Scope()
if span := scope.GetSpan(); span != nil {
return span.ToTraceparent()
}
propagationContext := scope.propagationContextSnapshot()
return fmt.Sprintf("00-%s-%s-00", propagationContext.TraceID, propagationContext.SpanID)
}
// GetBaggage returns the current Sentry baggage string, to be used as a HTTP header value
// or HTML meta tag value.
// This function is context aware, as in it either returns the baggage based
// on the current span or the scope's propagation context.
func (hub *Hub) GetBaggage() string {
scope := hub.Scope()
if span := scope.GetSpan(); span != nil {
return span.ToBaggage()
}
return scope.propagationContextSnapshot().DynamicSamplingContext.String()
}
// HasHubOnContext checks whether Hub instance is bound to a given Context struct.
func HasHubOnContext(ctx context.Context) bool {
_, ok := ctx.Value(HubContextKey).(*Hub)
return ok
}
// GetHubFromContext tries to retrieve Hub instance from the given Context struct
// or return nil if one is not found.
func GetHubFromContext(ctx context.Context) *Hub {
if hub, ok := ctx.Value(HubContextKey).(*Hub); ok {
return hub
}
return nil
}
// hubFromContext returns either a hub stored in the context or the current hub.
// The return value is guaranteed to be non-nil, unlike GetHubFromContext.
func hubFromContext(ctx context.Context) *Hub {
if hub, ok := ctx.Value(HubContextKey).(*Hub); ok {
return hub
}
return currentHub
}
// SetHubOnContext stores given Hub instance on the Context struct and returns a new Context.
func SetHubOnContext(ctx context.Context, hub *Hub) context.Context {
return context.WithValue(ctx, HubContextKey, hub)
}
+393
View File
@@ -0,0 +1,393 @@
package sentry
import (
"fmt"
"os"
"regexp"
"runtime"
"runtime/debug"
"strings"
"sync"
"github.com/getsentry/sentry-go/internal/debuglog"
)
// ================================
// Modules Integration
// ================================
type modulesIntegration struct {
once sync.Once
modules map[string]string
}
func (mi *modulesIntegration) Name() string {
return "Modules"
}
func (mi *modulesIntegration) SetupOnce(client *Client) {
client.AddEventProcessor(mi.processor)
}
func (mi *modulesIntegration) processor(event *Event, _ *EventHint) *Event {
if len(event.Modules) == 0 {
mi.once.Do(func() {
info, ok := debug.ReadBuildInfo()
if !ok {
debuglog.Print("The Modules integration is not available in binaries built without module support.")
return
}
mi.modules = extractModules(info)
})
}
event.Modules = mi.modules
return event
}
func extractModules(info *debug.BuildInfo) map[string]string {
modules := map[string]string{
info.Main.Path: info.Main.Version,
}
for _, dep := range info.Deps {
ver := dep.Version
if dep.Replace != nil {
ver += fmt.Sprintf(" => %s %s", dep.Replace.Path, dep.Replace.Version)
}
modules[dep.Path] = strings.TrimSuffix(ver, " ")
}
return modules
}
// ================================
// Environment Integration
// ================================
type environmentIntegration struct{}
func (ei *environmentIntegration) Name() string {
return "Environment"
}
func (ei *environmentIntegration) SetupOnce(client *Client) {
client.AddEventProcessor(ei.processor)
}
func (ei *environmentIntegration) processor(event *Event, _ *EventHint) *Event {
// Initialize maps as necessary.
contextNames := []string{"device", "os", "runtime"}
if event.Contexts == nil {
event.Contexts = make(map[string]Context, len(contextNames))
}
for _, name := range contextNames {
if event.Contexts[name] == nil {
event.Contexts[name] = make(Context)
}
}
// Set contextual information preserving existing data. For each context, if
// the existing value is not of type map[string]interface{}, then no
// additional information is added.
if deviceContext, ok := event.Contexts["device"]; ok {
if _, ok := deviceContext["arch"]; !ok {
deviceContext["arch"] = runtime.GOARCH
}
if _, ok := deviceContext["num_cpu"]; !ok {
deviceContext["num_cpu"] = runtime.NumCPU()
}
}
if osContext, ok := event.Contexts["os"]; ok {
if _, ok := osContext["name"]; !ok {
osContext["name"] = runtime.GOOS
}
}
if runtimeContext, ok := event.Contexts["runtime"]; ok {
if _, ok := runtimeContext["name"]; !ok {
runtimeContext["name"] = "go"
}
if _, ok := runtimeContext["version"]; !ok {
runtimeContext["version"] = runtime.Version()
}
if _, ok := runtimeContext["go_numroutines"]; !ok {
runtimeContext["go_numroutines"] = runtime.NumGoroutine()
}
if _, ok := runtimeContext["go_maxprocs"]; !ok {
runtimeContext["go_maxprocs"] = runtime.GOMAXPROCS(0)
}
if _, ok := runtimeContext["go_numcgocalls"]; !ok {
runtimeContext["go_numcgocalls"] = runtime.NumCgoCall()
}
}
return event
}
// ================================
// Ignore Errors Integration
// ================================
type ignoreErrorsIntegration struct {
ignoreErrors []*regexp.Regexp
}
func (iei *ignoreErrorsIntegration) Name() string {
return "IgnoreErrors"
}
func (iei *ignoreErrorsIntegration) SetupOnce(client *Client) {
iei.ignoreErrors = transformStringsIntoRegexps(client.options.IgnoreErrors)
client.AddEventProcessor(iei.processor)
}
func (iei *ignoreErrorsIntegration) processor(event *Event, _ *EventHint) *Event {
suspects := getIgnoreErrorsSuspects(event)
for _, suspect := range suspects {
for _, pattern := range iei.ignoreErrors {
if pattern.Match([]byte(suspect)) || strings.Contains(suspect, pattern.String()) {
debuglog.Printf("Event dropped due to being matched by `IgnoreErrors` option."+
"| Value matched: %s | Filter used: %s", suspect, pattern)
return nil
}
}
}
return event
}
func transformStringsIntoRegexps(strings []string) []*regexp.Regexp {
var exprs []*regexp.Regexp
for _, s := range strings {
r, err := regexp.Compile(s)
if err == nil {
exprs = append(exprs, r)
}
}
return exprs
}
func getIgnoreErrorsSuspects(event *Event) []string {
suspects := []string{}
if event.Message != "" {
suspects = append(suspects, event.Message)
}
for _, ex := range event.Exception {
suspects = append(suspects, ex.Type, ex.Value)
}
return suspects
}
// ================================
// Ignore Transactions Integration
// ================================
type ignoreTransactionsIntegration struct {
ignoreTransactions []*regexp.Regexp
}
func (iei *ignoreTransactionsIntegration) Name() string {
return "IgnoreTransactions"
}
func (iei *ignoreTransactionsIntegration) SetupOnce(client *Client) {
iei.ignoreTransactions = transformStringsIntoRegexps(client.options.IgnoreTransactions)
client.AddEventProcessor(iei.processor)
}
func (iei *ignoreTransactionsIntegration) processor(event *Event, _ *EventHint) *Event {
suspect := event.Transaction
if suspect == "" {
return event
}
for _, pattern := range iei.ignoreTransactions {
if pattern.Match([]byte(suspect)) || strings.Contains(suspect, pattern.String()) {
debuglog.Printf("Transaction dropped due to being matched by `IgnoreTransactions` option."+
"| Value matched: %s | Filter used: %s", suspect, pattern)
return nil
}
}
return event
}
// ================================
// Contextify Frames Integration
// ================================
type contextifyFramesIntegration struct {
sr sourceReader
contextLines int
cachedLocations sync.Map
}
func (cfi *contextifyFramesIntegration) Name() string {
return "ContextifyFrames"
}
func (cfi *contextifyFramesIntegration) SetupOnce(client *Client) {
cfi.sr = newSourceReader()
cfi.contextLines = 5
client.AddEventProcessor(cfi.processor)
}
func (cfi *contextifyFramesIntegration) processor(event *Event, _ *EventHint) *Event {
// Range over all exceptions
for _, ex := range event.Exception {
// If it has no stacktrace, just bail out
if ex.Stacktrace == nil {
continue
}
// If it does, it should have frames, so try to contextify them
ex.Stacktrace.Frames = cfi.contextify(ex.Stacktrace.Frames)
}
// Range over all threads
for _, th := range event.Threads {
// If it has no stacktrace, just bail out
if th.Stacktrace == nil {
continue
}
// If it does, it should have frames, so try to contextify them
th.Stacktrace.Frames = cfi.contextify(th.Stacktrace.Frames)
}
return event
}
func (cfi *contextifyFramesIntegration) contextify(frames []Frame) []Frame {
contextifiedFrames := make([]Frame, 0, len(frames))
for _, frame := range frames {
if !frame.InApp {
contextifiedFrames = append(contextifiedFrames, frame)
continue
}
var path string
if cachedPath, ok := cfi.cachedLocations.Load(frame.AbsPath); ok {
if p, ok := cachedPath.(string); ok {
path = p
}
} else {
// Optimize for happy path here
if fileExists(frame.AbsPath) {
path = frame.AbsPath
} else {
path = cfi.findNearbySourceCodeLocation(frame.AbsPath)
}
}
if path == "" {
contextifiedFrames = append(contextifiedFrames, frame)
continue
}
lines, contextLine := cfi.sr.readContextLines(path, frame.Lineno, cfi.contextLines)
contextifiedFrames = append(contextifiedFrames, cfi.addContextLinesToFrame(frame, lines, contextLine))
}
return contextifiedFrames
}
func (cfi *contextifyFramesIntegration) findNearbySourceCodeLocation(originalPath string) string {
trimmedPath := strings.TrimPrefix(originalPath, "/")
components := strings.Split(trimmedPath, "/")
for len(components) > 0 {
components = components[1:]
possibleLocation := strings.Join(components, "/")
if fileExists(possibleLocation) {
cfi.cachedLocations.Store(originalPath, possibleLocation)
return possibleLocation
}
}
cfi.cachedLocations.Store(originalPath, "")
return ""
}
func (cfi *contextifyFramesIntegration) addContextLinesToFrame(frame Frame, lines [][]byte, contextLine int) Frame {
for i, line := range lines {
switch {
case i < contextLine:
frame.PreContext = append(frame.PreContext, string(line))
case i == contextLine:
frame.ContextLine = string(line)
default:
frame.PostContext = append(frame.PostContext, string(line))
}
}
return frame
}
// ================================
// Global Tags Integration
// ================================
const envTagsPrefix = "SENTRY_TAGS_"
type globalTagsIntegration struct {
tags map[string]string
envTags map[string]string
}
func (ti *globalTagsIntegration) Name() string {
return "GlobalTags"
}
func (ti *globalTagsIntegration) SetupOnce(client *Client) {
ti.tags = make(map[string]string, len(client.options.Tags))
for k, v := range client.options.Tags {
ti.tags[k] = v
}
ti.envTags = loadEnvTags()
client.AddEventProcessor(ti.processor)
}
func (ti *globalTagsIntegration) processor(event *Event, _ *EventHint) *Event {
if len(ti.tags) == 0 && len(ti.envTags) == 0 {
return event
}
if event.Tags == nil {
event.Tags = make(map[string]string, len(ti.tags)+len(ti.envTags))
}
for k, v := range ti.tags {
if _, ok := event.Tags[k]; !ok {
event.Tags[k] = v
}
}
for k, v := range ti.envTags {
if _, ok := event.Tags[k]; !ok {
event.Tags[k] = v
}
}
return event
}
func loadEnvTags() map[string]string {
tags := map[string]string{}
for _, pair := range os.Environ() {
parts := strings.Split(pair, "=")
if !strings.HasPrefix(parts[0], envTagsPrefix) {
continue
}
tag := strings.TrimPrefix(parts[0], envTagsPrefix)
tags[tag] = parts[1]
}
return tags
}
+935
View File
@@ -0,0 +1,935 @@
package sentry
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/getsentry/sentry-go/attribute"
"github.com/getsentry/sentry-go/internal/debuglog"
"github.com/getsentry/sentry-go/internal/protocol"
"github.com/getsentry/sentry-go/internal/ratelimit"
)
const errorType = ""
const eventType = "event"
const transactionType = "transaction"
const checkInType = "check_in"
var logEvent = struct {
Type string
ContentType string
}{
"log",
"application/vnd.sentry.items.log+json",
}
var traceMetricEvent = struct {
Type string
ContentType string
}{
"trace_metric",
"application/vnd.sentry.items.trace-metric+json",
}
// Level marks the severity of the event.
type Level string
// Describes the severity of the event.
const (
LevelDebug Level = "debug"
LevelInfo Level = "info"
LevelWarning Level = "warning"
LevelError Level = "error"
LevelFatal Level = "fatal"
)
// SdkInfo contains all metadata about the SDK.
type SdkInfo = protocol.SdkInfo
type SdkPackage = protocol.SdkPackage
// TODO: This type could be more useful, as map of interface{} is too generic
// and requires a lot of type assertions in beforeBreadcrumb calls
// plus it could just be map[string]interface{} then.
// BreadcrumbHint contains information that can be associated with a Breadcrumb.
type BreadcrumbHint map[string]interface{}
// Breadcrumb specifies an application event that occurred before a Sentry event.
// An event may contain one or more breadcrumbs.
type Breadcrumb struct {
Type string `json:"type,omitempty"`
Category string `json:"category,omitempty"`
Message string `json:"message,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Level Level `json:"level,omitempty"`
Timestamp time.Time `json:"timestamp,omitzero"`
}
// TODO: provide constants for known breadcrumb types.
// See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types.
// Logger provides a chaining API for structured logging to Sentry.
type Logger interface {
// Write implements the io.Writer interface. Currently, the [sentry.Hub] is
// context aware, in order to get the correct trace correlation. Using this
// might result in incorrect span association on logs. If you need to use
// Write it is recommended to create a NewLogger so that the associated context
// is passed correctly.
Write(p []byte) (n int, err error)
// SetAttributes allows attaching parameters to the logger using the attribute API.
// These attributes will be included in all subsequent log entries.
SetAttributes(...attribute.Builder)
// Trace defines the [sentry.LogLevel] for the log entry.
Trace() LogEntry
// Debug defines the [sentry.LogLevel] for the log entry.
Debug() LogEntry
// Info defines the [sentry.LogLevel] for the log entry.
Info() LogEntry
// Warn defines the [sentry.LogLevel] for the log entry.
Warn() LogEntry
// Error defines the [sentry.LogLevel] for the log entry.
Error() LogEntry
// Fatal defines the [sentry.LogLevel] for the log entry.
Fatal() LogEntry
// Panic defines the [sentry.LogLevel] for the log entry.
Panic() LogEntry
// LFatal defines the [sentry.LogLevel] for the log entry. This only sets
// the level to fatal, but does not panic or exit.
LFatal() LogEntry
// GetCtx returns the [context.Context] set on the logger.
GetCtx() context.Context
}
// LogEntry defines the interface for a log entry that supports chaining attributes.
type LogEntry interface {
// WithCtx creates a new LogEntry with the specified context without overwriting the previous one.
WithCtx(ctx context.Context) LogEntry
// StringSlice adds a string slice attribute to the LogEntry.
StringSlice(key string, value []string) LogEntry
// String adds a string attribute to the LogEntry.
String(key, value string) LogEntry
// Int adds an int attribute to the LogEntry.
Int(key string, value int) LogEntry
// Int64Slice adds an int64 slice attribute to the LogEntry.
Int64Slice(key string, value []int64) LogEntry
// Int64 adds an int64 attribute to the LogEntry.
Int64(key string, value int64) LogEntry
// Float64Slice adds a float64 slice attribute to the LogEntry.
Float64Slice(key string, value []float64) LogEntry
// Float64 adds a float64 attribute to the LogEntry.
Float64(key string, value float64) LogEntry
// BoolSlice adds a bool slice attribute to the LogEntry.
BoolSlice(key string, value []bool) LogEntry
// Bool adds a bool attribute to the LogEntry.
Bool(key string, value bool) LogEntry
// Emit emits the LogEntry with the provided arguments.
Emit(args ...interface{})
// Emitf emits the LogEntry using a format string and arguments.
Emitf(format string, args ...interface{})
}
// Meter provides an interface for recording metrics.
type Meter interface {
// WithCtx returns a new Meter that uses the given context for trace/span association.
WithCtx(ctx context.Context) Meter
// SetAttributes allows attaching parameters to the meter using the attribute API.
// These attributes will be included in all subsequent metrics.
SetAttributes(attrs ...attribute.Builder)
// Count records a count metric.
Count(name string, count int64, opts ...MeterOption)
// Gauge records a gauge metric.
Gauge(name string, value float64, opts ...MeterOption)
// Distribution records a distribution metric.
Distribution(name string, sample float64, opts ...MeterOption)
}
// MeterOption configures a metric recording call.
type MeterOption func(*meterOptions)
type meterOptions struct {
unit string
scope *Scope
attributes map[string]attribute.Value
}
// WithUnit sets the unit for the metric (e.g., "millisecond", "byte").
func WithUnit(unit string) MeterOption {
return func(o *meterOptions) {
o.unit = unit
}
}
// WithScopeOverride sets a custom scope for the metric, overriding the default scope from the hub.
func WithScopeOverride(scope *Scope) MeterOption {
return func(o *meterOptions) {
o.scope = scope
}
}
// WithAttributes sets attributes for the metric.
func WithAttributes(attrs ...attribute.Builder) MeterOption {
return func(o *meterOptions) {
if o.attributes == nil {
o.attributes = make(map[string]attribute.Value, len(attrs))
}
for _, a := range attrs {
if a.Value.Type() == attribute.INVALID {
debuglog.Printf("invalid attribute: %v", a)
continue
}
o.attributes[a.Key] = a.Value
}
}
}
// Attachment allows associating files with your events to aid in investigation.
// An event may contain one or more attachments.
type Attachment struct {
Filename string
ContentType string
Payload []byte
}
// User describes the user associated with an Event. If this is used, at least
// an ID or an IP address should be provided.
type User struct {
ID string `json:"id,omitempty"`
Email string `json:"email,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Username string `json:"username,omitempty"`
Name string `json:"name,omitempty"`
Data map[string]string `json:"data,omitempty"`
}
func (u User) IsEmpty() bool {
if u.ID != "" {
return false
}
if u.Email != "" {
return false
}
if u.IPAddress != "" {
return false
}
if u.Username != "" {
return false
}
if u.Name != "" {
return false
}
if len(u.Data) > 0 {
return false
}
return true
}
// Request contains information on a HTTP request related to the event.
type Request struct {
URL string `json:"url,omitempty"`
Method string `json:"method,omitempty"`
Data string `json:"data,omitempty"`
QueryString string `json:"query_string,omitempty"`
Cookies string `json:"cookies,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
func sendDefaultPIIEnabled(client *Client) bool {
return client != nil && client.options.SendDefaultPII
}
func newRequest(r *http.Request, sendDefaultPII bool) *Request {
prot := protocol.SchemeHTTP
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
prot = protocol.SchemeHTTPS
}
url := fmt.Sprintf("%s://%s%s", prot, r.Host, r.URL.Path)
var cookies string
var env map[string]string
headers := map[string]string{}
if sendDefaultPII {
// We read only the first Cookie header because of the specification:
// https://tools.ietf.org/html/rfc6265#section-5.4
// When the user agent generates an HTTP request, the user agent MUST NOT
// attach more than one Cookie header field.
cookies = r.Header.Get("Cookie")
headers = make(map[string]string, len(r.Header))
for k, v := range r.Header {
headers[k] = strings.Join(v, ",")
}
if addr, port, err := net.SplitHostPort(r.RemoteAddr); err == nil {
env = map[string]string{"REMOTE_ADDR": addr, "REMOTE_PORT": port}
}
} else {
for k, v := range r.Header {
if !IsSensitiveHeader(k) {
headers[k] = strings.Join(v, ",")
}
}
}
headers["Host"] = r.Host
return &Request{
URL: url,
Method: r.Method,
QueryString: r.URL.RawQuery,
Cookies: cookies,
Headers: headers,
Env: env,
}
}
// NewRequest returns a new Sentry Request from the given http.Request.
//
// NewRequest avoids operations that depend on network access. In particular, it
// does not read r.Body.
func NewRequest(r *http.Request) *Request {
return newRequest(r, sendDefaultPIIEnabled(CurrentHub().Client()))
}
// Mechanism is the mechanism by which an exception was generated and handled.
type Mechanism struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
HelpLink string `json:"help_link,omitempty"`
Source string `json:"source,omitempty"`
Handled *bool `json:"handled,omitempty"`
ParentID *int `json:"parent_id,omitempty"`
ExceptionID int `json:"exception_id"`
IsExceptionGroup bool `json:"is_exception_group,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
// SetUnhandled indicates that the exception is an unhandled exception, i.e.
// from a panic.
func (m *Mechanism) SetUnhandled() {
m.Handled = Pointer(false)
}
// Exception specifies an error that occurred.
type Exception struct {
Type string `json:"type,omitempty"` // used as the main issue title
Value string `json:"value,omitempty"` // used as the main issue subtitle
Module string `json:"module,omitempty"`
ThreadID uint64 `json:"thread_id,omitempty"`
Stacktrace *Stacktrace `json:"stacktrace,omitempty"`
Mechanism *Mechanism `json:"mechanism,omitempty"`
}
// SDKMetaData is a struct to stash data which is needed at some point in the SDK's event processing pipeline
// but which shouldn't get send to Sentry.
type SDKMetaData struct {
dsc DynamicSamplingContext
}
// Contains information about how the name of the transaction was determined.
type TransactionInfo struct {
Source TransactionSource `json:"source,omitempty"`
}
// The DebugMeta interface is not used in Golang apps, but may be populated
// when proxying Events from other platforms, like iOS, Android, and the
// Web. (See: https://develop.sentry.dev/sdk/event-payloads/debugmeta/ ).
type DebugMeta struct {
SdkInfo *DebugMetaSdkInfo `json:"sdk_info,omitempty"`
Images []DebugMetaImage `json:"images,omitempty"`
}
type DebugMetaSdkInfo struct {
SdkName string `json:"sdk_name,omitempty"`
VersionMajor int `json:"version_major,omitempty"`
VersionMinor int `json:"version_minor,omitempty"`
VersionPatchlevel int `json:"version_patchlevel,omitempty"`
}
type DebugMetaImage struct {
Type string `json:"type,omitempty"` // all
ImageAddr string `json:"image_addr,omitempty"` // macho,elf,pe
ImageSize int `json:"image_size,omitempty"` // macho,elf,pe
DebugID string `json:"debug_id,omitempty"` // macho,elf,pe,wasm,sourcemap
DebugFile string `json:"debug_file,omitempty"` // macho,elf,pe,wasm
CodeID string `json:"code_id,omitempty"` // macho,elf,pe,wasm
CodeFile string `json:"code_file,omitempty"` // macho,elf,pe,wasm,sourcemap
ImageVmaddr string `json:"image_vmaddr,omitempty"` // macho,elf,pe
Arch string `json:"arch,omitempty"` // macho,elf,pe
UUID string `json:"uuid,omitempty"` // proguard
}
// EventID is a hexadecimal string representing a unique uuid4 for an Event.
// An EventID must be 32 characters long, lowercase and not have any dashes.
type EventID string
type Context = map[string]interface{}
// Event is the fundamental data structure that is sent to Sentry.
type Event struct {
Breadcrumbs []*Breadcrumb `json:"breadcrumbs,omitempty"`
Contexts map[string]Context `json:"contexts,omitempty"`
Dist string `json:"dist,omitempty"`
Environment string `json:"environment,omitempty"`
EventID EventID `json:"event_id,omitempty"`
Fingerprint []string `json:"fingerprint,omitempty"`
Level Level `json:"level,omitempty"`
Message string `json:"message,omitempty"`
Platform string `json:"platform,omitempty"`
Release string `json:"release,omitempty"`
Sdk SdkInfo `json:"sdk,omitempty"`
ServerName string `json:"server_name,omitempty"`
Threads []Thread `json:"threads,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
Timestamp time.Time `json:"timestamp,omitzero"`
Transaction string `json:"transaction,omitempty"`
User User `json:"user,omitempty"`
Logger string `json:"logger,omitempty"`
Modules map[string]string `json:"modules,omitempty"`
Request *Request `json:"request,omitempty"`
Exception []Exception `json:"exception,omitempty"`
DebugMeta *DebugMeta `json:"debug_meta,omitempty"`
Attachments []*Attachment `json:"-"`
// The fields below are only relevant for transactions.
Type string `json:"type,omitempty"`
StartTime time.Time `json:"start_timestamp,omitzero"`
Spans []*Span `json:"spans,omitempty"`
TransactionInfo *TransactionInfo `json:"transaction_info,omitempty"`
// The fields below are only relevant for crons/check ins
CheckIn *CheckIn `json:"check_in,omitempty"`
MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
// The fields below are only relevant for logs
Logs []Log `json:"-"`
// The fields below are only relevant for metrics
Metrics []Metric `json:"-"`
// The fields below are not part of the final JSON payload.
sdkMetaData SDKMetaData
// Pre-serialized copies of mutable fields, set by MakeSerializationSafe.
serializedTags json.RawMessage
serializedContexts json.RawMessage
serializedBreadcrumbs json.RawMessage
serializedException json.RawMessage
serializedUser json.RawMessage
serializationSafe bool
}
// SetException appends the unwrapped errors to the event's exception list.
//
// maxErrorDepth is the maximum depth of the error chain we will look
// into while unwrapping the errors. If maxErrorDepth is -1, we will
// unwrap all errors in the chain.
func (e *Event) SetException(exception error, maxErrorDepth int) {
if exception == nil {
return
}
exceptions := convertErrorToExceptions(exception, maxErrorDepth)
if len(exceptions) == 0 {
return
}
e.Exception = exceptions
}
// safeMarshal wraps json.Marshal with a recover guard.
//
// we shouldn't panic since we already pre serialized all user mutable fields, but using this just to be safe.
func (e *Event) safeMarshal() (b []byte, err error) {
defer func() {
if r := recover(); r != nil {
b = nil
err = fmt.Errorf("panic during event marshaling: %v", r)
}
}()
return json.Marshal(e)
}
// ToEnvelopeItem converts the Event to a Sentry envelope item.
func (e *Event) ToEnvelopeItem() (item *protocol.EnvelopeItem, err error) {
eventBody, err := e.safeMarshal()
if err != nil {
return nil, fmt.Errorf("could not encode event as JSON, skipping delivery: %w", err)
}
// TODO: all event types should be abstracted to implement EnvelopeItemConvertible and convert themselves.
switch e.Type {
case transactionType:
item = protocol.NewTransactionItem(e.GetSpanCount(), eventBody)
case checkInType:
item = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeCheckIn, eventBody)
case logEvent.Type:
item = protocol.NewLogItem(len(e.Logs), eventBody)
case traceMetricEvent.Type:
item = protocol.NewTraceMetricItem(len(e.Metrics), eventBody)
default:
item = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeEvent, eventBody)
}
return item, nil
}
// ToEnvelope converts the Event to a Sentry envelope.
func (e *Event) ToEnvelope(header *protocol.EnvelopeHeader) (*protocol.Envelope, error) {
item, err := e.ToEnvelopeItem()
if err != nil {
return nil, err
}
envelope := protocol.NewEnvelope(header)
envelope.AddItem(item)
for _, attachment := range e.Attachments {
attachmentItem := protocol.NewAttachmentItem(attachment.Filename, attachment.ContentType, attachment.Payload)
envelope.AddItem(attachmentItem)
}
return envelope, nil
}
// GetCategory returns the rate limit category for this event.
func (e *Event) GetCategory() ratelimit.Category {
return e.toCategory()
}
// GetEventID returns the event ID.
func (e *Event) GetEventID() string {
return string(e.EventID)
}
// GetSdkInfo returns SDK information for the envelope header.
func (e *Event) GetSdkInfo() *protocol.SdkInfo {
return &e.Sdk
}
// GetDynamicSamplingContext returns trace context for the envelope header.
func (e *Event) GetDynamicSamplingContext() map[string]string {
trace := make(map[string]string)
if dsc := e.sdkMetaData.dsc; dsc.HasEntries() {
for k, v := range dsc.Entries {
trace[k] = v
}
}
return trace
}
// GetSpanCount returns the number of spans in the transaction including the transaction itself. It is used for client
// reports. Returns 0 for non-transaction events.
func (e *Event) GetSpanCount() int {
if e.Type != transactionType {
return 0
}
return len(e.Spans) + 1
}
// GetLogByteSize returns the approximate total byte size of all logs in the event. It is used for client
// reports. Returns 0 for non-log events.
func (e *Event) GetLogByteSize() int {
if e.Type != logEvent.Type {
return 0
}
var size int
for i := range e.Logs {
size += e.Logs[i].ApproximateSize()
}
return size
}
// TODO: Event.Contexts map[string]interface{} => map[string]EventContext,
// to prevent accidentally storing T when we mean *T.
// For example, the TraceContext must be stored as *TraceContext to pick up the
// MarshalJSON method (and avoid copying).
// type EventContext interface{ EventContext() }
// MarshalJSON converts the Event struct to JSON.
func (e *Event) MarshalJSON() ([]byte, error) {
if e.Type == checkInType {
return e.checkInMarshalJSON()
}
return e.defaultMarshalJSON()
}
func (e *Event) defaultMarshalJSON() ([]byte, error) {
// event aliases Event to allow calling json.Marshal without an infinite
// loop. It preserves all fields while none of the attached methods.
type event Event
// Use pre-serialized bytes for fields that contain user mutable data.
if e.hasPreSerializedFields() {
return e.preSerializedMarshalJSON()
}
if e.Type == transactionType {
return json.Marshal(struct{ *event }{(*event)(e)})
}
// metrics and logs should be serialized under the same `items` json field.
if e.Type == logEvent.Type {
type logEvent struct {
*event
Items []Log `json:"items,omitempty"`
Type json.RawMessage `json:"type,omitempty"`
}
return json.Marshal(logEvent{event: (*event)(e), Items: e.Logs})
}
if e.Type == traceMetricEvent.Type {
type metricEvent struct {
*event
Items []Metric `json:"items,omitempty"`
Type json.RawMessage `json:"type,omitempty"`
}
return json.Marshal(metricEvent{event: (*event)(e), Items: e.Metrics})
}
// errorEvent is like Event with shadowed fields for customizing JSON
// marshaling.
type errorEvent struct {
*event
// The fields below are not part of error events and only make sense to
// be sent for transactions. They shadow the respective fields in Event
// and are meant to remain nil, triggering the omitempty behavior.
Type json.RawMessage `json:"type,omitempty"`
StartTime json.RawMessage `json:"start_timestamp,omitempty"`
Spans json.RawMessage `json:"spans,omitempty"`
TransactionInfo json.RawMessage `json:"transaction_info,omitempty"`
}
x := errorEvent{event: (*event)(e)}
return json.Marshal(x)
}
func (e *Event) hasPreSerializedFields() bool {
return e.serializationSafe
}
// preSerializedMarshalJSON handles marshaling when MakeSerializationSafe has
// pre-serialized mutable fields. Shadow structs ensure the json.RawMessage
// bytes are emitted directly, overriding the original fields.
func (e *Event) preSerializedMarshalJSON() ([]byte, error) {
type event Event
if e.Type == transactionType {
type safeTransaction struct {
*event
Tags json.RawMessage `json:"tags,omitempty"`
Contexts json.RawMessage `json:"contexts,omitempty"`
Breadcrumbs json.RawMessage `json:"breadcrumbs,omitempty"`
Exception json.RawMessage `json:"exception,omitempty"`
User json.RawMessage `json:"user,omitempty"`
}
return json.Marshal(safeTransaction{
event: (*event)(e),
Tags: e.serializedTags,
Contexts: e.serializedContexts,
Breadcrumbs: e.serializedBreadcrumbs,
Exception: e.serializedException,
User: e.serializedUser,
})
}
// Error event: also shadow transaction-only fields to exclude them.
type safeErrorEvent struct {
*event
Tags json.RawMessage `json:"tags,omitempty"`
Contexts json.RawMessage `json:"contexts,omitempty"`
Breadcrumbs json.RawMessage `json:"breadcrumbs,omitempty"`
Exception json.RawMessage `json:"exception,omitempty"`
User json.RawMessage `json:"user,omitempty"`
Type json.RawMessage `json:"type,omitempty"`
StartTime json.RawMessage `json:"start_timestamp,omitempty"`
Spans json.RawMessage `json:"spans,omitempty"`
TransactionInfo json.RawMessage `json:"transaction_info,omitempty"`
}
return json.Marshal(safeErrorEvent{
event: (*event)(e),
Tags: e.serializedTags,
Contexts: e.serializedContexts,
Breadcrumbs: e.serializedBreadcrumbs,
Exception: e.serializedException,
User: e.serializedUser,
})
}
func (e *Event) checkInMarshalJSON() ([]byte, error) {
checkIn := serializedCheckIn{
CheckInID: string(e.CheckIn.ID),
MonitorSlug: e.CheckIn.MonitorSlug,
Status: e.CheckIn.Status,
Duration: e.CheckIn.Duration.Seconds(),
Release: e.Release,
Environment: e.Environment,
MonitorConfig: nil,
}
if e.MonitorConfig != nil {
checkIn.MonitorConfig = &MonitorConfig{
Schedule: e.MonitorConfig.Schedule,
CheckInMargin: e.MonitorConfig.CheckInMargin,
MaxRuntime: e.MonitorConfig.MaxRuntime,
Timezone: e.MonitorConfig.Timezone,
FailureIssueThreshold: e.MonitorConfig.FailureIssueThreshold,
RecoveryThreshold: e.MonitorConfig.RecoveryThreshold,
}
}
return json.Marshal(checkIn)
}
func (e *Event) toCategory() ratelimit.Category {
switch e.Type {
case errorType:
return ratelimit.CategoryError
case transactionType:
return ratelimit.CategoryTransaction
case logEvent.Type:
return ratelimit.CategoryLog
case checkInType:
return ratelimit.CategoryMonitor
case traceMetricEvent.Type:
return ratelimit.CategoryTraceMetric
default:
return ratelimit.CategoryUnknown
}
}
// NewEvent creates a new Event.
func NewEvent() *Event {
return &Event{
Contexts: make(map[string]Context),
Tags: make(map[string]string),
Modules: make(map[string]string),
}
}
// Thread specifies threads that were running at the time of an event.
type Thread struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Stacktrace *Stacktrace `json:"stacktrace,omitempty"`
Crashed bool `json:"crashed,omitempty"`
Current bool `json:"current,omitempty"`
}
// EventHint contains information that can be associated with an Event.
type EventHint struct {
Data interface{}
EventID string
OriginalException error
RecoveredException interface{}
Context context.Context
Request *http.Request
Response *http.Response
}
type Log struct {
Timestamp time.Time `json:"timestamp,omitzero"`
TraceID TraceID `json:"trace_id"`
SpanID SpanID `json:"span_id,omitzero"`
Level LogLevel `json:"level"`
Severity int `json:"severity_number,omitempty"`
Body string `json:"body"`
Attributes map[string]attribute.Value `json:"attributes,omitempty"`
// approximateSize is the pre-computed approximate size in bytes.
approximateSize int
}
// ApproximateSize returns the pre-computed approximate serialized size in bytes.
func (l *Log) ApproximateSize() int {
return l.approximateSize
}
// computeLogSize estimates the serialized JSON size of a log entry.
func computeLogSize(l *Log) int {
// Base overhead: timestamp, trace_id, level, severity, JSON structure
size := len(l.Body) + 60
for k, v := range l.Attributes {
// Key + type/value JSON overhead
size += len(k) + 20
s := fmt.Sprint(v.AsInterface())
size += len(s)
}
return size
}
// MakeSerializationSafe is a no-op for Log, all fields are passed from the safe attribute API.
func (l *Log) MakeSerializationSafe() {}
// GetCategory returns the rate limit category for logs.
func (l *Log) GetCategory() ratelimit.Category {
return ratelimit.CategoryLog
}
// GetEventID returns empty string (event ID set when batching).
func (l *Log) GetEventID() string {
return ""
}
// GetSdkInfo returns nil (SDK info set when batching).
func (l *Log) GetSdkInfo() *protocol.SdkInfo {
return nil
}
// GetDynamicSamplingContext returns nil (trace context set when batching).
func (l *Log) GetDynamicSamplingContext() map[string]string {
return nil
}
type MetricType string
const (
MetricTypeInvalid MetricType = ""
MetricTypeCounter MetricType = "counter"
MetricTypeGauge MetricType = "gauge"
MetricTypeDistribution MetricType = "distribution"
)
type Metric struct {
Timestamp time.Time `json:"timestamp,omitzero"`
TraceID TraceID `json:"trace_id"`
SpanID SpanID `json:"span_id,omitzero"`
Type MetricType `json:"type"`
Name string `json:"name"`
Value MetricValue `json:"value"`
Unit string `json:"unit,omitempty"`
Attributes map[string]attribute.Value `json:"attributes,omitempty"`
}
// MakeSerializationSafe is a no-op for Metric, all fields are passed from the safe attribute API.
func (m *Metric) MakeSerializationSafe() {}
// GetCategory returns the rate limit category for metrics.
func (m *Metric) GetCategory() ratelimit.Category {
return ratelimit.CategoryTraceMetric
}
// GetEventID returns empty string (event ID set when batching).
func (m *Metric) GetEventID() string {
return ""
}
// GetSdkInfo returns nil (SDK info set when batching).
func (m *Metric) GetSdkInfo() *protocol.SdkInfo {
return nil
}
// GetDynamicSamplingContext returns nil (trace context set when batching).
func (m *Metric) GetDynamicSamplingContext() map[string]string {
return nil
}
// MetricValue stores metric values with full precision.
// It supports int64 (for counters) and float64 (for gauges and distributions).
type MetricValue struct {
value attribute.Value
}
// Int64MetricValue creates a MetricValue from an int64.
// Used for counter metrics to preserve full int64 precision.
func Int64MetricValue(v int64) MetricValue {
return MetricValue{value: attribute.Int64Value(v)}
}
// Float64MetricValue creates a MetricValue from a float64.
// Used for gauge and distribution metrics.
func Float64MetricValue(v float64) MetricValue {
return MetricValue{value: attribute.Float64Value(v)}
}
// Type returns the type of the stored value (attribute.INT64 or attribute.FLOAT64).
func (v MetricValue) Type() attribute.Type {
return v.value.Type()
}
// Int64 returns the value as int64 if it holds an int64.
// The second return value indicates whether the type matched.
func (v MetricValue) Int64() (int64, bool) {
if v.value.Type() == attribute.INT64 {
return v.value.AsInt64(), true
}
return 0, false
}
// Float64 returns the value as float64 if it holds a float64.
// The second return value indicates whether the type matched.
func (v MetricValue) Float64() (float64, bool) {
if v.value.Type() == attribute.FLOAT64 {
return v.value.AsFloat64(), true
}
return 0, false
}
// AsInterface returns the value as int64 or float64.
// Use type assertion or type switch to handle the result.
func (v MetricValue) AsInterface() any {
return v.value.AsInterface()
}
// MarshalJSON serializes the value as a bare number.
func (v MetricValue) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value.AsInterface())
}
// MakeSerializationSafe pre-serializes all fields containing user mutable data to json.RawMessage, preventing race
// conditions when the event is later serialized on a background goroutine.
func (e *Event) MakeSerializationSafe() {
if len(e.Tags) > 0 {
if b, err := json.Marshal(e.Tags); err == nil {
e.serializedTags = b
}
}
if len(e.Contexts) > 0 {
if b, err := json.Marshal(e.Contexts); err == nil {
e.serializedContexts = b
}
}
if len(e.Breadcrumbs) > 0 {
if b, err := json.Marshal(e.Breadcrumbs); err == nil {
e.serializedBreadcrumbs = b
}
}
if len(e.Exception) > 0 {
if b, err := json.Marshal(e.Exception); err == nil {
e.serializedException = b
}
}
if !e.User.IsEmpty() {
if b, err := json.Marshal(e.User); err == nil {
e.serializedUser = b
}
}
for _, span := range e.Spans {
span.makeSerializationSafe()
}
e.serializationSafe = true
}
+79
View File
@@ -0,0 +1,79 @@
package debug
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"net/http/httputil"
)
// Transport implements http.RoundTripper and can be used to wrap other HTTP
// transports for debugging, normally http.DefaultTransport.
type Transport struct {
http.RoundTripper
Output io.Writer
// Dump controls whether to dump HTTP request and responses.
Dump bool
// Trace enables usage of net/http/httptrace.
Trace bool
}
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
var buf bytes.Buffer
if t.Dump {
b, err := httputil.DumpRequestOut(req, true)
if err != nil {
panic(err)
}
_, err = buf.Write(ensureTrailingNewline(b))
if err != nil {
panic(err)
}
}
if t.Trace {
trace := &httptrace.ClientTrace{
DNSDone: func(di httptrace.DNSDoneInfo) {
fmt.Fprintf(&buf, "* DNS %v → %v\n", req.Host, di.Addrs)
},
GotConn: func(ci httptrace.GotConnInfo) {
fmt.Fprintf(&buf, "* Connection local=%v remote=%v", ci.Conn.LocalAddr(), ci.Conn.RemoteAddr())
if ci.Reused {
fmt.Fprint(&buf, " (reused)")
}
if ci.WasIdle {
fmt.Fprintf(&buf, " (idle %v)", ci.IdleTime)
}
fmt.Fprintln(&buf)
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
}
resp, err := t.RoundTripper.RoundTrip(req)
if err != nil {
return nil, err
}
if t.Dump {
b, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
_, err = buf.Write(ensureTrailingNewline(b))
if err != nil {
panic(err)
}
}
_, err = io.Copy(t.Output, &buf)
if err != nil {
panic(err)
}
return resp, nil
}
func ensureTrailingNewline(b []byte) []byte {
if len(b) > 0 && b[len(b)-1] != '\n' {
b = append(b, '\n')
}
return b
}
+35
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...)
}
+642
View File
@@ -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
View File
@@ -0,0 +1,12 @@
## Why do we have this "otel/baggage" folder?
The root sentry-go SDK (namely, the Dynamic Sampling functionality) needs an implementation of the [baggage spec](https://www.w3.org/TR/baggage/).
For that reason, we've taken the existing baggage implementation from the [opentelemetry-go](https://github.com/open-telemetry/opentelemetry-go/) repository, and fixed a few things that in our opinion were violating the specification.
These issues are:
1. Baggage string value `one%20two` should be properly parsed as "one two"
1. Baggage string value `one+two` should be parsed as "one+two"
1. Go string value "one two" should be encoded as `one%20two` (percent encoding), and NOT as `one+two` (URL query encoding).
1. Go string value "1=1" might be encoded as `1=1`, because the spec says: "Note, value MAY contain any number of the equal sign (=) characters. Parsers MUST NOT assume that the equal sign is only used to separate key and value.". `1%3D1` is also valid, but to simplify the implementation we're not doing it.
Changes were made in this PR: https://github.com/getsentry/sentry-go/pull/568
+604
View File
@@ -0,0 +1,604 @@
// Adapted from https://github.com/open-telemetry/opentelemetry-go/blob/c21b6b6bb31a2f74edd06e262f1690f3f6ea3d5c/baggage/baggage.go
//
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package baggage
import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"unicode/utf8"
"github.com/getsentry/sentry-go/internal/otel/baggage/internal/baggage"
)
const (
maxMembers = 180
maxBytesPerMembers = 4096
maxBytesPerBaggageString = 8192
listDelimiter = ","
keyValueDelimiter = "="
propertyDelimiter = ";"
keyDef = `([\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5a\x5e-\x7a\x7c\x7e]+)`
valueDef = `([\x21\x23-\x2b\x2d-\x3a\x3c-\x5B\x5D-\x7e]*)`
keyValueDef = `\s*` + keyDef + `\s*` + keyValueDelimiter + `\s*` + valueDef + `\s*`
)
var (
keyRe = regexp.MustCompile(`^` + keyDef + `$`)
valueRe = regexp.MustCompile(`^` + valueDef + `$`)
propertyRe = regexp.MustCompile(`^(?:\s*` + keyDef + `\s*|` + keyValueDef + `)$`)
)
var (
errInvalidKey = errors.New("invalid key")
errInvalidValue = errors.New("invalid value")
errInvalidProperty = errors.New("invalid baggage list-member property")
errInvalidMember = errors.New("invalid baggage list-member")
errMemberNumber = errors.New("too many list-members in baggage-string")
errMemberBytes = errors.New("list-member too large")
errBaggageBytes = errors.New("baggage-string too large")
)
// Property is an additional metadata entry for a baggage list-member.
type Property struct {
key, value string
// hasValue indicates if a zero-value value means the property does not
// have a value or if it was the zero-value.
hasValue bool
// hasData indicates whether the created property contains data or not.
// Properties that do not contain data are invalid with no other check
// required.
hasData bool
}
// NewKeyProperty returns a new Property for key.
//
// If key is invalid, an error will be returned.
func NewKeyProperty(key string) (Property, error) {
if !keyRe.MatchString(key) {
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidKey, key)
}
p := Property{key: key, hasData: true}
return p, nil
}
// NewKeyValueProperty returns a new Property for key with value.
//
// If key or value are invalid, an error will be returned.
func NewKeyValueProperty(key, value string) (Property, error) {
if !keyRe.MatchString(key) {
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidKey, key)
}
if !valueRe.MatchString(value) {
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidValue, value)
}
p := Property{
key: key,
value: value,
hasValue: true,
hasData: true,
}
return p, nil
}
func newInvalidProperty() Property {
return Property{}
}
// parseProperty attempts to decode a Property from the passed string. It
// returns an error if the input is invalid according to the W3C Baggage
// specification.
func parseProperty(property string) (Property, error) {
if property == "" {
return newInvalidProperty(), nil
}
match := propertyRe.FindStringSubmatch(property)
if len(match) != 4 {
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidProperty, property)
}
p := Property{hasData: true}
if match[1] != "" {
p.key = match[1]
} else {
p.key = match[2]
p.value = match[3]
p.hasValue = true
}
return p, nil
}
// validate ensures p conforms to the W3C Baggage specification, returning an
// error otherwise.
func (p Property) validate() error {
errFunc := func(err error) error {
return fmt.Errorf("invalid property: %w", err)
}
if !p.hasData {
return errFunc(fmt.Errorf("%w: %q", errInvalidProperty, p))
}
if !keyRe.MatchString(p.key) {
return errFunc(fmt.Errorf("%w: %q", errInvalidKey, p.key))
}
if p.hasValue && !valueRe.MatchString(p.value) {
return errFunc(fmt.Errorf("%w: %q", errInvalidValue, p.value))
}
if !p.hasValue && p.value != "" {
return errFunc(errors.New("inconsistent value"))
}
return nil
}
// Key returns the Property key.
func (p Property) Key() string {
return p.key
}
// Value returns the Property value. Additionally, a boolean value is returned
// indicating if the returned value is the empty if the Property has a value
// that is empty or if the value is not set.
func (p Property) Value() (string, bool) {
return p.value, p.hasValue
}
// String encodes Property into a string compliant with the W3C Baggage
// specification.
func (p Property) String() string {
if p.hasValue {
return fmt.Sprintf("%s%s%v", p.key, keyValueDelimiter, p.value)
}
return p.key
}
type properties []Property
func fromInternalProperties(iProps []baggage.Property) properties {
if len(iProps) == 0 {
return nil
}
props := make(properties, len(iProps))
for i, p := range iProps {
props[i] = Property{
key: p.Key,
value: p.Value,
hasValue: p.HasValue,
}
}
return props
}
func (p properties) asInternal() []baggage.Property {
if len(p) == 0 {
return nil
}
iProps := make([]baggage.Property, len(p))
for i, prop := range p {
iProps[i] = baggage.Property{
Key: prop.key,
Value: prop.value,
HasValue: prop.hasValue,
}
}
return iProps
}
func (p properties) Copy() properties {
if len(p) == 0 {
return nil
}
props := make(properties, len(p))
copy(props, p)
return props
}
// validate ensures each Property in p conforms to the W3C Baggage
// specification, returning an error otherwise.
func (p properties) validate() error {
for _, prop := range p {
if err := prop.validate(); err != nil {
return err
}
}
return nil
}
// String encodes properties into a string compliant with the W3C Baggage
// specification.
func (p properties) String() string {
props := make([]string, len(p))
for i, prop := range p {
props[i] = prop.String()
}
return strings.Join(props, propertyDelimiter)
}
// Member is a list-member of a baggage-string as defined by the W3C Baggage
// specification.
type Member struct {
key, value string
properties properties
// hasData indicates whether the created property contains data or not.
// Properties that do not contain data are invalid with no other check
// required.
hasData bool
}
// NewMember returns a new Member from the passed arguments. The key will be
// used directly while the value will be url decoded after validation. An error
// is returned if the created Member would be invalid according to the W3C
// Baggage specification.
func NewMember(key, value string, props ...Property) (Member, error) {
m := Member{
key: key,
value: value,
properties: properties(props).Copy(),
hasData: true,
}
if err := m.validate(); err != nil {
return newInvalidMember(), err
}
//// NOTE(anton): I don't think we need to unescape here
// decodedValue, err := url.PathUnescape(value)
// if err != nil {
// return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value)
// }
// m.value = decodedValue
return m, nil
}
func newInvalidMember() Member {
return Member{}
}
// parseMember attempts to decode a Member from the passed string. It returns
// an error if the input is invalid according to the W3C Baggage
// specification.
func parseMember(member string) (Member, error) {
if n := len(member); n > maxBytesPerMembers {
return newInvalidMember(), fmt.Errorf("%w: %d", errMemberBytes, n)
}
var (
key, value string
props properties
)
parts := strings.SplitN(member, propertyDelimiter, 2)
switch len(parts) {
case 2:
// Parse the member properties.
for _, pStr := range strings.Split(parts[1], propertyDelimiter) {
p, err := parseProperty(pStr)
if err != nil {
return newInvalidMember(), err
}
props = append(props, p)
}
fallthrough
case 1:
// Parse the member key/value pair.
// Take into account a value can contain equal signs (=).
kv := strings.SplitN(parts[0], keyValueDelimiter, 2)
if len(kv) != 2 {
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidMember, member)
}
// "Leading and trailing whitespaces are allowed but MUST be trimmed
// when converting the header into a data structure."
key = strings.TrimSpace(kv[0])
value = strings.TrimSpace(kv[1])
var err error
if !keyRe.MatchString(key) {
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidKey, key)
}
if !valueRe.MatchString(value) {
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value)
}
decodedValue, err := url.PathUnescape(value)
if err != nil {
return newInvalidMember(), fmt.Errorf("%w: %q", err, value)
}
value = decodedValue
default:
// This should never happen unless a developer has changed the string
// splitting somehow. Panic instead of failing silently and allowing
// the bug to slip past the CI checks.
panic("failed to parse baggage member")
}
return Member{key: key, value: value, properties: props, hasData: true}, nil
}
// validate ensures m conforms to the W3C Baggage specification.
// A key is just an ASCII string, but a value must be URL encoded UTF-8,
// returning an error otherwise.
func (m Member) validate() error {
if !m.hasData {
return fmt.Errorf("%w: %q", errInvalidMember, m)
}
if !keyRe.MatchString(m.key) {
return fmt.Errorf("%w: %q", errInvalidKey, m.key)
}
//// NOTE(anton): IMO it's too early to validate the value here.
// if !valueRe.MatchString(m.value) {
// return fmt.Errorf("%w: %q", errInvalidValue, m.value)
// }
return m.properties.validate()
}
// Key returns the Member key.
func (m Member) Key() string { return m.key }
// Value returns the Member value.
func (m Member) Value() string { return m.value }
// Properties returns a copy of the Member properties.
func (m Member) Properties() []Property { return m.properties.Copy() }
// String encodes Member into a string compliant with the W3C Baggage
// specification.
func (m Member) String() string {
// A key is just an ASCII string, but a value is URL encoded UTF-8.
s := fmt.Sprintf("%s%s%s", m.key, keyValueDelimiter, percentEncodeValue(m.value))
if len(m.properties) > 0 {
s = fmt.Sprintf("%s%s%s", s, propertyDelimiter, m.properties.String())
}
return s
}
// percentEncodeValue encodes the baggage value, using percent-encoding for
// disallowed octets.
func percentEncodeValue(s string) string {
const upperhex = "0123456789ABCDEF"
var sb strings.Builder
for byteIndex, width := 0, 0; byteIndex < len(s); byteIndex += width {
runeValue, w := utf8.DecodeRuneInString(s[byteIndex:])
width = w
char := string(runeValue)
if valueRe.MatchString(char) && char != "%" {
// The character is returned as is, no need to percent-encode
sb.WriteString(char)
} else {
// We need to percent-encode each byte of the multi-octet character
for j := 0; j < width; j++ {
b := s[byteIndex+j]
sb.WriteByte('%')
// Bitwise operations are inspired by "net/url"
sb.WriteByte(upperhex[b>>4])
sb.WriteByte(upperhex[b&15])
}
}
}
return sb.String()
}
// Baggage is a list of baggage members representing the baggage-string as
// defined by the W3C Baggage specification.
type Baggage struct { //nolint:golint
list baggage.List
}
// New returns a new valid Baggage. It returns an error if it results in a
// Baggage exceeding limits set in that specification.
//
// It expects all the provided members to have already been validated.
func New(members ...Member) (Baggage, error) {
if len(members) == 0 {
return Baggage{}, nil
}
b := make(baggage.List)
for _, m := range members {
if !m.hasData {
return Baggage{}, errInvalidMember
}
// OpenTelemetry resolves duplicates by last-one-wins.
b[m.key] = baggage.Item{
Value: m.value,
Properties: m.properties.asInternal(),
}
}
// Check member numbers after deduplication.
if len(b) > maxMembers {
return Baggage{}, errMemberNumber
}
bag := Baggage{b}
if n := len(bag.String()); n > maxBytesPerBaggageString {
return Baggage{}, fmt.Errorf("%w: %d", errBaggageBytes, n)
}
return bag, nil
}
// Parse attempts to decode a baggage-string from the passed string. It
// returns an error if the input is invalid according to the W3C Baggage
// specification.
//
// If there are duplicate list-members contained in baggage, the last one
// defined (reading left-to-right) will be the only one kept. This diverges
// from the W3C Baggage specification which allows duplicate list-members, but
// conforms to the OpenTelemetry Baggage specification.
func Parse(bStr string) (Baggage, error) {
if bStr == "" {
return Baggage{}, nil
}
if n := len(bStr); n > maxBytesPerBaggageString {
return Baggage{}, fmt.Errorf("%w: %d", errBaggageBytes, n)
}
b := make(baggage.List)
for _, memberStr := range strings.Split(bStr, listDelimiter) {
m, err := parseMember(memberStr)
if err != nil {
return Baggage{}, err
}
// OpenTelemetry resolves duplicates by last-one-wins.
b[m.key] = baggage.Item{
Value: m.value,
Properties: m.properties.asInternal(),
}
}
// OpenTelemetry does not allow for duplicate list-members, but the W3C
// specification does. Now that we have deduplicated, ensure the baggage
// does not exceed list-member limits.
if len(b) > maxMembers {
return Baggage{}, errMemberNumber
}
return Baggage{b}, nil
}
// Member returns the baggage list-member identified by key.
//
// If there is no list-member matching the passed key the returned Member will
// be a zero-value Member.
// The returned member is not validated, as we assume the validation happened
// when it was added to the Baggage.
func (b Baggage) Member(key string) Member {
v, ok := b.list[key]
if !ok {
// We do not need to worry about distinguishing between the situation
// where a zero-valued Member is included in the Baggage because a
// zero-valued Member is invalid according to the W3C Baggage
// specification (it has an empty key).
return newInvalidMember()
}
return Member{
key: key,
value: v.Value,
properties: fromInternalProperties(v.Properties),
hasData: true,
}
}
// Members returns all the baggage list-members.
// The order of the returned list-members does not have significance.
//
// The returned members are not validated, as we assume the validation happened
// when they were added to the Baggage.
func (b Baggage) Members() []Member {
if len(b.list) == 0 {
return nil
}
members := make([]Member, 0, len(b.list))
for k, v := range b.list {
members = append(members, Member{
key: k,
value: v.Value,
properties: fromInternalProperties(v.Properties),
hasData: true,
})
}
return members
}
// SetMember returns a copy the Baggage with the member included. If the
// baggage contains a Member with the same key the existing Member is
// replaced.
//
// If member is invalid according to the W3C Baggage specification, an error
// is returned with the original Baggage.
func (b Baggage) SetMember(member Member) (Baggage, error) {
if !member.hasData {
return b, errInvalidMember
}
n := len(b.list)
if _, ok := b.list[member.key]; !ok {
n++
}
list := make(baggage.List, n)
for k, v := range b.list {
// Do not copy if we are just going to overwrite.
if k == member.key {
continue
}
list[k] = v
}
list[member.key] = baggage.Item{
Value: member.value,
Properties: member.properties.asInternal(),
}
return Baggage{list: list}, nil
}
// DeleteMember returns a copy of the Baggage with the list-member identified
// by key removed.
func (b Baggage) DeleteMember(key string) Baggage {
n := len(b.list)
if _, ok := b.list[key]; ok {
n--
}
list := make(baggage.List, n)
for k, v := range b.list {
if k == key {
continue
}
list[k] = v
}
return Baggage{list: list}
}
// Len returns the number of list-members in the Baggage.
func (b Baggage) Len() int {
return len(b.list)
}
// String encodes Baggage into a string compliant with the W3C Baggage
// specification. The returned string will be invalid if the Baggage contains
// any invalid list-members.
func (b Baggage) String() string {
members := make([]string, 0, len(b.list))
for k, v := range b.list {
members = append(members, Member{
key: k,
value: v.Value,
properties: fromInternalProperties(v.Properties),
}.String())
}
return strings.Join(members, listDelimiter)
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {}
@@ -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
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"`
}
+18
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)
}
+115
View File
@@ -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
View File
@@ -0,0 +1,22 @@
package ratelimit
import "time"
// A Deadline is a time instant when a rate limit expires.
type Deadline time.Time
// After reports whether the deadline d is after other.
func (d Deadline) After(other Deadline) bool {
return time.Time(d).After(time.Time(other))
}
// Equal reports whether d and e represent the same deadline.
func (d Deadline) Equal(e Deadline) bool {
return time.Time(d).Equal(time.Time(e))
}
// String returns the deadline formatted for debugging.
func (d Deadline) String() string {
// Like time.Time.String, but without the monotonic clock reading.
return time.Time(d).Round(0).String()
}
+3
View File
@@ -0,0 +1,3 @@
// Package ratelimit provides tools to work with rate limits imposed by Sentry's
// data ingestion pipeline.
package ratelimit
+64
View File
@@ -0,0 +1,64 @@
package ratelimit
import (
"net/http"
"time"
)
// Map maps categories to rate limit deadlines.
//
// A rate limit is in effect for a given category if either the category's
// deadline or the deadline for the special CategoryAll has not yet expired.
//
// Use IsRateLimited to check whether a category is rate-limited.
type Map map[Category]Deadline
// IsRateLimited returns true if the category is currently rate limited.
func (m Map) IsRateLimited(c Category) bool {
return m.isRateLimited(c, time.Now())
}
func (m Map) isRateLimited(c Category, now time.Time) bool {
return m.Deadline(c).After(Deadline(now))
}
// Deadline returns the deadline when the rate limit for the given category or
// the special CategoryAll expire, whichever is furthest into the future.
func (m Map) Deadline(c Category) Deadline {
categoryDeadline := m[c]
allDeadline := m[CategoryAll]
if categoryDeadline.After(allDeadline) {
return categoryDeadline
}
return allDeadline
}
// Merge merges the other map into m.
//
// If a category appears in both maps, the deadline that is furthest into the
// future is preserved.
func (m Map) Merge(other Map) {
for c, d := range other {
if d.After(m[c]) {
m[c] = d
}
}
}
// FromResponse returns a rate limit map from an HTTP response.
func FromResponse(r *http.Response) Map {
return fromResponse(r, time.Now())
}
func fromResponse(r *http.Response, now time.Time) Map {
s := r.Header.Get("X-Sentry-Rate-Limits")
if s != "" {
return parseXSentryRateLimits(s, now)
}
if r.StatusCode == http.StatusTooManyRequests {
s := r.Header.Get("Retry-After")
deadline, _ := parseRetryAfter(s, now)
return Map{CategoryAll: deadline}
}
return Map{}
}
@@ -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
}
@@ -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
}
@@ -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
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()
}
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
@@ -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
View File
@@ -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
View File
@@ -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
}
+354
View File
@@ -0,0 +1,354 @@
package sentry
import (
"context"
"fmt"
"maps"
"os"
"strings"
"sync"
"time"
"github.com/getsentry/sentry-go/attribute"
"github.com/getsentry/sentry-go/internal/debuglog"
)
type LogLevel string
const (
LogLevelTrace LogLevel = "trace"
LogLevelDebug LogLevel = "debug"
LogLevelInfo LogLevel = "info"
LogLevelWarn LogLevel = "warn"
LogLevelError LogLevel = "error"
LogLevelFatal LogLevel = "fatal"
)
const (
LogSeverityTrace int = 1
LogSeverityDebug int = 5
LogSeverityInfo int = 9
LogSeverityWarning int = 13
LogSeverityError int = 17
LogSeverityFatal int = 21
)
type sentryLogger struct {
ctx context.Context
hub *Hub
attributes map[string]attribute.Value
defaultAttributes map[string]attribute.Value
mu sync.RWMutex
}
type logEntry struct {
logger *sentryLogger
ctx context.Context
level LogLevel
severity int
attributes map[string]attribute.Value
shouldPanic bool
shouldFatal bool
}
// NewLogger returns a Logger that emits logs to Sentry. If logging is turned off, all logs get discarded.
func NewLogger(ctx context.Context) Logger { // nolint: dupl
var hub *Hub
hub = GetHubFromContext(ctx)
if hub == nil {
hub = CurrentHub()
}
client := hub.Client()
if client != nil && client.options.EnableLogs {
// Build default attrs
serverAddr := client.options.ServerName
if serverAddr == "" {
serverAddr, _ = os.Hostname()
}
defaults := map[string]string{
"sentry.release": client.options.Release,
"sentry.environment": client.options.Environment,
"sentry.server.address": serverAddr,
"sentry.sdk.name": client.sdkIdentifier,
"sentry.sdk.version": client.sdkVersion,
}
defaultAttrs := make(map[string]attribute.Value, len(defaults))
for k, v := range defaults {
if v != "" {
defaultAttrs[k] = attribute.StringValue(v)
}
}
return &sentryLogger{
ctx: ctx,
hub: hub,
attributes: make(map[string]attribute.Value),
defaultAttributes: defaultAttrs,
mu: sync.RWMutex{},
}
}
debuglog.Println("fallback to noopLogger: enableLogs disabled")
return &noopLogger{}
}
func (l *sentryLogger) Write(p []byte) (int, error) {
msg := strings.TrimRight(string(p), "\n")
l.Info().Emit(msg)
return len(p), nil
}
func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, entryAttrs map[string]attribute.Value, args ...interface{}) {
if message == "" {
return
}
hub := hubFromContexts(ctx, l.ctx)
if hub == nil {
hub = l.hub
}
client := hub.Client()
if client == nil {
return
}
scope := hub.Scope()
traceID, spanID := resolveTrace(scope, client, ctx, l.ctx)
// Pre-allocate with capacity hint to avoid map growth reallocations
estimatedCap := len(l.defaultAttributes) + len(entryAttrs) + len(args) + 8 // scope ~3 + instance ~5
attrs := make(map[string]attribute.Value, estimatedCap)
// attribute precedence: default -> scope -> instance (from SetAttrs) -> entry-specific
for k, v := range l.defaultAttributes {
attrs[k] = v
}
scope.populateAttrs(attrs)
l.mu.RLock()
for k, v := range l.attributes {
attrs[k] = v
}
l.mu.RUnlock()
for k, v := range entryAttrs {
attrs[k] = v
}
if len(args) > 0 {
attrs["sentry.message.template"] = attribute.StringValue(message)
for i, p := range args {
attrs[fmt.Sprintf("sentry.message.parameters.%d", i)] = attribute.StringValue(fmt.Sprintf("%+v", p))
}
}
log := &Log{
Timestamp: time.Now(),
TraceID: traceID,
SpanID: spanID,
Level: level,
Severity: severity,
Body: fmt.Sprintf(message, args...),
Attributes: attrs,
}
log.approximateSize = computeLogSize(log)
client.captureLog(log, scope)
if client.options.Debug {
debuglog.Printf(message, args...)
}
}
func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
l.mu.Lock()
defer l.mu.Unlock()
for _, a := range attrs {
if a.Value.Type() == attribute.INVALID {
debuglog.Printf("invalid attribute: %v", a)
continue
}
l.attributes[a.Key] = a.Value
}
}
func (l *sentryLogger) Trace() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelTrace,
severity: LogSeverityTrace,
attributes: make(map[string]attribute.Value),
}
}
func (l *sentryLogger) Debug() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelDebug,
severity: LogSeverityDebug,
attributes: make(map[string]attribute.Value),
}
}
func (l *sentryLogger) Info() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelInfo,
severity: LogSeverityInfo,
attributes: make(map[string]attribute.Value),
}
}
func (l *sentryLogger) Warn() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelWarn,
severity: LogSeverityWarning,
attributes: make(map[string]attribute.Value),
}
}
func (l *sentryLogger) Error() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelError,
severity: LogSeverityError,
attributes: make(map[string]attribute.Value),
}
}
func (l *sentryLogger) Fatal() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelFatal,
severity: LogSeverityFatal,
attributes: make(map[string]attribute.Value),
shouldFatal: true,
}
}
func (l *sentryLogger) Panic() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelFatal,
severity: LogSeverityFatal,
attributes: make(map[string]attribute.Value),
shouldPanic: true,
}
}
func (l *sentryLogger) LFatal() LogEntry {
return &logEntry{
logger: l,
ctx: l.ctx,
level: LogLevelFatal,
severity: LogSeverityFatal,
attributes: make(map[string]attribute.Value),
}
}
func (l *sentryLogger) GetCtx() context.Context {
return l.ctx
}
func (e *logEntry) WithCtx(ctx context.Context) LogEntry {
return &logEntry{
logger: e.logger,
ctx: ctx,
level: e.level,
severity: e.severity,
attributes: maps.Clone(e.attributes),
shouldPanic: e.shouldPanic,
shouldFatal: e.shouldFatal,
}
}
func (e *logEntry) String(key, value string) LogEntry {
e.attributes[key] = attribute.StringValue(value)
return e
}
func (e *logEntry) StringSlice(key string, value []string) LogEntry {
e.attributes[key] = attribute.StringSliceValue(value)
return e
}
func (e *logEntry) Int(key string, value int) LogEntry {
e.attributes[key] = attribute.Int64Value(int64(value))
return e
}
func (e *logEntry) Int64Slice(key string, value []int64) LogEntry {
e.attributes[key] = attribute.Int64SliceValue(value)
return e
}
func (e *logEntry) Int64(key string, value int64) LogEntry {
e.attributes[key] = attribute.Int64Value(value)
return e
}
func (e *logEntry) Float64Slice(key string, value []float64) LogEntry {
e.attributes[key] = attribute.Float64SliceValue(value)
return e
}
func (e *logEntry) Float64(key string, value float64) LogEntry {
e.attributes[key] = attribute.Float64Value(value)
return e
}
func (e *logEntry) BoolSlice(key string, value []bool) LogEntry {
e.attributes[key] = attribute.BoolSliceValue(value)
return e
}
func (e *logEntry) Bool(key string, value bool) LogEntry {
e.attributes[key] = attribute.BoolValue(value)
return e
}
// Uint64 adds uint64 attributes to the log entry.
//
// This method is intentionally not part of the LogEntry interface to avoid exposing uint64 in the public API.
func (e *logEntry) Uint64(key string, value uint64) LogEntry {
e.attributes[key] = attribute.Uint64Value(value)
return e
}
func (e *logEntry) Emit(args ...interface{}) {
e.logger.log(e.ctx, e.level, e.severity, fmt.Sprint(args...), e.attributes)
if e.level == LogLevelFatal {
if e.shouldPanic {
panic(fmt.Sprint(args...))
}
if e.shouldFatal {
os.Exit(1)
}
}
}
func (e *logEntry) Emitf(format string, args ...interface{}) {
e.logger.log(e.ctx, e.level, e.severity, format, e.attributes, args...)
if e.level == LogLevelFatal {
if e.shouldPanic {
formattedMessage := fmt.Sprintf(format, args...)
panic(formattedMessage)
}
if e.shouldFatal {
os.Exit(1)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
package sentry
import (
"time"
)
// logBatchProcessor batches logs and sends them to Sentry.
type logBatchProcessor struct {
*batchProcessor[Log]
}
func newLogBatchProcessor(client *Client) *logBatchProcessor {
return &logBatchProcessor{
batchProcessor: newBatchProcessor(func(items []Log) {
if len(items) == 0 {
return
}
event := NewEvent()
event.Timestamp = time.Now()
event.EventID = EventID(uuid())
event.Type = logEvent.Type
event.Logs = items
client.Transport.SendEvent(event)
}),
}
}
func (p *logBatchProcessor) Send(log *Log) bool {
return p.batchProcessor.Send(*log)
}
+130
View File
@@ -0,0 +1,130 @@
package sentry
import (
"context"
"fmt"
"os"
"github.com/getsentry/sentry-go/attribute"
"github.com/getsentry/sentry-go/internal/debuglog"
)
// Fallback, no-op logger if logging is disabled.
type noopLogger struct{}
// noopLogEntry implements LogEntry for the no-op logger.
type noopLogEntry struct {
level LogLevel
shouldPanic bool
shouldFatal bool
}
func (n *noopLogEntry) WithCtx(_ context.Context) LogEntry {
return n
}
func (n *noopLogEntry) String(_, _ string) LogEntry {
return n
}
func (n *noopLogEntry) Int(_ string, _ int) LogEntry {
return n
}
func (n *noopLogEntry) Int64(_ string, _ int64) LogEntry {
return n
}
func (n *noopLogEntry) Float64(_ string, _ float64) LogEntry {
return n
}
func (n *noopLogEntry) Bool(_ string, _ bool) LogEntry {
return n
}
func (n *noopLogEntry) StringSlice(_ string, _ []string) LogEntry {
return n
}
func (n *noopLogEntry) Int64Slice(_ string, _ []int64) LogEntry {
return n
}
func (n *noopLogEntry) Float64Slice(_ string, _ []float64) LogEntry {
return n
}
func (n *noopLogEntry) BoolSlice(_ string, _ []bool) LogEntry {
return n
}
func (n *noopLogEntry) Attributes(_ ...attribute.Builder) LogEntry {
return n
}
func (n *noopLogEntry) Emit(args ...interface{}) {
debuglog.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
if n.level == LogLevelFatal {
if n.shouldPanic {
panic(args)
}
if n.shouldFatal {
os.Exit(1)
}
}
}
func (n *noopLogEntry) Emitf(message string, args ...interface{}) {
debuglog.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
if n.level == LogLevelFatal {
if n.shouldPanic {
panic(fmt.Sprintf(message, args...))
}
if n.shouldFatal {
os.Exit(1)
}
}
}
func (n *noopLogger) GetCtx() context.Context { return context.Background() }
func (*noopLogger) Trace() LogEntry {
return &noopLogEntry{level: LogLevelTrace}
}
func (*noopLogger) Debug() LogEntry {
return &noopLogEntry{level: LogLevelDebug}
}
func (*noopLogger) Info() LogEntry {
return &noopLogEntry{level: LogLevelInfo}
}
func (*noopLogger) Warn() LogEntry {
return &noopLogEntry{level: LogLevelWarn}
}
func (*noopLogger) Error() LogEntry {
return &noopLogEntry{level: LogLevelError}
}
func (*noopLogger) Fatal() LogEntry {
return &noopLogEntry{level: LogLevelFatal, shouldFatal: true}
}
func (*noopLogger) Panic() LogEntry {
return &noopLogEntry{level: LogLevelFatal, shouldPanic: true}
}
func (*noopLogger) LFatal() LogEntry {
return &noopLogEntry{level: LogLevelFatal}
}
func (*noopLogger) SetAttributes(...attribute.Builder) {
debuglog.Printf("No attributes attached. Turn on logging via EnableLogs")
}
func (*noopLogger) Write(_ []byte) (n int, err error) {
return 0, fmt.Errorf("log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
}
+32
View File
@@ -0,0 +1,32 @@
package sentry
import (
"time"
)
// metricBatchProcessor batches metrics and sends them to Sentry.
type metricBatchProcessor struct {
*batchProcessor[Metric]
}
func newMetricBatchProcessor(client *Client) *metricBatchProcessor {
return &metricBatchProcessor{
batchProcessor: newBatchProcessor(func(items []Metric) {
if len(items) == 0 {
return
}
event := NewEvent()
event.Timestamp = time.Now()
event.EventID = EventID(uuid())
event.Type = traceMetricEvent.Type
event.Metrics = items
client.Transport.SendEvent(event)
}),
}
}
func (p *metricBatchProcessor) Send(metric *Metric) bool {
return p.batchProcessor.Send(*metric)
}
+241
View File
@@ -0,0 +1,241 @@
package sentry
import (
"context"
"maps"
"os"
"sync"
"time"
"github.com/getsentry/sentry-go/attribute"
"github.com/getsentry/sentry-go/internal/debuglog"
)
// Duration Units.
const (
UnitNanosecond = "nanosecond"
UnitMicrosecond = "microsecond"
UnitMillisecond = "millisecond"
UnitSecond = "second"
UnitMinute = "minute"
UnitHour = "hour"
UnitDay = "day"
UnitWeek = "week"
)
// Information Units.
const (
UnitBit = "bit"
UnitByte = "byte"
UnitKilobyte = "kilobyte"
UnitKibibyte = "kibibyte"
UnitMegabyte = "megabyte"
UnitMebibyte = "mebibyte"
UnitGigabyte = "gigabyte"
UnitGibibyte = "gibibyte"
UnitTerabyte = "terabyte"
UnitTebibyte = "tebibyte"
UnitPetabyte = "petabyte"
UnitPebibyte = "pebibyte"
UnitExabyte = "exabyte"
UnitExbibyte = "exbibyte"
)
// Fraction Units.
const (
UnitRatio = "ratio"
UnitPercent = "percent"
)
// NewMeter returns a new Meter. If there is no Client bound to the current hub, or if metrics are disabled,
// it returns a no-op Meter that discards all metrics.
func NewMeter(ctx context.Context) Meter {
hub := GetHubFromContext(ctx)
if hub == nil {
hub = CurrentHub()
}
client := hub.Client()
if client != nil && !client.options.DisableMetrics {
// build default attrs
serverAddr := client.options.ServerName
if serverAddr == "" {
serverAddr, _ = os.Hostname()
}
defaults := map[string]string{
"sentry.release": client.options.Release,
"sentry.environment": client.options.Environment,
"sentry.server.address": serverAddr,
"sentry.sdk.name": client.sdkIdentifier,
"sentry.sdk.version": client.sdkVersion,
}
defaultAttrs := make(map[string]attribute.Value)
for k, v := range defaults {
if v != "" {
defaultAttrs[k] = attribute.StringValue(v)
}
}
return &sentryMeter{
ctx: ctx,
hub: hub,
attributes: make(map[string]attribute.Value),
defaultAttributes: defaultAttrs,
mu: sync.RWMutex{},
}
}
debuglog.Printf("fallback to noopMeter: metrics disabled")
return &noopMeter{}
}
type sentryMeter struct {
ctx context.Context
hub *Hub
attributes map[string]attribute.Value
defaultAttributes map[string]attribute.Value
mu sync.RWMutex
}
func (m *sentryMeter) emit(ctx context.Context, metricType MetricType, name string, value MetricValue, unit string, attributes map[string]attribute.Value, customScope *Scope) {
if name == "" {
debuglog.Println("empty name provided, dropping metric")
return
}
hub := hubFromContexts(ctx, m.ctx)
if hub == nil {
hub = m.hub
}
client := hub.Client()
if client == nil {
return
}
scope := hub.Scope()
if customScope != nil {
scope = customScope
}
traceID, spanID := resolveTrace(scope, client, ctx, m.ctx)
// Pre-allocate with capacity hint to avoid map growth reallocations
estimatedCap := len(m.defaultAttributes) + len(attributes) + 8 // scope ~3 + call-specific ~5
attrs := make(map[string]attribute.Value, estimatedCap)
// attribute precedence: default -> scope -> instance (from SetAttrs) -> entry-specific
for k, v := range m.defaultAttributes {
attrs[k] = v
}
scope.populateAttrs(attrs)
m.mu.RLock()
for k, v := range m.attributes {
attrs[k] = v
}
m.mu.RUnlock()
for k, v := range attributes {
attrs[k] = v
}
metric := &Metric{
Timestamp: time.Now(),
TraceID: traceID,
SpanID: spanID,
Type: metricType,
Name: name,
Value: value,
Unit: unit,
Attributes: attrs,
}
if client.captureMetric(metric, scope) && client.options.Debug {
debuglog.Printf("Metric %s [%s]: %v %s", metricType, name, value.AsInterface(), unit)
}
}
// WithCtx returns a new Meter that uses the given context for trace/span association.
func (m *sentryMeter) WithCtx(ctx context.Context) Meter {
m.mu.RLock()
attrsCopy := maps.Clone(m.attributes)
m.mu.RUnlock()
return &sentryMeter{
ctx: ctx,
hub: m.hub,
attributes: attrsCopy,
defaultAttributes: m.defaultAttributes,
mu: sync.RWMutex{},
}
}
func (m *sentryMeter) applyOptions(opts []MeterOption) *meterOptions {
o := &meterOptions{}
for _, opt := range opts {
opt(o)
}
return o
}
// Count implements Meter.
func (m *sentryMeter) Count(name string, count int64, opts ...MeterOption) {
o := m.applyOptions(opts)
m.emit(m.ctx, MetricTypeCounter, name, Int64MetricValue(count), o.unit, o.attributes, o.scope)
}
// Distribution implements Meter.
func (m *sentryMeter) Distribution(name string, sample float64, opts ...MeterOption) {
o := m.applyOptions(opts)
m.emit(m.ctx, MetricTypeDistribution, name, Float64MetricValue(sample), o.unit, o.attributes, o.scope)
}
// Gauge implements Meter.
func (m *sentryMeter) Gauge(name string, value float64, opts ...MeterOption) {
o := m.applyOptions(opts)
m.emit(m.ctx, MetricTypeGauge, name, Float64MetricValue(value), o.unit, o.attributes, o.scope)
}
// SetAttributes implements Meter.
func (m *sentryMeter) SetAttributes(attrs ...attribute.Builder) {
m.mu.Lock()
defer m.mu.Unlock()
for _, a := range attrs {
if a.Value.Type() == attribute.INVALID {
debuglog.Printf("invalid attribute: %v", a)
continue
}
m.attributes[a.Key] = a.Value
}
}
// noopMeter is a no-operation implementation of Meter.
// This is used when there is no client available in the context or when metrics are disabled.
type noopMeter struct{}
// WithCtx implements Meter.
func (n *noopMeter) WithCtx(_ context.Context) Meter {
return n
}
// Count implements Meter.
func (n *noopMeter) Count(name string, _ int64, _ ...MeterOption) {
debuglog.Printf("Metric %q is being dropped. Turn on metrics by setting DisableMetrics to false", name)
}
// Distribution implements Meter.
func (n *noopMeter) Distribution(name string, _ float64, _ ...MeterOption) {
debuglog.Printf("Metric %q is being dropped. Turn on metrics by setting DisableMetrics to false", name)
}
// Gauge implements Meter.
func (n *noopMeter) Gauge(name string, _ float64, _ ...MeterOption) {
debuglog.Printf("Metric %q is being dropped. Turn on metrics by setting DisableMetrics to false", name)
}
// SetAttributes implements Meter.
func (n *noopMeter) SetAttributes(_ ...attribute.Builder) {
debuglog.Printf("No attributes attached. Turn on metrics by setting DisableMetrics to false")
}
+99
View File
@@ -0,0 +1,99 @@
package sentry
import (
"context"
"sync"
"time"
)
// MockScope implements [Scope] for use in tests.
type MockScope struct {
breadcrumb *Breadcrumb
shouldDropEvent bool
}
func (scope *MockScope) AddBreadcrumb(breadcrumb *Breadcrumb, _ int) {
scope.breadcrumb = breadcrumb
}
func (scope *MockScope) ApplyToEvent(event *Event, _ *EventHint, _ *Client) *Event {
if scope.shouldDropEvent {
return nil
}
return event
}
// MockTransport implements [Transport] for use in tests.
type MockTransport struct {
mu sync.Mutex
events []*Event
lastEvent *Event
}
func (t *MockTransport) Configure(_ ClientOptions) {}
func (t *MockTransport) SendEvent(event *Event) {
t.mu.Lock()
defer t.mu.Unlock()
t.events = append(t.events, event)
t.lastEvent = event
}
func (t *MockTransport) Flush(_ time.Duration) bool {
return true
}
func (t *MockTransport) FlushWithContext(_ context.Context) bool { return true }
func (t *MockTransport) Events() []*Event {
t.mu.Lock()
defer t.mu.Unlock()
return t.events
}
func (t *MockTransport) Close() {}
// MockLogEntry implements [sentry.LogEntry] for use in tests.
type MockLogEntry struct {
Attributes map[string]any
}
func (m *MockLogEntry) StringSlice(key string, value []string) LogEntry {
m.Attributes[key] = value
return m
}
func (m *MockLogEntry) Int64Slice(key string, value []int64) LogEntry {
m.Attributes[key] = value
return m
}
func (m *MockLogEntry) Float64Slice(key string, value []float64) LogEntry {
m.Attributes[key] = value
return m
}
func (m *MockLogEntry) BoolSlice(key string, value []bool) LogEntry {
m.Attributes[key] = value
return m
}
func NewMockLogEntry() *MockLogEntry {
return &MockLogEntry{Attributes: make(map[string]any)}
}
func (m *MockLogEntry) WithCtx(_ context.Context) LogEntry { return m }
func (m *MockLogEntry) String(key, value string) LogEntry { m.Attributes[key] = value; return m }
func (m *MockLogEntry) Int(key string, value int) LogEntry {
m.Attributes[key] = int64(value)
return m
}
func (m *MockLogEntry) Int64(key string, value int64) LogEntry {
m.Attributes[key] = value
return m
}
func (m *MockLogEntry) Float64(key string, value float64) LogEntry {
m.Attributes[key] = value
return m
}
func (m *MockLogEntry) Bool(key string, value bool) LogEntry {
m.Attributes[key] = value
return m
}
func (m *MockLogEntry) Emit(...any) {}
func (m *MockLogEntry) Emitf(string, ...any) {}
+74
View File
@@ -0,0 +1,74 @@
package sentry
import (
"crypto/rand"
)
type PropagationContext struct {
TraceID TraceID `json:"trace_id"`
SpanID SpanID `json:"span_id"`
ParentSpanID SpanID `json:"parent_span_id,omitzero"`
DynamicSamplingContext DynamicSamplingContext `json:"-"`
}
func (p PropagationContext) Map() map[string]interface{} {
m := map[string]interface{}{
"trace_id": p.TraceID,
"span_id": p.SpanID,
}
if p.ParentSpanID != zeroSpanID {
m["parent_span_id"] = p.ParentSpanID
}
return m
}
func NewPropagationContext() PropagationContext {
p := PropagationContext{}
if _, err := rand.Read(p.TraceID[:]); err != nil {
panic(err)
}
if _, err := rand.Read(p.SpanID[:]); err != nil {
panic(err)
}
return p
}
func PropagationContextFromHeaders(trace, baggage string) (PropagationContext, error) {
p := NewPropagationContext()
if _, err := rand.Read(p.SpanID[:]); err != nil {
panic(err)
}
hasTrace := false
if trace != "" {
if tpc, valid := ParseTraceParentContext([]byte(trace)); valid {
hasTrace = true
p.TraceID = tpc.TraceID
p.ParentSpanID = tpc.ParentSpanID
}
}
if baggage != "" {
dsc, err := DynamicSamplingContextFromHeader([]byte(baggage))
if err != nil {
return PropagationContext{}, err
}
p.DynamicSamplingContext = dsc
}
// In case a sentry-trace header is present but there are no sentry-related
// values in the baggage, create an empty, frozen DynamicSamplingContext.
if hasTrace && !p.DynamicSamplingContext.HasEntries() {
p.DynamicSamplingContext = DynamicSamplingContext{
Frozen: true,
}
}
return p, nil
}
+169
View File
@@ -0,0 +1,169 @@
package report
import (
"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"
)
// Aggregator collects discarded event outcomes for client reports.
// Uses atomic operations to be safe for concurrent use.
type Aggregator struct {
mu sync.Mutex
outcomes map[OutcomeKey]*atomic.Int64
}
// NewAggregator creates a new client report Aggregator.
func NewAggregator() *Aggregator {
a := &Aggregator{
outcomes: make(map[OutcomeKey]*atomic.Int64),
}
return a
}
// Record records a discarded event outcome.
func (a *Aggregator) Record(reason DiscardReason, category ratelimit.Category, quantity int64) {
if a == nil || quantity <= 0 {
return
}
key := OutcomeKey{Reason: reason, Category: category}
a.mu.Lock()
defer a.mu.Unlock()
counter, exists := a.outcomes[key]
if !exists {
counter = &atomic.Int64{}
a.outcomes[key] = counter
}
counter.Add(quantity)
}
// RecordOne is a helper method to record one discarded event outcome.
func (a *Aggregator) RecordOne(reason DiscardReason, category ratelimit.Category) {
a.Record(reason, category, 1)
}
// TakeReport atomically takes all accumulated outcomes and returns a ClientReport.
func (a *Aggregator) TakeReport() *ClientReport {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
if len(a.outcomes) == 0 {
return nil
}
var events []DiscardedEvent
for key, counter := range a.outcomes {
quantity := counter.Swap(0)
if quantity > 0 {
events = append(events, DiscardedEvent{
Reason: key.Reason,
Category: key.Category,
Quantity: quantity,
})
}
}
// Clear empty counters to prevent unbounded growth
for key, counter := range a.outcomes {
if counter.Load() == 0 {
delete(a.outcomes, key)
}
}
if len(events) == 0 {
return nil
}
return &ClientReport{
Timestamp: time.Now(),
DiscardedEvents: events,
}
}
// RecordForEnvelope records client report outcomes for all items in the envelope.
// It inspects envelope item headers to derive categories, span counts, and log byte sizes.
func (a *Aggregator) RecordForEnvelope(reason DiscardReason, envelope *protocol.Envelope) {
if a == nil {
return
}
for _, item := range envelope.Items {
if item == nil || item.Header == nil {
continue
}
switch item.Header.Type {
case protocol.EnvelopeItemTypeEvent:
a.RecordOne(reason, ratelimit.CategoryError)
case protocol.EnvelopeItemTypeTransaction:
a.RecordOne(reason, ratelimit.CategoryTransaction)
spanCount := int64(item.Header.SpanCount)
a.Record(reason, ratelimit.CategorySpan, spanCount)
case protocol.EnvelopeItemTypeLog:
if item.Header.ItemCount != nil {
a.Record(reason, ratelimit.CategoryLog, int64(*item.Header.ItemCount))
}
if item.Header.Length != nil {
a.Record(reason, ratelimit.CategoryLogByte, int64(*item.Header.Length))
}
case protocol.EnvelopeItemTypeTraceMetric:
a.RecordOne(reason, ratelimit.CategoryTraceMetric)
case protocol.EnvelopeItemTypeCheckIn:
a.RecordOne(reason, ratelimit.CategoryMonitor)
case protocol.EnvelopeItemTypeAttachment, protocol.EnvelopeItemTypeClientReport:
// Skip — not reportable categories
}
}
}
// RecordItem records outcomes for a telemetry item, including supplementary
// categories (span outcomes for transactions, byte size for logs).
func (a *Aggregator) RecordItem(reason DiscardReason, item protocol.TelemetryItem) {
category := item.GetCategory()
a.RecordOne(reason, category)
// Span outcomes for transactions
if category == ratelimit.CategoryTransaction {
type spanCounter interface{ GetSpanCount() int }
if sc, ok := item.(spanCounter); ok {
if count := sc.GetSpanCount(); count > 0 {
a.Record(reason, ratelimit.CategorySpan, int64(count))
}
}
}
// Byte size outcomes for logs
if category == ratelimit.CategoryLog {
type sizer interface{ ApproximateSize() int }
if s, ok := item.(sizer); ok {
if size := s.ApproximateSize(); size > 0 {
a.Record(reason, ratelimit.CategoryLogByte, int64(size))
}
}
}
}
// AttachToEnvelope adds a client report to the envelope if the Aggregator has outcomes available.
func (a *Aggregator) AttachToEnvelope(envelope *protocol.Envelope) {
if a == nil {
return
}
r := a.TakeReport()
if r != nil {
rItem, err := r.ToEnvelopeItem()
if err == nil {
envelope.AddItem(rItem)
} else {
debuglog.Printf("failed to serialize client report: %v, with err: %v", r, err)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package report
import (
"github.com/getsentry/sentry-go/internal/protocol"
"github.com/getsentry/sentry-go/internal/ratelimit"
)
// ClientReportRecorder is used by components that need to record lost/discarded events.
type ClientReportRecorder interface {
Record(reason DiscardReason, category ratelimit.Category, quantity int64)
RecordOne(reason DiscardReason, category ratelimit.Category)
RecordForEnvelope(reason DiscardReason, envelope *protocol.Envelope)
RecordItem(reason DiscardReason, item protocol.TelemetryItem)
}
// ClientReportProvider is used by the single component responsible for sending client reports.
type ClientReportProvider interface {
TakeReport() *ClientReport
AttachToEnvelope(envelope *protocol.Envelope)
}
// noopRecorder is a no-op implementation of ClientReportRecorder.
type noopRecorder struct{}
func (noopRecorder) Record(DiscardReason, ratelimit.Category, int64) {}
func (noopRecorder) RecordOne(DiscardReason, ratelimit.Category) {}
func (noopRecorder) RecordForEnvelope(DiscardReason, *protocol.Envelope) {}
func (noopRecorder) RecordItem(DiscardReason, protocol.TelemetryItem) {}
// noopProvider is a no-op implementation of ClientReportProvider.
type noopProvider struct{}
func (noopProvider) TakeReport() *ClientReport { return nil }
func (noopProvider) AttachToEnvelope(_ *protocol.Envelope) {}
// NoopRecorder returns a no-op ClientReportRecorder that silently discards all records.
func NoopRecorder() ClientReportRecorder { return noopRecorder{} }
// NoopProvider returns a no-op ClientReportProvider that always returns nil reports.
func NoopProvider() ClientReportProvider { return noopProvider{} }
+18
View File
@@ -0,0 +1,18 @@
package report
import (
"github.com/getsentry/sentry-go/internal/ratelimit"
)
// OutcomeKey uniquely identifies an outcome bucket for aggregation.
type OutcomeKey struct {
Reason DiscardReason
Category ratelimit.Category
}
// DiscardedEvent represents a single discard event outcome for the OutcomeKey.
type DiscardedEvent struct {
Reason DiscardReason `json:"reason"`
Category ratelimit.Category `json:"category"`
Quantity int64 `json:"quantity"`
}
+38
View File
@@ -0,0 +1,38 @@
package report
// DiscardReason represents why an item was discarded.
type DiscardReason string
const (
// ReasonQueueOverflow indicates the transport queue was full.
ReasonQueueOverflow DiscardReason = "queue_overflow"
// ReasonBufferOverflow indicates that an internal buffer was full.
ReasonBufferOverflow DiscardReason = "buffer_overflow"
// ReasonRateLimitBackoff indicates the item was dropped due to rate limiting.
ReasonRateLimitBackoff DiscardReason = "ratelimit_backoff"
// ReasonBeforeSend indicates the item was dropped due to a BeforeSend callback.
ReasonBeforeSend DiscardReason = "before_send"
// ReasonEventProcessor indicates the item was dropped due to an event processor callback.
ReasonEventProcessor DiscardReason = "event_processor"
// ReasonSampleRate indicates the item was dropped due to sampling.
ReasonSampleRate DiscardReason = "sample_rate"
// ReasonNetworkError indicates an HTTP request failed (connection error).
ReasonNetworkError DiscardReason = "network_error"
// ReasonSendError indicates HTTP returned an error status (4xx, 5xx).
//
// The party that drops an envelope is responsible for counting the event (we skip outcomes that the server records
// `http.StatusTooManyRequests` 429). However, relay does not always record an outcome for oversized envelopes, so
// we accept the trade-off of double counting `http.StatusRequestEntityTooLarge` (413) codes, to record all events.
// For more details https://develop.sentry.dev/sdk/expected-features/#dealing-with-network-failures
ReasonSendError DiscardReason = "send_error"
// ReasonInternalError indicates an internal SDK error.
ReasonInternalError DiscardReason = "internal_sdk_error"
)
+23
View File
@@ -0,0 +1,23 @@
package report
import (
"encoding/json"
"time"
"github.com/getsentry/sentry-go/internal/protocol"
)
// ClientReport is the payload sent to Sentry for tracking discarded events.
type ClientReport struct {
Timestamp time.Time `json:"timestamp"`
DiscardedEvents []DiscardedEvent `json:"discarded_events"`
}
// ToEnvelopeItem converts the ClientReport to an envelope item.
func (r *ClientReport) ToEnvelopeItem() (*protocol.EnvelopeItem, error) {
payload, err := json.Marshal(r)
if err != nil {
return nil, err
}
return protocol.NewClientReportItem(payload), nil
}
+603
View File
@@ -0,0 +1,603 @@
package sentry
import (
"bytes"
"context"
"io"
"maps"
"net/http"
"sync"
"time"
"github.com/getsentry/sentry-go/attribute"
"github.com/getsentry/sentry-go/internal/debuglog"
"github.com/getsentry/sentry-go/internal/ratelimit"
"github.com/getsentry/sentry-go/report"
)
// Scope holds contextual data for the current scope.
//
// The scope is an object that can cloned efficiently and stores data that is
// locally relevant to an event. For instance the scope will hold recorded
// breadcrumbs and similar information.
//
// The scope can be interacted with in two ways. First, the scope is routinely
// updated with information by functions such as AddBreadcrumb which will modify
// the current scope. Second, the current scope can be configured through the
// ConfigureScope function or Hub method of the same name.
//
// The scope is meant to be modified but not inspected directly. When preparing
// an event for reporting, the current client adds information from the current
// scope into the event.
type Scope struct {
mu sync.RWMutex
attributes map[string]attribute.Value
breadcrumbs []*Breadcrumb
attachments []*Attachment
user User
tags map[string]string
contexts map[string]Context
fingerprint []string
level Level
request *http.Request
// requestBody holds a reference to the original request.Body.
requestBody interface {
// Bytes returns bytes from the original body, lazily buffered as the
// original body is read.
Bytes() []byte
// Overflow returns true if the body is larger than the maximum buffer
// size.
Overflow() bool
}
eventProcessors []EventProcessor
propagationContext PropagationContext
span *Span
}
// NewScope creates a new Scope.
func NewScope() *Scope {
return &Scope{
attributes: make(map[string]attribute.Value),
breadcrumbs: make([]*Breadcrumb, 0),
attachments: make([]*Attachment, 0),
tags: make(map[string]string),
contexts: make(map[string]Context),
fingerprint: make([]string, 0),
propagationContext: NewPropagationContext(),
}
}
// AddBreadcrumb adds new breadcrumb to the current scope
// and optionally throws the old one if limit is reached.
func (scope *Scope) AddBreadcrumb(breadcrumb *Breadcrumb, limit int) {
if breadcrumb.Timestamp.IsZero() {
breadcrumb.Timestamp = time.Now()
}
scope.mu.Lock()
defer scope.mu.Unlock()
scope.breadcrumbs = append(scope.breadcrumbs, breadcrumb)
if len(scope.breadcrumbs) > limit {
scope.breadcrumbs = scope.breadcrumbs[1 : limit+1]
}
}
// ClearBreadcrumbs clears all breadcrumbs from the current scope.
func (scope *Scope) ClearBreadcrumbs() {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.breadcrumbs = []*Breadcrumb{}
}
// AddAttachment adds new attachment to the current scope.
func (scope *Scope) AddAttachment(attachment *Attachment) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.attachments = append(scope.attachments, attachment)
}
// ClearAttachments clears all attachments from the current scope.
func (scope *Scope) ClearAttachments() {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.attachments = []*Attachment{}
}
// SetUser sets the user for the current scope.
func (scope *Scope) SetUser(user User) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.user = user
}
// SetRequest sets the request for the current scope.
func (scope *Scope) SetRequest(r *http.Request) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.request = r
if r == nil {
return
}
// Don't buffer request body if we know it is oversized.
if r.ContentLength > maxRequestBodyBytes {
return
}
// Don't buffer if there is no body.
if r.Body == nil || r.Body == http.NoBody {
return
}
buf := &limitedBuffer{Capacity: maxRequestBodyBytes}
r.Body = readCloser{
Reader: io.TeeReader(r.Body, buf),
Closer: r.Body,
}
scope.requestBody = buf
}
// SetRequestBody sets the request body for the current scope.
//
// This method should only be called when the body bytes are already available
// in memory. Typically, the request body is buffered lazily from the
// Request.Body from SetRequest.
func (scope *Scope) SetRequestBody(b []byte) {
scope.mu.Lock()
defer scope.mu.Unlock()
capacity := maxRequestBodyBytes
overflow := false
if len(b) > capacity {
overflow = true
b = b[:capacity]
}
scope.requestBody = &limitedBuffer{
Capacity: capacity,
Buffer: *bytes.NewBuffer(b),
overflow: overflow,
}
}
// maxRequestBodyBytes is the default maximum request body size to send to
// Sentry.
const maxRequestBodyBytes = 10 * 1024
// A limitedBuffer is like a bytes.Buffer, but limited to store at most Capacity
// bytes. Any writes past the capacity are silently discarded, similar to
// io.Discard.
type limitedBuffer struct {
Capacity int
bytes.Buffer
overflow bool
}
// Write implements io.Writer.
func (b *limitedBuffer) Write(p []byte) (n int, err error) {
// Silently ignore writes after overflow.
if b.overflow {
return len(p), nil
}
left := b.Capacity - b.Len()
if left < 0 {
left = 0
}
if len(p) > left {
b.overflow = true
p = p[:left]
}
return b.Buffer.Write(p)
}
// Overflow returns true if the limitedBuffer discarded bytes written to it.
func (b *limitedBuffer) Overflow() bool {
return b.overflow
}
// readCloser combines an io.Reader and an io.Closer to implement io.ReadCloser.
type readCloser struct {
io.Reader
io.Closer
}
// SetAttributes adds attributes to the current scope.
func (scope *Scope) SetAttributes(attrs ...attribute.Builder) {
scope.mu.Lock()
defer scope.mu.Unlock()
for _, a := range attrs {
if a.Value.Type() == attribute.INVALID {
debuglog.Printf("invalid attribute: %v", a)
continue
}
scope.attributes[a.Key] = a.Value
}
}
// RemoveAttribute removes an attribute from the current scope.
func (scope *Scope) RemoveAttribute(key string) {
scope.mu.Lock()
defer scope.mu.Unlock()
delete(scope.attributes, key)
}
// SetTag adds a tag to the current scope.
func (scope *Scope) SetTag(key, value string) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.tags[key] = value
}
// SetTags assigns multiple tags to the current scope.
func (scope *Scope) SetTags(tags map[string]string) {
scope.mu.Lock()
defer scope.mu.Unlock()
for k, v := range tags {
scope.tags[k] = v
}
}
// RemoveTag removes a tag from the current scope.
func (scope *Scope) RemoveTag(key string) {
scope.mu.Lock()
defer scope.mu.Unlock()
delete(scope.tags, key)
}
// SetContext adds a context to the current scope.
func (scope *Scope) SetContext(key string, value Context) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.contexts[key] = value
}
// SetContexts assigns multiple contexts to the current scope.
func (scope *Scope) SetContexts(contexts map[string]Context) {
scope.mu.Lock()
defer scope.mu.Unlock()
for k, v := range contexts {
scope.contexts[k] = v
}
}
// RemoveContext removes a context from the current scope.
func (scope *Scope) RemoveContext(key string) {
scope.mu.Lock()
defer scope.mu.Unlock()
delete(scope.contexts, key)
}
// SetFingerprint sets new fingerprint for the current scope.
func (scope *Scope) SetFingerprint(fingerprint []string) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.fingerprint = fingerprint
}
// SetLevel sets new level for the current scope.
func (scope *Scope) SetLevel(level Level) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.level = level
}
// SetPropagationContext sets the propagation context for the current scope.
func (scope *Scope) SetPropagationContext(propagationContext PropagationContext) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.propagationContext = propagationContext
}
func (scope *Scope) propagationContextSnapshot() PropagationContext {
scope.mu.RLock()
defer scope.mu.RUnlock()
return scope.propagationContext
}
// GetSpan returns the span from the current scope.
func (scope *Scope) GetSpan() *Span {
scope.mu.RLock()
defer scope.mu.RUnlock()
return scope.span
}
// SetSpan sets a span for the current scope.
func (scope *Scope) SetSpan(span *Span) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.span = span
}
// Clone returns a copy of the current scope with all data copied over.
func (scope *Scope) Clone() *Scope {
scope.mu.RLock()
defer scope.mu.RUnlock()
clone := NewScope()
clone.user = scope.user
clone.breadcrumbs = make([]*Breadcrumb, len(scope.breadcrumbs))
copy(clone.breadcrumbs, scope.breadcrumbs)
clone.attachments = make([]*Attachment, len(scope.attachments))
copy(clone.attachments, scope.attachments)
clone.attributes = maps.Clone(scope.attributes)
clone.contexts = maps.Clone(scope.contexts)
clone.tags = maps.Clone(scope.tags)
clone.fingerprint = make([]string, len(scope.fingerprint))
copy(clone.fingerprint, scope.fingerprint)
clone.level = scope.level
clone.request = scope.request
clone.requestBody = scope.requestBody
clone.eventProcessors = scope.eventProcessors
clone.propagationContext = scope.propagationContext
clone.span = scope.span
return clone
}
// Clear removes the data from the current scope. Not safe for concurrent use.
func (scope *Scope) Clear() {
*scope = *NewScope()
}
// AddEventProcessor adds an event processor to the current scope.
func (scope *Scope) AddEventProcessor(processor EventProcessor) {
scope.mu.Lock()
defer scope.mu.Unlock()
scope.eventProcessors = append(scope.eventProcessors, processor)
}
// ApplyToEvent takes the data from the current scope and attaches it to the event.
func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) *Event { //nolint:gocyclo
scope.mu.RLock()
defer scope.mu.RUnlock()
if len(scope.breadcrumbs) > 0 {
event.Breadcrumbs = append(event.Breadcrumbs, scope.breadcrumbs...)
}
if len(scope.attachments) > 0 {
event.Attachments = append(event.Attachments, scope.attachments...)
}
if len(scope.tags) > 0 {
if event.Tags == nil {
event.Tags = make(map[string]string, len(scope.tags))
}
for key, value := range scope.tags {
event.Tags[key] = value
}
}
if len(scope.contexts) > 0 {
if event.Contexts == nil {
event.Contexts = make(map[string]Context)
}
for key, value := range scope.contexts {
if key == "trace" && event.Type == transactionType {
// Do not override trace context of
// transactions, otherwise it breaks the
// transaction event representation.
// For error events, the trace context is used
// to link errors and traces/spans in Sentry.
continue
}
// Ensure we are not overwriting event fields
if _, ok := event.Contexts[key]; !ok {
event.Contexts[key] = cloneContext(value)
}
}
}
if event.Contexts == nil {
event.Contexts = make(map[string]Context)
}
if scope.span != nil {
if _, ok := event.Contexts["trace"]; !ok {
event.Contexts["trace"] = scope.span.traceContext().Map()
}
transaction := scope.span.GetTransaction()
if transaction != nil {
event.sdkMetaData.dsc = DynamicSamplingContextFromTransaction(transaction)
}
} else {
event.Contexts["trace"] = scope.propagationContext.Map()
dsc := scope.propagationContext.DynamicSamplingContext
if !dsc.HasEntries() && client != nil {
dsc = DynamicSamplingContextFromScope(scope, client)
}
event.sdkMetaData.dsc = dsc
}
// If an external trace resolver is registered (e.g. OTel), override
// trace/span IDs from the hint context or the scope's request context.
if client != nil {
var ctx context.Context
if hint != nil {
ctx = hint.Context
}
if ctx == nil && scope.request != nil {
ctx = scope.request.Context()
}
if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); event.Type != transactionType && ok {
traceCtx := event.Contexts["trace"]
traceCtx["trace_id"] = traceID.String()
traceCtx["span_id"] = spanID.String()
}
}
if event.User.IsEmpty() {
event.User = scope.user
}
if len(event.Fingerprint) == 0 {
event.Fingerprint = append(event.Fingerprint, scope.fingerprint...)
}
if scope.level != "" {
event.Level = scope.level
}
if event.Request == nil && scope.request != nil {
event.Request = newRequest(scope.request, sendDefaultPIIEnabled(client))
// NOTE: The SDK does not attempt to send partial request body data.
//
// The reason being that Sentry's ingest pipeline and UI are optimized
// to show structured data. Additionally, tooling around PII scrubbing
// relies on structured data; truncated request bodies would create
// invalid payloads that are more prone to leaking PII data.
//
// Users can still send more data along their events if they want to,
// for example using Event.Contexts.
if scope.requestBody != nil && !scope.requestBody.Overflow() {
event.Request.Data = string(scope.requestBody.Bytes())
}
}
for _, processor := range scope.eventProcessors {
id := event.EventID
category := event.toCategory()
spanCountBefore := event.GetSpanCount()
event = processor(event, hint)
if event == nil {
debuglog.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id)
if client != nil {
client.reportRecorder.RecordOne(report.ReasonEventProcessor, category)
if category == ratelimit.CategoryTransaction {
client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore))
}
}
return nil
}
if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 {
if client != nil {
client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans))
}
}
}
return event
}
// cloneContext returns a new context with keys and values copied from the passed one.
//
// Note: a new Context (map) is returned, but the function does NOT do
// a proper deep copy: if some context values are pointer types (e.g. maps),
// they won't be properly copied.
func cloneContext(c Context) Context {
res := make(Context, len(c))
for k, v := range c {
res[k] = v
}
return res
}
func (scope *Scope) populateAttrs(attrs map[string]attribute.Value) {
if scope == nil {
return
}
scope.mu.RLock()
defer scope.mu.RUnlock()
// Add user-related attributes
if !scope.user.IsEmpty() {
if scope.user.ID != "" {
attrs["user.id"] = attribute.StringValue(scope.user.ID)
}
if scope.user.Name != "" {
attrs["user.name"] = attribute.StringValue(scope.user.Name)
}
if scope.user.Email != "" {
attrs["user.email"] = attribute.StringValue(scope.user.Email)
}
}
for k, v := range scope.attributes {
attrs[k] = v
}
}
// hubFromContexts is a helper to return the first hub found in the given contexts.
func hubFromContexts(ctxs ...context.Context) *Hub {
for _, ctx := range ctxs {
if ctx == nil {
continue
}
if hub := GetHubFromContext(ctx); hub != nil {
return hub
}
}
return nil
}
// resolveTrace resolves trace ID and span ID from the given scope and contexts.
//
// The resolution order follows a most-specific-to-least-specific pattern:
// 1. If an external trace resolver was registered (eg. OTel), we prioritise trace context
// information from that
// 2. Check for span directly in contexts (SpanFromContext) - this is the most specific
// source as it represents a span explicitly attached to the current operation's context
// 3. Check scope's span - provides access to span set on the hub's scope
// 4. Fall back to scope's propagation context trace ID
//
// This ordering ensures we always use the most contextually relevant tracing information.
// For example, if a specific span is active for an operation, we use that span's trace/span IDs
// rather than accidentally using a different span that might be set on the hub's scope.
func resolveTrace(scope *Scope, client *Client, ctxs ...context.Context) (traceID TraceID, spanID SpanID) {
var span *Span
for _, ctx := range ctxs {
if ctx == nil {
continue
}
if client != nil {
if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); ok {
return traceID, spanID
}
}
if span = SpanFromContext(ctx); span != nil {
break
}
}
if scope != nil {
scope.mu.RLock()
if span == nil {
span = scope.span
}
if span != nil {
traceID = span.TraceID
spanID = span.SpanID
} else {
traceID = scope.propagationContext.TraceID
}
scope.mu.RUnlock()
}
return traceID, spanID
}
+152
View File
@@ -0,0 +1,152 @@
package sentry
import (
"context"
"time"
)
// The version of the SDK.
const SDKVersion = "0.46.2"
// apiVersion is the minimum version of the Sentry API compatible with the
// sentry-go SDK.
const apiVersion = "7"
// DefaultFlushTimeout is the default timeout used for flushing events.
const DefaultFlushTimeout = 2 * time.Second
// Init initializes the SDK with options. The returned error is non-nil if
// options is invalid, for instance if a malformed DSN is provided.
func Init(options ClientOptions) error {
hub := CurrentHub()
client, err := NewClient(options)
if err != nil {
return err
}
hub.BindClient(client)
return nil
}
// AddBreadcrumb records a new breadcrumb.
//
// The total number of breadcrumbs that can be recorded are limited by the
// configuration on the client.
func AddBreadcrumb(breadcrumb *Breadcrumb) {
hub := CurrentHub()
hub.AddBreadcrumb(breadcrumb, nil)
}
// CaptureMessage captures an arbitrary message.
func CaptureMessage(message string) *EventID {
hub := CurrentHub()
return hub.CaptureMessage(message)
}
// CaptureException captures an error.
func CaptureException(exception error) *EventID {
hub := CurrentHub()
return hub.CaptureException(exception)
}
// CaptureCheckIn captures a (cron) monitor check-in.
func CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *EventID {
hub := CurrentHub()
return hub.CaptureCheckIn(checkIn, monitorConfig)
}
// CaptureEvent captures an event on the currently active client if any.
//
// The event must already be assembled. Typically code would instead use
// the utility methods like CaptureException. The return value is the
// event ID. In case Sentry is disabled or event was dropped, the return value will be nil.
func CaptureEvent(event *Event) *EventID {
hub := CurrentHub()
return hub.CaptureEvent(event)
}
// Recover captures a panic.
func Recover() *EventID {
if err := recover(); err != nil {
hub := CurrentHub()
return hub.Recover(err)
}
return nil
}
// RecoverWithContext captures a panic and passes relevant context object.
func RecoverWithContext(ctx context.Context) *EventID {
err := recover()
if err == nil {
return nil
}
hub := GetHubFromContext(ctx)
if hub == nil {
hub = CurrentHub()
}
return hub.RecoverWithContext(ctx, err)
}
// WithScope is a shorthand for CurrentHub().WithScope.
func WithScope(f func(scope *Scope)) {
hub := CurrentHub()
hub.WithScope(f)
}
// ConfigureScope is a shorthand for CurrentHub().ConfigureScope.
func ConfigureScope(f func(scope *Scope)) {
hub := CurrentHub()
hub.ConfigureScope(f)
}
// PushScope is a shorthand for CurrentHub().PushScope.
func PushScope() {
hub := CurrentHub()
hub.PushScope()
}
// PopScope is a shorthand for CurrentHub().PopScope.
func PopScope() {
hub := CurrentHub()
hub.PopScope()
}
// Flush waits until the underlying Transport sends any buffered events to the
// Sentry server, blocking for at most the given timeout. It returns false if
// the timeout was reached. In that case, some events may not have been sent.
//
// Flush should be called before terminating the program to avoid
// unintentionally dropping events.
//
// Do not call Flush indiscriminately after every call to CaptureEvent,
// CaptureException or CaptureMessage. Instead, to have the SDK send events over
// the network synchronously, configure it to use the HTTPSyncTransport in the
// call to Init.
func Flush(timeout time.Duration) bool {
hub := CurrentHub()
return hub.Flush(timeout)
}
// FlushWithContext waits until the underlying Transport sends any buffered events
// to the Sentry server, blocking for at most the duration specified by the context.
// It returns false if the context is canceled before the events are sent. In such a case,
// some events may not be delivered.
//
// FlushWithContext should be called before terminating the program to ensure no
// events are unintentionally dropped.
//
// Avoid calling FlushWithContext indiscriminately after each call to CaptureEvent,
// CaptureException, or CaptureMessage. To send events synchronously over the network,
// configure the SDK to use HTTPSyncTransport during initialization with Init.
func FlushWithContext(ctx context.Context) bool {
hub := CurrentHub()
return hub.FlushWithContext(ctx)
}
// LastEventID returns an ID of last captured event.
func LastEventID() EventID {
hub := CurrentHub()
return hub.LastEventID()
}
+70
View File
@@ -0,0 +1,70 @@
package sentry
import (
"bytes"
"os"
"sync"
)
type sourceReader struct {
mu sync.Mutex
cache map[string][][]byte
}
func newSourceReader() sourceReader {
return sourceReader{
cache: make(map[string][][]byte),
}
}
func (sr *sourceReader) readContextLines(filename string, line, context int) ([][]byte, int) {
sr.mu.Lock()
defer sr.mu.Unlock()
lines, ok := sr.cache[filename]
if !ok {
data, err := os.ReadFile(filename)
if err != nil {
sr.cache[filename] = nil
return nil, 0
}
lines = bytes.Split(data, []byte{'\n'})
sr.cache[filename] = lines
}
return sr.calculateContextLines(lines, line, context)
}
func (sr *sourceReader) calculateContextLines(lines [][]byte, line, context int) ([][]byte, int) {
// Stacktrace lines are 1-indexed, slices are 0-indexed
line--
// contextLine points to a line that caused an issue itself, in relation to
// returned slice.
contextLine := context
if lines == nil || line >= len(lines) || line < 0 {
return nil, 0
}
if context < 0 {
context = 0
contextLine = 0
}
start := line - context
if start < 0 {
contextLine += start
start = 0
}
end := line + context + 1
if end > len(lines) {
end = len(lines)
}
return lines[start:end], contextLine
}
+58
View File
@@ -0,0 +1,58 @@
package sentry
import (
"sync"
"github.com/getsentry/sentry-go/internal/debuglog"
)
// A spanRecorder stores a span tree that makes up a transaction. Safe for
// concurrent use. It is okay to add child spans from multiple goroutines.
type spanRecorder struct {
mu sync.Mutex
spans []*Span
overflowOnce sync.Once
}
// record stores a span. The first stored span is assumed to be the root of a
// span tree.
func (r *spanRecorder) record(s *Span) {
maxSpans := defaultMaxSpans
if client := CurrentHub().Client(); client != nil {
maxSpans = client.options.MaxSpans
}
r.mu.Lock()
defer r.mu.Unlock()
if len(r.spans) >= maxSpans {
r.overflowOnce.Do(func() {
root := r.spans[0]
debuglog.Printf("Too many spans: dropping spans from transaction with TraceID=%s SpanID=%s limit=%d",
root.TraceID, root.SpanID, maxSpans)
})
// TODO(tracing): mark the transaction event in some way to
// communicate that spans were dropped.
return
}
r.spans = append(r.spans, s)
}
// root returns the first recorded span. Returns nil if none have been recorded.
func (r *spanRecorder) root() *Span {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.spans) == 0 {
return nil
}
return r.spans[0]
}
// children returns a list of all recorded spans, except the root. Returns nil
// if there are no children.
func (r *spanRecorder) children() []*Span {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.spans) < 2 {
return nil
}
return r.spans[1:]
}
+407
View File
@@ -0,0 +1,407 @@
package sentry
import (
"go/build"
"reflect"
"runtime"
"slices"
"strings"
)
const unknown string = "unknown"
// The module download is split into two parts: downloading the go.mod and downloading the actual code.
// If you have dependencies only needed for tests, then they will show up in your go.mod,
// and go get will download their go.mods, but it will not download their code.
// The test-only dependencies get downloaded only when you need it, such as the first time you run go test.
//
// https://github.com/golang/go/issues/26913#issuecomment-411976222
// Stacktrace holds information about the frames of the stack.
type Stacktrace struct {
Frames []Frame `json:"frames,omitempty"`
FramesOmitted []uint `json:"frames_omitted,omitempty"`
}
// NewStacktrace creates a stacktrace using runtime.Callers.
func NewStacktrace() *Stacktrace {
pcs := make([]uintptr, 100)
n := runtime.Callers(1, pcs)
if n == 0 {
return nil
}
runtimeFrames := extractFrames(pcs[:n])
frames := createFrames(runtimeFrames)
stacktrace := Stacktrace{
Frames: frames,
}
return &stacktrace
}
// TODO: Make it configurable so that anyone can provide their own implementation?
// Use of reflection allows us to not have a hard dependency on any given
// package, so we don't have to import it.
// ExtractStacktrace creates a new Stacktrace based on the given error.
func ExtractStacktrace(err error) *Stacktrace {
method := extractReflectedStacktraceMethod(err)
var pcs []uintptr
if method.IsValid() {
pcs = extractPcs(method)
} else {
pcs = extractXErrorsPC(err)
}
if len(pcs) == 0 {
return nil
}
runtimeFrames := extractFrames(pcs)
frames := createFrames(runtimeFrames)
stacktrace := Stacktrace{
Frames: frames,
}
return &stacktrace
}
func extractReflectedStacktraceMethod(err error) reflect.Value {
errValue := reflect.ValueOf(err)
// https://github.com/go-errors/errors
methodStackFrames := errValue.MethodByName("StackFrames")
if methodStackFrames.IsValid() {
return methodStackFrames
}
// https://github.com/pkg/errors
methodStackTrace := errValue.MethodByName("StackTrace")
if methodStackTrace.IsValid() {
return methodStackTrace
}
// https://github.com/pingcap/errors
methodGetStackTracer := errValue.MethodByName("GetStackTracer")
if methodGetStackTracer.IsValid() {
stacktracer := methodGetStackTracer.Call(nil)[0]
stacktracerStackTrace := reflect.ValueOf(stacktracer).MethodByName("StackTrace")
if stacktracerStackTrace.IsValid() {
return stacktracerStackTrace
}
}
return reflect.Value{}
}
func extractPcs(method reflect.Value) []uintptr {
var pcs []uintptr
stacktrace := method.Call(nil)[0]
if stacktrace.Kind() != reflect.Slice {
return nil
}
for i := 0; i < stacktrace.Len(); i++ {
pc := stacktrace.Index(i)
switch pc.Kind() {
case reflect.Uintptr:
pcs = append(pcs, uintptr(pc.Uint()))
case reflect.Struct:
for _, fieldName := range []string{"ProgramCounter", "PC"} {
field := pc.FieldByName(fieldName)
if !field.IsValid() {
continue
}
if field.Kind() == reflect.Uintptr {
pcs = append(pcs, uintptr(field.Uint()))
break
}
}
}
}
return pcs
}
// extractXErrorsPC extracts program counters from error values compatible with
// the error types from golang.org/x/xerrors.
//
// It returns nil if err is not compatible with errors from that package or if
// no program counters are stored in err.
func extractXErrorsPC(err error) []uintptr {
// This implementation uses the reflect package to avoid a hard dependency
// on third-party packages.
// We don't know if err matches the expected type. For simplicity, instead
// of trying to account for all possible ways things can go wrong, some
// assumptions are made and if they are violated the code will panic. We
// recover from any panic and ignore it, returning nil.
//nolint: errcheck
defer func() { recover() }()
field := reflect.ValueOf(err).Elem().FieldByName("frame") // type Frame struct{ frames [3]uintptr }
field = field.FieldByName("frames")
field = field.Slice(1, field.Len()) // drop first pc pointing to xerrors.New
pc := make([]uintptr, field.Len())
for i := 0; i < field.Len(); i++ {
pc[i] = uintptr(field.Index(i).Uint())
}
return pc
}
// Frame represents a function call and it's metadata. Frames are associated
// with a Stacktrace.
type Frame struct {
Function string `json:"function,omitempty"`
Symbol string `json:"symbol,omitempty"`
// Module is, despite the name, the Sentry protocol equivalent of a Go
// package's import path.
Module string `json:"module,omitempty"`
Filename string `json:"filename,omitempty"`
AbsPath string `json:"abs_path,omitempty"`
Lineno int `json:"lineno,omitempty"`
Colno int `json:"colno,omitempty"`
PreContext []string `json:"pre_context,omitempty"`
ContextLine string `json:"context_line,omitempty"`
PostContext []string `json:"post_context,omitempty"`
InApp bool `json:"in_app"`
Vars map[string]interface{} `json:"vars,omitempty"`
// Package and the below are not used for Go stack trace frames. In
// other platforms it refers to a container where the Module can be
// found. For example, a Java JAR, a .NET Assembly, or a native
// dynamic library. They exists for completeness, allowing the
// construction and reporting of custom event payloads.
Package string `json:"package,omitempty"`
InstructionAddr string `json:"instruction_addr,omitempty"`
AddrMode string `json:"addr_mode,omitempty"`
SymbolAddr string `json:"symbol_addr,omitempty"`
ImageAddr string `json:"image_addr,omitempty"`
Platform string `json:"platform,omitempty"`
StackStart bool `json:"stack_start,omitempty"`
}
// NewFrame assembles a stacktrace frame out of runtime.Frame.
func NewFrame(f runtime.Frame) Frame {
function := f.Function
var pkg string
if function != "" {
pkg, function = splitQualifiedFunctionName(function)
}
return newFrame(pkg, function, f.File, f.Line)
}
// Like filepath.IsAbs() but doesn't care what platform you run this on.
// I.e. it also recognizies `/path/to/file` when run on Windows.
func isAbsPath(path string) bool {
if len(path) == 0 {
return false
}
// If the volume name starts with a double slash, this is an absolute path.
if len(path) >= 1 && (path[0] == '/' || path[0] == '\\') {
return true
}
// Windows absolute path, see https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
return true
}
return false
}
func newFrame(module string, function string, file string, line int) Frame {
frame := Frame{
Lineno: line,
Module: module,
Function: function,
}
switch {
case len(file) == 0:
frame.Filename = unknown
// Leave abspath as the empty string to be omitted when serializing event as JSON.
case isAbsPath(file):
frame.AbsPath = file
// TODO: in the general case, it is not trivial to come up with a
// "project relative" path with the data we have in run time.
// We shall not use filepath.Base because it creates ambiguous paths and
// affects the "Suspect Commits" feature.
// For now, leave relpath empty to be omitted when serializing the event
// as JSON. Improve this later.
default:
// f.File is a relative path. This may happen when the binary is built
// with the -trimpath flag.
frame.Filename = file
// Omit abspath when serializing the event as JSON.
}
setInAppFrame(&frame)
return frame
}
// splitQualifiedFunctionName splits a package path-qualified function name into
// package name and function name. Such qualified names are found in
// runtime.Frame.Function values.
func splitQualifiedFunctionName(name string) (pkg string, fun string) {
pkg = packageName(name)
if len(pkg) > 0 {
fun = name[len(pkg)+1:]
}
return
}
func extractFrames(pcs []uintptr) []runtime.Frame {
var frames = make([]runtime.Frame, 0, len(pcs))
callersFrames := runtime.CallersFrames(pcs)
for {
callerFrame, more := callersFrames.Next()
frames = append(frames, callerFrame)
if !more {
break
}
}
slices.Reverse(frames)
return frames
}
// createFrames creates Frame objects while filtering out frames that are not
// meant to be reported to Sentry, those are frames internal to the SDK or Go.
func createFrames(frames []runtime.Frame) []Frame {
if len(frames) == 0 {
return nil
}
result := make([]Frame, 0, len(frames))
for _, frame := range frames {
function := frame.Function
var pkg string
if function != "" {
pkg, function = splitQualifiedFunctionName(function)
}
if !shouldSkipFrame(pkg) {
result = append(result, newFrame(pkg, function, frame.File, frame.Line))
}
}
// Fix issues grouping errors with the new fully qualified function names
// introduced from Go 1.21
result = cleanupFunctionNamePrefix(result)
return result
}
// TODO ID: why do we want to do this?
// I'm not aware of other SDKs skipping all Sentry frames, regardless of their position in the stactrace.
// For example, in the .NET SDK, only the first frames are skipped until the call to the SDK.
// As is, this will also hide any intermediate frames in the stack and make debugging issues harder.
func shouldSkipFrame(module string) bool {
// Skip Go internal frames.
if module == "runtime" || module == "testing" {
return true
}
// Skip Sentry internal frames, except for frames in _test packages (for testing).
if strings.HasPrefix(module, "github.com/getsentry/sentry-go") &&
!strings.HasSuffix(module, "_test") {
return true
}
return false
}
// On Windows, GOROOT has backslashes, but we want forward slashes.
var goRoot = strings.ReplaceAll(build.Default.GOROOT, "\\", "/")
func setInAppFrame(frame *Frame) {
frame.InApp = true
if strings.HasPrefix(frame.AbsPath, goRoot) || strings.Contains(frame.Module, "vendor") ||
strings.Contains(frame.Module, "third_party") {
frame.InApp = false
}
}
func callerFunctionName() string {
pcs := make([]uintptr, 1)
runtime.Callers(3, pcs)
callersFrames := runtime.CallersFrames(pcs)
callerFrame, _ := callersFrames.Next()
return baseName(callerFrame.Function)
}
// packageName returns the package part of the symbol name, or the empty string
// if there is none.
// It replicates https://golang.org/pkg/debug/gosym/#Sym.PackageName, avoiding a
// dependency on debug/gosym.
func packageName(name string) string {
if isCompilerGeneratedSymbol(name) {
return ""
}
pathend := strings.LastIndex(name, "/")
if pathend < 0 {
pathend = 0
}
if i := strings.Index(name[pathend:], "."); i != -1 {
return name[:pathend+i]
}
return ""
}
// baseName returns the symbol name without the package or receiver name.
// It replicates https://golang.org/pkg/debug/gosym/#Sym.BaseName, avoiding a
// dependency on debug/gosym.
func baseName(name string) string {
if i := strings.LastIndex(name, "."); i != -1 {
return name[i+1:]
}
return name
}
func isCompilerGeneratedSymbol(name string) bool {
// In versions of Go 1.20 and above a prefix of "type:" and "go:" is a
// compiler-generated symbol that doesn't belong to any package.
// See variable reservedimports in cmd/compile/internal/gc/subr.go
if strings.HasPrefix(name, "go:") || strings.HasPrefix(name, "type:") {
return true
}
return false
}
// Walk backwards through the results and for the current function name
// remove it's parent function's prefix, leaving only it's actual name. This
// fixes issues grouping errors with the new fully qualified function names
// introduced from Go 1.21.
func cleanupFunctionNamePrefix(f []Frame) []Frame {
for i := len(f) - 1; i > 0; i-- {
name := f[i].Function
parentName := f[i-1].Function + "."
if !strings.HasPrefix(name, parentName) {
continue
}
f[i].Function = name[len(parentName):]
}
return f
}
+26
View File
@@ -0,0 +1,26 @@
package sentry
// A SamplingContext is passed to a TracesSampler to determine a sampling
// decision.
//
// TODO(tracing): possibly expand SamplingContext to include custom /
// user-provided data.
type SamplingContext struct {
Span *Span // The current span, always non-nil.
Parent *Span // The local parent span, non-nil only when the parent exists in the same process.
// ParentSampled is the sampling decision of the parent span.
//
// For a remote span, Parent is nil but ParentSampled reflects the upstream decision.
// For a local span, ParentSampled mirrors Parent.Sampled.
ParentSampled Sampled
// ParentSampleRate is the sample rate used by the parent transaction.
ParentSampleRate *float64
}
// The TracesSample type is an adapter to allow the use of ordinary
// functions as a TracesSampler.
type TracesSampler func(ctx SamplingContext) float64
func (f TracesSampler) Sample(ctx SamplingContext) float64 {
return f(ctx)
}
+1234
View File
File diff suppressed because it is too large Load Diff
+964
View File
@@ -0,0 +1,964 @@
package sentry
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/getsentry/sentry-go/internal/debuglog"
httpinternal "github.com/getsentry/sentry-go/internal/http"
"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 (
defaultBufferSize = 1000
defaultTimeout = time.Second * 30
defaultClientReportsTick = time.Second * 30
)
// Transport is used by the Client to deliver events to remote server.
type Transport interface {
Flush(timeout time.Duration) bool
FlushWithContext(ctx context.Context) bool
Configure(options ClientOptions)
SendEvent(event *Event)
Close()
}
func getProxyConfig(options ClientOptions) func(*http.Request) (*url.URL, error) {
if options.HTTPSProxy != "" {
return func(*http.Request) (*url.URL, error) {
return url.Parse(options.HTTPSProxy)
}
}
if options.HTTPProxy != "" {
return func(*http.Request) (*url.URL, error) {
return url.Parse(options.HTTPProxy)
}
}
return http.ProxyFromEnvironment
}
func getTLSConfig(options ClientOptions) *tls.Config {
if options.CaCerts != nil {
// #nosec G402 -- We should be using `MinVersion: tls.VersionTLS12`,
// but we don't want to break peoples code without the major bump.
return &tls.Config{
RootCAs: options.CaCerts,
}
}
return nil
}
// eventDebugContext returns a panic-safe summary of an event for debug logging.
// It only touches scalar fields and len() of collections, which do not trigger
// the runtime concurrent-map-access fatal nor invoke user-defined Stringers.
func eventDebugContext(event *Event) string {
return fmt.Sprintf(
"event=%s type=%s level=%s "+
"breadcrumbs=%d exceptions=%d tags=%d contexts=%d attachments=%d",
event.EventID, event.Type, event.Level,
len(event.Breadcrumbs), len(event.Exception),
len(event.Tags), len(event.Contexts), len(event.Attachments),
)
}
func getRequestBodyFromEvent(event *Event) []byte {
body, err := json.Marshal(event)
if err != nil {
debuglog.Printf("Could not encode event as JSON, skipping delivery. %s: %v", eventDebugContext(event), err)
return nil
}
return body
}
func encodeAttachment(enc *json.Encoder, b io.Writer, attachment *Attachment) error {
// Attachment header
err := enc.Encode(struct {
Type string `json:"type"`
Length int `json:"length"`
Filename string `json:"filename"`
ContentType string `json:"content_type,omitempty"`
}{
Type: "attachment",
Length: len(attachment.Payload),
Filename: attachment.Filename,
ContentType: attachment.ContentType,
})
if err != nil {
return err
}
// Attachment payload
if _, err = b.Write(attachment.Payload); err != nil {
return err
}
// "Envelopes should be terminated with a trailing newline."
//
// [1]: https://develop.sentry.dev/sdk/envelopes/#envelopes
if _, err := b.Write([]byte("\n")); err != nil {
return err
}
return nil
}
func encodeClientReport(enc *json.Encoder, cr *report.ClientReport) error {
payload, err := json.Marshal(cr)
if err != nil {
return err
}
err = encodeEnvelopeItem(enc, string(protocol.EnvelopeItemTypeClientReport), payload)
if err != nil {
return err
}
return nil
}
func encodeEnvelopeItem(enc *json.Encoder, itemType string, body json.RawMessage) error {
// Item header
err := enc.Encode(struct {
Type string `json:"type"`
Length int `json:"length"`
}{
Type: itemType,
Length: len(body),
})
if err == nil {
// payload
err = enc.Encode(body)
}
return err
}
func encodeEnvelopeLogs(enc *json.Encoder, count int, body json.RawMessage) error {
err := enc.Encode(
struct {
Type string `json:"type"`
ItemCount int `json:"item_count"`
ContentType string `json:"content_type"`
}{
Type: logEvent.Type,
ItemCount: count,
ContentType: logEvent.ContentType,
})
if err == nil {
err = enc.Encode(body)
}
return err
}
func encodeEnvelopeMetrics(enc *json.Encoder, count int, body json.RawMessage) error {
err := enc.Encode(
struct {
Type string `json:"type"`
ItemCount int `json:"item_count"`
ContentType string `json:"content_type"`
}{
Type: traceMetricEvent.Type,
ItemCount: count,
ContentType: traceMetricEvent.ContentType,
})
if err == nil {
err = enc.Encode(body)
}
return err
}
func recordForEvent(recorder report.ClientReportRecorder, reason report.DiscardReason, event *Event) {
category := event.toCategory()
switch event.Type {
case transactionType:
recorder.RecordOne(reason, category)
if count := event.GetSpanCount(); count > 0 {
recorder.Record(reason, ratelimit.CategorySpan, int64(count))
}
case logEvent.Type:
if count := len(event.Logs); count > 0 {
recorder.Record(reason, ratelimit.CategoryLog, int64(count))
}
if size := event.GetLogByteSize(); size > 0 {
recorder.Record(reason, ratelimit.CategoryLogByte, int64(size))
}
default:
recorder.RecordOne(reason, category)
}
}
func recordForBatchItem(recorder report.ClientReportRecorder, reason report.DiscardReason, item *batchItem) {
switch {
case item.logItemCount > 0:
recorder.Record(reason, ratelimit.CategoryLog, int64(item.logItemCount))
if item.logByteSize > 0 {
recorder.Record(reason, ratelimit.CategoryLogByte, int64(item.logByteSize))
}
default:
recorder.RecordOne(reason, item.category)
if item.spanCount > 0 {
recorder.Record(reason, ratelimit.CategorySpan, int64(item.spanCount))
}
}
}
// envelopeHeader represents the header of a Sentry envelope.
type envelopeHeader struct {
EventID EventID `json:"event_id,omitempty"`
SentAt time.Time `json:"sent_at"`
Dsn *Dsn `json:"dsn,omitempty"`
Sdk map[string]string `json:"sdk,omitempty"`
Trace map[string]string `json:"trace,omitempty"`
}
func encodeEnvelopeHeader(enc *json.Encoder, header *envelopeHeader) error {
return enc.Encode(header)
}
func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMessage) (*bytes.Buffer, error) {
var b bytes.Buffer
enc := json.NewEncoder(&b)
// Construct the trace envelope header
var trace = map[string]string{}
if dsc := event.sdkMetaData.dsc; dsc.HasEntries() {
for k, v := range dsc.Entries {
trace[k] = v
}
}
// Envelope header
err := encodeEnvelopeHeader(enc, &envelopeHeader{
EventID: event.EventID,
SentAt: sentAt,
Trace: trace,
Dsn: dsn,
Sdk: map[string]string{
"name": event.Sdk.Name,
"version": event.Sdk.Version,
},
})
if err != nil {
return nil, err
}
switch event.Type {
case transactionType, checkInType:
err = encodeEnvelopeItem(enc, event.Type, body)
case logEvent.Type:
err = encodeEnvelopeLogs(enc, len(event.Logs), body)
case traceMetricEvent.Type:
err = encodeEnvelopeMetrics(enc, len(event.Metrics), body)
default:
err = encodeEnvelopeItem(enc, eventType, body)
}
if err != nil {
return nil, err
}
// Attachments
for _, attachment := range event.Attachments {
if err := encodeAttachment(enc, &b, attachment); err != nil {
return nil, err
}
}
return &b, nil
}
// getRequestFromEnvelope creates an HTTP request from a pre-built envelope.
// sdkName and sdkVersion are used for User-Agent and authentication headers.
func getRequestFromEnvelope(ctx context.Context, dsn *Dsn, envelope *bytes.Buffer, sdkName, sdkVersion string) (*http.Request, error) {
if ctx == nil {
ctx = context.Background()
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
dsn.GetAPIURL().String(),
envelope,
)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", fmt.Sprintf("%s/%s", sdkName, sdkVersion))
request.Header.Set("Content-Type", "application/x-sentry-envelope")
auth := fmt.Sprintf("Sentry sentry_version=%s, "+
"sentry_client=%s/%s, sentry_key=%s", apiVersion, sdkName, sdkVersion, dsn.GetPublicKey())
// The key sentry_secret is effectively deprecated and no longer needs to be set.
// However, since it was required in older self-hosted versions,
// it should still be passed through to Sentry if set.
if dsn.GetSecretKey() != "" {
auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey())
}
request.Header.Set("X-Sentry-Auth", auth)
return request, nil
}
// ================================
// HTTPTransport
// ================================
// A batch groups items that are processed sequentially.
type batch struct {
items chan batchItem
started chan struct{} // closed to signal items started to be worked on
done chan struct{} // closed to signal completion of all items
}
type batchItem struct {
ctx context.Context
envelope *bytes.Buffer
sdkName string
sdkVersion string
category ratelimit.Category
eventIdentifier string
spanCount int
logItemCount int
logByteSize int
}
// HTTPTransport is the default, non-blocking, implementation of Transport.
//
// Clients using this transport will enqueue requests in a buffer and return to
// the caller before any network communication has happened. Requests are sent
// to Sentry sequentially from a background goroutine.
type HTTPTransport struct {
dsn *Dsn
client *http.Client
transport http.RoundTripper
recorder report.ClientReportRecorder
provider report.ClientReportProvider
// buffer is a channel of batches. Calling Flush terminates work on the
// current in-flight items and starts a new batch for subsequent events.
buffer chan batch
startOnce sync.Once
closeOnce sync.Once
// Size of the transport buffer. Defaults to 30.
BufferSize int
// HTTP Client request timeout. Defaults to 30 seconds.
Timeout time.Duration
mu sync.RWMutex
limits ratelimit.Map
// receiving signal will terminate worker.
done chan struct{}
}
// NewHTTPTransport returns a new pre-configured instance of HTTPTransport.
func NewHTTPTransport() *HTTPTransport {
transport := HTTPTransport{
BufferSize: defaultBufferSize,
Timeout: defaultTimeout,
done: make(chan struct{}),
limits: make(ratelimit.Map),
}
return &transport
}
// Configure is called by the Client itself, providing its own ClientOptions.
func (t *HTTPTransport) Configure(options ClientOptions) {
dsn, err := NewDsn(options.Dsn)
if err != nil {
debuglog.Printf("%v\n", err)
return
}
t.dsn = dsn
if t.recorder == nil {
t.recorder = report.NoopRecorder()
}
if t.provider == nil {
t.provider = report.NoopProvider()
}
// A buffered channel with capacity 1 works like a mutex, ensuring only one
// goroutine can access the current batch at a given time. Access is
// synchronized by reading from and writing to the channel.
t.buffer = make(chan batch, 1)
t.buffer <- batch{
items: make(chan batchItem, t.BufferSize),
started: make(chan struct{}),
done: make(chan struct{}),
}
if options.HTTPTransport != nil {
t.transport = options.HTTPTransport
} else {
t.transport = &http.Transport{
Proxy: getProxyConfig(options),
TLSClientConfig: getTLSConfig(options),
}
}
if options.HTTPClient != nil {
t.client = options.HTTPClient
} else {
t.client = &http.Client{
Transport: t.transport,
Timeout: t.Timeout,
}
}
t.startOnce.Do(func() {
go t.worker()
})
}
// SendEvent assembles a new packet out of Event and sends it to the remote server.
func (t *HTTPTransport) SendEvent(event *Event) {
t.SendEventWithContext(context.Background(), event)
}
// SendEventWithContext assembles a new packet out of Event and sends it to the remote server.
func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) {
if t.dsn == nil {
return
}
category := event.toCategory()
if t.disabled(category) {
recordForEvent(t.recorder, report.ReasonRateLimitBackoff, event)
return
}
body := getRequestBodyFromEvent(event)
if body == nil {
recordForEvent(t.recorder, report.ReasonInternalError, event)
return
}
envelope, err := envelopeFromBody(event, t.dsn, time.Now(), body)
if err != nil {
debuglog.Printf("Failed to build envelope, skipping delivery. %s: %v", eventDebugContext(event), err)
recordForEvent(t.recorder, report.ReasonInternalError, event)
return
}
// <-t.buffer is equivalent to acquiring a lock to access the current batch.
// A few lines below, t.buffer <- b releases the lock.
//
// The lock must be held during the select block below to guarantee that
// b.items is not closed while trying to send to it. Remember that sending
// on a closed channel panics.
//
// Note that the select block takes a bounded amount of CPU time because of
// the default case that is executed if sending on b.items would block. That
// is, the event is dropped if it cannot be sent immediately to the b.items
// channel (used as a queue).
b := <-t.buffer
identifier := eventIdentifier(event)
select {
case b.items <- batchItem{
ctx: ctx,
envelope: envelope,
sdkName: event.Sdk.Name,
sdkVersion: event.Sdk.Version,
category: category,
eventIdentifier: identifier,
spanCount: event.GetSpanCount(),
logItemCount: len(event.Logs),
logByteSize: event.GetLogByteSize(),
}:
debuglog.Printf(
"Sending %s to %s project: %s",
identifier,
t.dsn.GetHost(),
t.dsn.GetProjectID(),
)
default:
debuglog.Printf("Event dropped due to transport buffer being full. %s", eventDebugContext(event))
recordForEvent(t.recorder, report.ReasonQueueOverflow, event)
}
t.buffer <- b
}
// Flush waits until any buffered events are sent to the Sentry server, blocking
// for at most the given timeout. It returns false if the timeout was reached.
// In that case, some events may not have been sent.
//
// Flush should be called before terminating the program to avoid
// unintentionally dropping events.
//
// Do not call Flush indiscriminately after every call to SendEvent. Instead, to
// have the SDK send events over the network synchronously, configure it to use
// the HTTPSyncTransport in the call to Init.
func (t *HTTPTransport) Flush(timeout time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return t.FlushWithContext(ctx)
}
// FlushWithContext works like Flush, but it accepts a context.Context instead of a timeout.
func (t *HTTPTransport) FlushWithContext(ctx context.Context) bool {
return t.flushInternal(ctx.Done())
}
func (t *HTTPTransport) flushInternal(timeout <-chan struct{}) bool {
// Wait until processing the current batch has started or the timeout.
//
// We must wait until the worker has seen the current batch, because it is
// the only way b.done will be closed. If we do not wait, there is a
// possible execution flow in which b.done is never closed, and the only way
// out of Flush would be waiting for the timeout, which is undesired.
var b batch
for {
select {
case b = <-t.buffer:
select {
case <-b.started:
goto started
default:
t.buffer <- b
}
case <-timeout:
goto fail
}
}
started:
// Signal that there won't be any more items in this batch, so that the
// worker inner loop can end.
close(b.items)
// Start a new batch for subsequent events.
t.buffer <- batch{
items: make(chan batchItem, t.BufferSize),
started: make(chan struct{}),
done: make(chan struct{}),
}
// Wait until the current batch is done or the timeout.
select {
case <-b.done:
debuglog.Println("Buffer flushed successfully.")
return true
case <-timeout:
goto fail
}
fail:
debuglog.Println("Buffer flushing was canceled or timed out.")
return false
}
// Close will terminate events sending loop.
// It useful to prevent goroutines leak in case of multiple HTTPTransport instances initiated.
//
// Close should be called after Flush and before terminating the program
// otherwise some events may be lost.
func (t *HTTPTransport) Close() {
t.closeOnce.Do(func() {
close(t.done)
})
}
func (t *HTTPTransport) worker() {
crTicker := time.NewTicker(defaultClientReportsTick)
defer crTicker.Stop()
for b := range t.buffer {
// Signal that processing of the current batch has started.
close(b.started)
// Return the batch to the buffer so that other goroutines can use it.
// Equivalent to releasing a lock.
t.buffer <- b
// Process all batch items.
loop:
for {
select {
case <-t.done:
return
case <-crTicker.C:
r := t.provider.TakeReport()
if r != nil {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
if err := encodeEnvelopeHeader(enc, &envelopeHeader{
SentAt: time.Now(),
Dsn: t.dsn,
Sdk: map[string]string{
"name": sdkIdentifier,
"version": SDKVersion,
},
}); err != nil {
continue
}
if err := encodeClientReport(enc, r); err != nil {
continue
}
req, err := getRequestFromEnvelope(context.Background(), t.dsn, &buf, sdkIdentifier, SDKVersion)
if err != nil {
debuglog.Printf("There was an issue when creating the request: %v", err)
continue
}
result, err := util.DoSendRequest(t.client, req, "client report")
if err != nil {
debuglog.Printf("There was an issue with sending an event: %v", err)
continue
}
t.mu.Lock()
t.limits.Merge(result.Limits)
t.mu.Unlock()
}
case item, open := <-b.items:
if !open {
break loop
}
if t.disabled(item.category) {
recordForBatchItem(t.recorder, report.ReasonRateLimitBackoff, &item)
continue
}
// Attach accumulated client report inside the worker to avoid background queue overflows.
t.attachClientReport(item.envelope)
request, err := getRequestFromEnvelope(item.ctx, t.dsn, item.envelope, item.sdkName, item.sdkVersion)
if err != nil {
debuglog.Printf("There was an issue when creating the request: %v", err)
recordForBatchItem(t.recorder, report.ReasonInternalError, &item)
continue
}
result, err := util.DoSendRequest(t.client, request, item.eventIdentifier)
if err != nil {
debuglog.Printf("There was an issue with sending an event: %v", err)
recordForBatchItem(t.recorder, report.ReasonNetworkError, &item)
continue
}
if result.IsSendError() {
recordForBatchItem(t.recorder, report.ReasonSendError, &item)
}
t.mu.Lock()
t.limits.Merge(result.Limits)
t.mu.Unlock()
}
}
// Signal that processing of the batch is done.
close(b.done)
}
}
// attachClientReport takes any pending client report from the provider and
// appends it to the envelope buffer.
func (t *HTTPTransport) attachClientReport(buf *bytes.Buffer) {
r := t.provider.TakeReport()
if r == nil {
return
}
enc := json.NewEncoder(buf)
if err := encodeClientReport(enc, r); err != nil {
debuglog.Printf("Failed to encode client report: %v", err)
}
}
func (t *HTTPTransport) disabled(c ratelimit.Category) bool {
t.mu.RLock()
defer t.mu.RUnlock()
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
}
// ================================
// HTTPSyncTransport
// ================================
// HTTPSyncTransport 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 HTTPTransport.
type HTTPSyncTransport struct {
dsn *Dsn
client *http.Client
transport http.RoundTripper
recorder report.ClientReportRecorder
provider report.ClientReportProvider
mu sync.Mutex
limits ratelimit.Map
// HTTP Client request timeout. Defaults to 30 seconds.
Timeout time.Duration
}
// NewHTTPSyncTransport returns a new pre-configured instance of HTTPSyncTransport.
func NewHTTPSyncTransport() *HTTPSyncTransport {
transport := HTTPSyncTransport{
Timeout: defaultTimeout,
limits: make(ratelimit.Map),
}
return &transport
}
// Configure is called by the Client itself, providing its own ClientOptions.
func (t *HTTPSyncTransport) Configure(options ClientOptions) {
dsn, err := NewDsn(options.Dsn)
if err != nil {
debuglog.Printf("%v\n", err)
return
}
t.dsn = dsn
if t.recorder == nil {
t.recorder = report.NoopRecorder()
}
if t.provider == nil {
t.provider = report.NoopProvider()
}
if options.HTTPTransport != nil {
t.transport = options.HTTPTransport
} else {
t.transport = &http.Transport{
Proxy: getProxyConfig(options),
TLSClientConfig: getTLSConfig(options),
}
}
if options.HTTPClient != nil {
t.client = options.HTTPClient
} else {
t.client = &http.Client{
Transport: t.transport,
Timeout: t.Timeout,
}
}
}
// SendEvent assembles a new packet out of Event and sends it to the remote server.
func (t *HTTPSyncTransport) SendEvent(event *Event) {
t.SendEventWithContext(context.Background(), event)
}
func (t *HTTPSyncTransport) Close() {}
// SendEventWithContext assembles a new packet out of Event and sends it to the remote server.
func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Event) {
if t.dsn == nil {
return
}
category := event.toCategory()
if t.disabled(category) {
recordForEvent(t.recorder, report.ReasonRateLimitBackoff, event)
return
}
body := getRequestBodyFromEvent(event)
if body == nil {
recordForEvent(t.recorder, report.ReasonInternalError, event)
return
}
envelope, err := envelopeFromBody(event, t.dsn, time.Now(), body)
if err != nil {
debuglog.Printf("Failed to build envelope, skipping delivery. %s: %v", eventDebugContext(event), err)
recordForEvent(t.recorder, report.ReasonInternalError, event)
return
}
if r := t.provider.TakeReport(); r != nil {
enc := json.NewEncoder(envelope)
if err := encodeClientReport(enc, r); err != nil {
debuglog.Printf("Failed to encode client report: %v", err)
}
}
request, err := getRequestFromEnvelope(ctx, t.dsn, envelope, event.Sdk.Name, event.Sdk.Version)
if err != nil {
recordForEvent(t.recorder, report.ReasonInternalError, event)
return
}
identifier := eventIdentifier(event)
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)
recordForEvent(t.recorder, report.ReasonNetworkError, event)
return
}
if result.IsSendError() {
recordForEvent(t.recorder, report.ReasonSendError, event)
}
t.mu.Lock()
t.limits.Merge(result.Limits)
t.mu.Unlock()
}
// Flush is a no-op for HTTPSyncTransport. It always returns true immediately.
func (t *HTTPSyncTransport) Flush(_ time.Duration) bool {
return true
}
// FlushWithContext is a no-op for HTTPSyncTransport. It always returns true immediately.
func (t *HTTPSyncTransport) FlushWithContext(_ context.Context) bool {
return true
}
func (t *HTTPSyncTransport) 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
}
// ================================
// noopTransport
// ================================
// noopTransport is an implementation of Transport interface which drops all the events.
// Only used internally when an empty DSN is provided, which effectively disables the SDK.
type noopTransport struct{}
var _ Transport = noopTransport{}
func (noopTransport) Configure(ClientOptions) {
debuglog.Println("Sentry client initialized with an empty DSN. Using noopTransport. No events will be delivered.")
}
func (noopTransport) SendEvent(*Event) {
debuglog.Println("Event dropped due to noopTransport usage.")
}
func (noopTransport) Flush(time.Duration) bool {
return true
}
func (noopTransport) FlushWithContext(context.Context) bool {
return true
}
func (noopTransport) Close() {}
// ================================
// Internal Transport Adapters
// ================================
// newInternalAsyncTransport creates a new AsyncTransport from internal/http
// wrapped to satisfy the Transport interface.
//
// This is not yet exposed in the public API and is for internal experimentation.
func newInternalAsyncTransport() Transport {
return &internalAsyncTransportAdapter{}
}
// internalAsyncTransportAdapter wraps the internal AsyncTransport to implement
// the root-level Transport interface.
type internalAsyncTransportAdapter struct {
transport protocol.TelemetryTransport
dsn *protocol.Dsn
recorder report.ClientReportRecorder
provider report.ClientReportProvider
}
func (a *internalAsyncTransportAdapter) Configure(options ClientOptions) {
transportOptions := httpinternal.TransportOptions{
Dsn: options.Dsn,
HTTPClient: options.HTTPClient,
HTTPTransport: options.HTTPTransport,
HTTPProxy: options.HTTPProxy,
HTTPSProxy: options.HTTPSProxy,
CaCerts: options.CaCerts,
Recorder: a.recorder,
Provider: a.provider,
SdkInfo: func() *protocol.SdkInfo {
return &protocol.SdkInfo{
Name: sdkIdentifier,
Version: SDKVersion,
}
},
}
a.transport = httpinternal.NewAsyncTransport(transportOptions)
if options.Dsn != "" {
dsn, err := protocol.NewDsn(options.Dsn)
if err != nil {
debuglog.Printf("Failed to parse DSN in adapter: %v\n", err)
} else {
a.dsn = dsn
}
}
}
func (a *internalAsyncTransportAdapter) SendEvent(event *Event) {
header := &protocol.EnvelopeHeader{EventID: string(event.EventID), SentAt: time.Now(), Dsn: a.dsn, Sdk: &protocol.SdkInfo{Name: event.Sdk.Name, Version: event.Sdk.Version}}
if header.EventID == "" {
header.EventID = protocol.GenerateEventID()
}
envelope, err := event.ToEnvelope(header)
if err != nil {
debuglog.Printf("Failed to convert event to envelope, skipping delivery. %s: %v", eventDebugContext(event), err)
if a.recorder != nil {
recordForEvent(a.recorder, report.ReasonInternalError, event)
}
return
}
if err := a.transport.SendEnvelope(envelope); err != nil {
debuglog.Printf("Error sending envelope: %v", err)
}
}
func (a *internalAsyncTransportAdapter) Flush(timeout time.Duration) bool {
return a.transport.Flush(timeout)
}
func (a *internalAsyncTransportAdapter) FlushWithContext(ctx context.Context) bool {
return a.transport.FlushWithContext(ctx)
}
func (a *internalAsyncTransportAdapter) Close() {
a.transport.Close()
}
+172
View File
@@ -0,0 +1,172 @@
package sentry
import (
"encoding/json"
"fmt"
"os"
"runtime/debug"
"strings"
"time"
"github.com/getsentry/sentry-go/internal/debuglog"
"github.com/getsentry/sentry-go/internal/protocol"
exec "golang.org/x/sys/execabs"
)
func uuid() string {
return protocol.GenerateEventID()
}
func fileExists(fileName string) bool {
_, err := os.Stat(fileName)
return err == nil
}
// monotonicTimeSince replaces uses of time.Now() to take into account the
// monotonic clock reading stored in start, such that duration = end - start is
// unaffected by changes in the system wall clock.
func monotonicTimeSince(start time.Time) (end time.Time) {
return start.Add(time.Since(start))
}
// nolint: unused
func prettyPrint(data interface{}) {
dbg, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(dbg))
}
// defaultRelease attempts to guess a default release for the currently running
// program.
func defaultRelease() (release string) {
// Return first non-empty environment variable known to hold release info, if any.
envs := []string{
"SENTRY_RELEASE",
"HEROKU_BUILD_COMMIT",
"HEROKU_SLUG_COMMIT", // Deprecated, kept for backwards compatibility
"SOURCE_VERSION",
"CODEBUILD_RESOLVED_SOURCE_VERSION",
"CIRCLE_SHA1",
"GAE_DEPLOYMENT_ID",
"GITHUB_SHA", // GitHub Actions - https://help.github.com/en/actions
"COMMIT_REF", // Netlify - https://docs.netlify.com/
"VERCEL_GIT_COMMIT_SHA", // Vercel - https://vercel.com/
"ZEIT_GITHUB_COMMIT_SHA", // Zeit (now known as Vercel)
"ZEIT_GITLAB_COMMIT_SHA",
"ZEIT_BITBUCKET_COMMIT_SHA",
}
for _, e := range envs {
if release = os.Getenv(e); release != "" {
debuglog.Printf("Using release from environment variable %s: %s", e, release)
return release
}
}
if info, ok := debug.ReadBuildInfo(); ok {
buildInfoVcsRevision := revisionFromBuildInfo(info)
if len(buildInfoVcsRevision) > 0 {
return buildInfoVcsRevision
}
}
// Derive a version string from Git. Example outputs:
// v1.0.1-0-g9de4
// v2.0-8-g77df-dirty
// 4f72d7
if _, err := exec.LookPath("git"); err == nil {
cmd := exec.Command("git", "describe", "--long", "--always", "--dirty")
b, err := cmd.Output()
if err != nil {
// Either Git is not available or the current directory is not a
// Git repository.
var s strings.Builder
fmt.Fprintf(&s, "Release detection failed: %v", err)
if err, ok := err.(*exec.ExitError); ok && len(err.Stderr) > 0 {
fmt.Fprintf(&s, ": %s", err.Stderr)
}
debuglog.Print(s.String())
} else {
release = strings.TrimSpace(string(b))
debuglog.Printf("Using release from Git: %s", release)
return release
}
}
debuglog.Print("Some Sentry features will not be available. See https://docs.sentry.io/product/releases/.")
debuglog.Print("To stop seeing this message, pass a Release to sentry.Init or set the SENTRY_RELEASE environment variable.")
return ""
}
func revisionFromBuildInfo(info *debug.BuildInfo) string {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" && setting.Value != "" {
debuglog.Printf("Using release from debug info: %s", setting.Value)
return setting.Value
}
}
return ""
}
func Pointer[T any](v T) *T {
return &v
}
var sensitiveHeaders = map[string]struct{}{
"_csrf": {},
"_csrf_token": {},
"_session": {},
"_xsrf": {},
"api-key": {},
"apikey": {},
"auth": {},
"authorization": {},
"cookie": {},
"credentials": {},
"csrf": {},
"csrf-token": {},
"csrftoken": {},
"ip-address": {},
"passwd": {},
"password": {},
"private-key": {},
"privatekey": {},
"proxy-authorization": {},
"remote-addr": {},
"secret": {},
"session": {},
"sessionid": {},
"token": {},
"user-session": {},
"x-api-key": {},
"x-csrftoken": {},
"x-forwarded-for": {},
"x-real-ip": {},
"xsrf-token": {},
}
// IsSensitiveHeader reports whether a header or metadata key should be treated as sensitive.
func IsSensitiveHeader(key string) bool {
_, ok := sensitiveHeaders[strings.ToLower(key)]
return ok
}
// eventIdentifier returns a human-readable identifier for the event to be used in log messages.
// Format: "<description> [<event-id>]".
func eventIdentifier(event *Event) string {
var description string
switch event.Type {
case errorType:
description = "error"
case transactionType:
description = "transaction"
case checkInType:
description = "check-in"
case logEvent.Type:
description = fmt.Sprintf("%d log events", len(event.Logs))
case traceMetricEvent.Type:
description = fmt.Sprintf("%d metric events", len(event.Metrics))
default:
description = fmt.Sprintf("%s event", event.Type)
}
return fmt.Sprintf("%s [%s]", description, event.EventID)
}