fix(go.sum): update ResolveSpec dependency to v1.0.87
This commit is contained in:
+12
@@ -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
@@ -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:
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
+1574
File diff suppressed because it is too large
Load Diff
+98
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
[](https://github.com/getsentry/sentry-go/actions/workflows/test.yml)
|
||||
[](https://goreportcard.com/report/github.com/getsentry/sentry-go)
|
||||
[](https://discord.gg/Ww9hbqr)
|
||||
[](https://x.com/intent/follow?screen_name=sentry)
|
||||
[](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`, you’ll 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)
|
||||
- [](https://pkg.go.dev/github.com/getsentry/sentry-go)
|
||||
- [](https://docs.sentry.io/platforms/go/)
|
||||
- [](https://github.com/getsentry/sentry-go/discussions)
|
||||
- [](https://discord.gg/Ww9hbqr)
|
||||
- [](http://stackoverflow.com/questions/tagged/sentry)
|
||||
- [](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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+6
@@ -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
@@ -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
@@ -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
@@ -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, ¤tID, childSource, visited, maxErrorDepth, currentDepth+1)
|
||||
}
|
||||
}
|
||||
case interface{ Unwrap() error }:
|
||||
unwrapped := v.Unwrap()
|
||||
if unwrapped != nil {
|
||||
convertErrorDFS(unwrapped, exceptions, ¤tID, MechanismTypeUnwrap, visited, maxErrorDepth, currentDepth+1)
|
||||
}
|
||||
case interface{ Cause() error }:
|
||||
cause := v.Cause()
|
||||
if cause != nil {
|
||||
convertErrorDFS(cause, exceptions, ¤tID, MechanismSourceCause, visited, maxErrorDepth, currentDepth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
+450
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,79 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/http/httputil"
|
||||
)
|
||||
|
||||
// Transport implements http.RoundTripper and can be used to wrap other HTTP
|
||||
// transports for debugging, normally http.DefaultTransport.
|
||||
type Transport struct {
|
||||
http.RoundTripper
|
||||
Output io.Writer
|
||||
// Dump controls whether to dump HTTP request and responses.
|
||||
Dump bool
|
||||
// Trace enables usage of net/http/httptrace.
|
||||
Trace bool
|
||||
}
|
||||
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var buf bytes.Buffer
|
||||
if t.Dump {
|
||||
b, err := httputil.DumpRequestOut(req, true)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = buf.Write(ensureTrailingNewline(b))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
if t.Trace {
|
||||
trace := &httptrace.ClientTrace{
|
||||
DNSDone: func(di httptrace.DNSDoneInfo) {
|
||||
fmt.Fprintf(&buf, "* DNS %v → %v\n", req.Host, di.Addrs)
|
||||
},
|
||||
GotConn: func(ci httptrace.GotConnInfo) {
|
||||
fmt.Fprintf(&buf, "* Connection local=%v remote=%v", ci.Conn.LocalAddr(), ci.Conn.RemoteAddr())
|
||||
if ci.Reused {
|
||||
fmt.Fprint(&buf, " (reused)")
|
||||
}
|
||||
if ci.WasIdle {
|
||||
fmt.Fprintf(&buf, " (idle %v)", ci.IdleTime)
|
||||
}
|
||||
fmt.Fprintln(&buf)
|
||||
},
|
||||
}
|
||||
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
|
||||
}
|
||||
resp, err := t.RoundTripper.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Dump {
|
||||
b, err := httputil.DumpResponse(resp, true)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = buf.Write(ensureTrailingNewline(b))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
_, err = io.Copy(t.Output, &buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func ensureTrailingNewline(b []byte) []byte {
|
||||
if len(b) > 0 && b[len(b)-1] != '\n' {
|
||||
b = append(b, '\n')
|
||||
}
|
||||
return b
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package debuglog
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
// logger is the global debug logger instance.
|
||||
var logger = log.New(io.Discard, "[Sentry] ", log.LstdFlags)
|
||||
|
||||
// SetOutput changes the output destination of the logger.
|
||||
func SetOutput(w io.Writer) {
|
||||
logger.SetOutput(w)
|
||||
}
|
||||
|
||||
// GetLogger returns the current logger instance.
|
||||
// This function is thread-safe and can be called concurrently.
|
||||
func GetLogger() *log.Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
// Printf calls Printf on the underlying logger.
|
||||
func Printf(format string, args ...interface{}) {
|
||||
logger.Printf(format, args...)
|
||||
}
|
||||
|
||||
// Println calls Println on the underlying logger.
|
||||
func Println(args ...interface{}) {
|
||||
logger.Println(args...)
|
||||
}
|
||||
|
||||
// Print calls Print on the underlying logger.
|
||||
func Print(args ...interface{}) {
|
||||
logger.Print(args...)
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/debuglog"
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/internal/util"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
const (
|
||||
apiVersion = 7
|
||||
|
||||
defaultTimeout = time.Second * 30
|
||||
defaultQueueSize = 1000
|
||||
defaultClientReportsTick = time.Second * 30
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTransportQueueFull = errors.New("transport queue full")
|
||||
ErrTransportClosed = errors.New("transport is closed")
|
||||
ErrEmptyEnvelope = errors.New("empty envelope provided")
|
||||
)
|
||||
|
||||
type TransportOptions struct {
|
||||
Dsn string
|
||||
HTTPClient *http.Client
|
||||
HTTPTransport http.RoundTripper
|
||||
HTTPProxy string
|
||||
HTTPSProxy string
|
||||
CaCerts *x509.CertPool
|
||||
Recorder report.ClientReportRecorder
|
||||
Provider report.ClientReportProvider
|
||||
SdkInfo func() *protocol.SdkInfo
|
||||
}
|
||||
|
||||
func getProxyConfig(options TransportOptions) func(*http.Request) (*url.URL, error) {
|
||||
if len(options.HTTPSProxy) > 0 {
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
return url.Parse(options.HTTPSProxy)
|
||||
}
|
||||
}
|
||||
|
||||
if len(options.HTTPProxy) > 0 {
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
return url.Parse(options.HTTPProxy)
|
||||
}
|
||||
}
|
||||
|
||||
return http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
func getTLSConfig(options TransportOptions) *tls.Config {
|
||||
if options.CaCerts != nil {
|
||||
return &tls.Config{
|
||||
RootCAs: options.CaCerts,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSentryRequestFromEnvelope(ctx context.Context, dsn *protocol.Dsn, envelope *protocol.Envelope) (r *http.Request, err error) {
|
||||
defer func() {
|
||||
if r != nil {
|
||||
var sdkName, sdkVersion string
|
||||
if envelope.Header.Sdk != nil {
|
||||
sdkVersion = envelope.Header.Sdk.Version
|
||||
sdkName = envelope.Header.Sdk.Name
|
||||
}
|
||||
|
||||
r.Header.Set("User-Agent", fmt.Sprintf("%s/%s", sdkName, sdkVersion))
|
||||
r.Header.Set("Content-Type", "application/x-sentry-envelope")
|
||||
|
||||
auth := fmt.Sprintf("Sentry sentry_version=%d, "+
|
||||
"sentry_client=%s/%s, sentry_key=%s", apiVersion, sdkName, sdkVersion, dsn.GetPublicKey())
|
||||
|
||||
if dsn.GetSecretKey() != "" {
|
||||
auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey())
|
||||
}
|
||||
|
||||
r.Header.Set("X-Sentry-Auth", auth)
|
||||
}
|
||||
}()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = envelope.WriteTo(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
dsn.GetAPIURL().String(),
|
||||
&buf,
|
||||
)
|
||||
}
|
||||
|
||||
func categoryFromEnvelope(envelope *protocol.Envelope) ratelimit.Category {
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return ratelimit.CategoryAll
|
||||
}
|
||||
|
||||
for _, item := range envelope.Items {
|
||||
if item == nil || item.Header == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch item.Header.Type {
|
||||
case protocol.EnvelopeItemTypeEvent:
|
||||
return ratelimit.CategoryError
|
||||
case protocol.EnvelopeItemTypeTransaction:
|
||||
return ratelimit.CategoryTransaction
|
||||
case protocol.EnvelopeItemTypeCheckIn:
|
||||
return ratelimit.CategoryMonitor
|
||||
case protocol.EnvelopeItemTypeLog:
|
||||
return ratelimit.CategoryLog
|
||||
case protocol.EnvelopeItemTypeAttachment:
|
||||
continue
|
||||
default:
|
||||
return ratelimit.CategoryAll
|
||||
}
|
||||
}
|
||||
|
||||
return ratelimit.CategoryAll
|
||||
}
|
||||
|
||||
// SyncTransport is a blocking implementation of Transport.
|
||||
//
|
||||
// Clients using this transport will send requests to Sentry sequentially and
|
||||
// block until a response is returned.
|
||||
//
|
||||
// The blocking behavior is useful in a limited set of use cases. For example,
|
||||
// use it when deploying code to a Function as a Service ("Serverless")
|
||||
// platform, where any work happening in a background goroutine is not
|
||||
// guaranteed to execute.
|
||||
//
|
||||
// For most cases, prefer AsyncTransport.
|
||||
type SyncTransport struct {
|
||||
dsn *protocol.Dsn
|
||||
client *http.Client
|
||||
transport http.RoundTripper
|
||||
recorder report.ClientReportRecorder
|
||||
provider report.ClientReportProvider
|
||||
sdkInfo func() *protocol.SdkInfo
|
||||
|
||||
mu sync.Mutex
|
||||
limits ratelimit.Map
|
||||
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func NewSyncTransport(options TransportOptions) protocol.TelemetryTransport {
|
||||
dsn, err := protocol.NewDsn(options.Dsn)
|
||||
if err != nil || dsn == nil {
|
||||
debuglog.Printf("Transport is disabled: invalid dsn: %v\n", err)
|
||||
return NewNoopTransport()
|
||||
}
|
||||
|
||||
recorder := options.Recorder
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
provider := options.Provider
|
||||
if provider == nil {
|
||||
provider = report.NoopProvider()
|
||||
}
|
||||
|
||||
transport := &SyncTransport{
|
||||
Timeout: defaultTimeout,
|
||||
limits: make(ratelimit.Map),
|
||||
dsn: dsn,
|
||||
recorder: recorder,
|
||||
provider: provider,
|
||||
sdkInfo: options.SdkInfo,
|
||||
}
|
||||
|
||||
if options.HTTPTransport != nil {
|
||||
transport.transport = options.HTTPTransport
|
||||
} else {
|
||||
transport.transport = &http.Transport{
|
||||
Proxy: getProxyConfig(options),
|
||||
TLSClientConfig: getTLSConfig(options),
|
||||
}
|
||||
}
|
||||
|
||||
if options.HTTPClient != nil {
|
||||
transport.client = options.HTTPClient
|
||||
} else {
|
||||
transport.client = &http.Client{
|
||||
Transport: transport.transport,
|
||||
Timeout: transport.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
func (t *SyncTransport) SendEnvelope(envelope *protocol.Envelope) error {
|
||||
return t.SendEnvelopeWithContext(context.Background(), envelope)
|
||||
}
|
||||
|
||||
func (t *SyncTransport) Close() {}
|
||||
|
||||
func (t *SyncTransport) IsRateLimited(category ratelimit.Category) bool {
|
||||
return t.disabled(category)
|
||||
}
|
||||
|
||||
func (t *SyncTransport) HasCapacity() bool { return true }
|
||||
|
||||
func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *protocol.Envelope) error {
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return ErrEmptyEnvelope
|
||||
}
|
||||
|
||||
category := categoryFromEnvelope(envelope)
|
||||
if t.disabled(category) {
|
||||
t.recorder.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope)
|
||||
return nil
|
||||
}
|
||||
// the sync transport needs to attach client reports when available
|
||||
t.provider.AttachToEnvelope(envelope)
|
||||
|
||||
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
|
||||
if err != nil {
|
||||
debuglog.Printf("There was an issue creating the request: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonInternalError, envelope)
|
||||
return err
|
||||
}
|
||||
identifier := util.EnvelopeIdentifier(envelope)
|
||||
debuglog.Printf(
|
||||
"Sending %s to %s project: %s",
|
||||
identifier,
|
||||
t.dsn.GetHost(),
|
||||
t.dsn.GetProjectID(),
|
||||
)
|
||||
|
||||
result, err := util.DoSendRequest(t.client, request, identifier)
|
||||
if err != nil {
|
||||
debuglog.Printf("There was an issue with sending an event: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonNetworkError, envelope)
|
||||
return err
|
||||
}
|
||||
if result.IsSendError() {
|
||||
t.recorder.RecordForEnvelope(report.ReasonSendError, envelope)
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.limits.Merge(result.Limits)
|
||||
t.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *SyncTransport) Flush(_ time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *SyncTransport) FlushWithContext(_ context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *SyncTransport) disabled(c ratelimit.Category) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
disabled := t.limits.IsRateLimited(c)
|
||||
if disabled {
|
||||
debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
// AsyncTransport is the default, non-blocking, implementation of Transport.
|
||||
//
|
||||
// Clients using this transport will enqueue requests in a queue and return to
|
||||
// the caller before any network communication has happened. Requests are sent
|
||||
// to Sentry sequentially from a background goroutine.
|
||||
type AsyncTransport struct {
|
||||
dsn *protocol.Dsn
|
||||
client *http.Client
|
||||
transport http.RoundTripper
|
||||
recorder report.ClientReportRecorder
|
||||
provider report.ClientReportProvider
|
||||
sdkInfo func() *protocol.SdkInfo
|
||||
|
||||
queue chan *protocol.Envelope
|
||||
|
||||
mu sync.RWMutex
|
||||
limits ratelimit.Map
|
||||
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
flushRequest chan chan struct{}
|
||||
|
||||
closeMu sync.RWMutex
|
||||
|
||||
QueueSize int
|
||||
Timeout time.Duration
|
||||
|
||||
startOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewAsyncTransport(options TransportOptions) protocol.TelemetryTransport {
|
||||
dsn, err := protocol.NewDsn(options.Dsn)
|
||||
if err != nil || dsn == nil {
|
||||
debuglog.Printf("Transport is disabled: invalid dsn: %v", err)
|
||||
return NewNoopTransport()
|
||||
}
|
||||
|
||||
recorder := options.Recorder
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
provider := options.Provider
|
||||
if provider == nil {
|
||||
provider = report.NoopProvider()
|
||||
}
|
||||
|
||||
transport := &AsyncTransport{
|
||||
QueueSize: defaultQueueSize,
|
||||
Timeout: defaultTimeout,
|
||||
done: make(chan struct{}),
|
||||
limits: make(ratelimit.Map),
|
||||
dsn: dsn,
|
||||
recorder: recorder,
|
||||
provider: provider,
|
||||
sdkInfo: options.SdkInfo,
|
||||
}
|
||||
|
||||
transport.queue = make(chan *protocol.Envelope, transport.QueueSize)
|
||||
transport.flushRequest = make(chan chan struct{})
|
||||
|
||||
if options.HTTPTransport != nil {
|
||||
transport.transport = options.HTTPTransport
|
||||
} else {
|
||||
transport.transport = &http.Transport{
|
||||
Proxy: getProxyConfig(options),
|
||||
TLSClientConfig: getTLSConfig(options),
|
||||
}
|
||||
}
|
||||
|
||||
if options.HTTPClient != nil {
|
||||
transport.client = options.HTTPClient
|
||||
} else {
|
||||
transport.client = &http.Client{
|
||||
Transport: transport.transport,
|
||||
Timeout: transport.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
transport.start()
|
||||
return transport
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) start() {
|
||||
t.startOnce.Do(func() {
|
||||
if t.recorder == nil {
|
||||
t.recorder = report.NoopRecorder()
|
||||
}
|
||||
if t.provider == nil {
|
||||
t.provider = report.NoopProvider()
|
||||
}
|
||||
t.wg.Add(1)
|
||||
go t.worker()
|
||||
})
|
||||
}
|
||||
|
||||
// HasCapacity reports whether the async transport queue appears to have space
|
||||
// for at least one more envelope. This is a best-effort, non-blocking check.
|
||||
func (t *AsyncTransport) HasCapacity() bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
select {
|
||||
case <-t.done:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
return len(t.queue) < cap(t.queue)
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error {
|
||||
t.closeMu.RLock()
|
||||
defer t.closeMu.RUnlock()
|
||||
|
||||
select {
|
||||
case <-t.done:
|
||||
return ErrTransportClosed
|
||||
default:
|
||||
}
|
||||
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return ErrEmptyEnvelope
|
||||
}
|
||||
|
||||
category := categoryFromEnvelope(envelope)
|
||||
if t.isRateLimited(category) {
|
||||
t.recorder.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope)
|
||||
return nil
|
||||
}
|
||||
|
||||
identifier := util.EnvelopeIdentifier(envelope)
|
||||
|
||||
select {
|
||||
case <-t.done:
|
||||
return ErrTransportClosed
|
||||
case t.queue <- envelope:
|
||||
debuglog.Printf(
|
||||
"Sending %s to %s project: %s",
|
||||
identifier,
|
||||
t.dsn.GetHost(),
|
||||
t.dsn.GetProjectID(),
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
t.recorder.RecordForEnvelope(report.ReasonQueueOverflow, envelope)
|
||||
return ErrTransportQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) Flush(timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return t.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) FlushWithContext(ctx context.Context) bool {
|
||||
t.closeMu.RLock()
|
||||
defer t.closeMu.RUnlock()
|
||||
|
||||
flushResponse := make(chan struct{})
|
||||
select {
|
||||
case <-t.done:
|
||||
debuglog.Println("Failed to flush, transport is closed.")
|
||||
return false
|
||||
case t.flushRequest <- flushResponse:
|
||||
select {
|
||||
case <-flushResponse:
|
||||
debuglog.Println("Buffer flushed successfully.")
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
debuglog.Println("Failed to flush, buffer timed out.")
|
||||
return false
|
||||
}
|
||||
case <-ctx.Done():
|
||||
debuglog.Println("Failed to flush, buffer timed out.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) Close() {
|
||||
t.closeOnce.Do(func() {
|
||||
t.closeMu.Lock()
|
||||
defer t.closeMu.Unlock()
|
||||
|
||||
close(t.done)
|
||||
t.wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) IsRateLimited(category ratelimit.Category) bool {
|
||||
return t.isRateLimited(category)
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) resolveSdkInfo() *protocol.SdkInfo {
|
||||
if t.sdkInfo == nil {
|
||||
return &protocol.SdkInfo{}
|
||||
}
|
||||
return t.sdkInfo()
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) worker() {
|
||||
defer t.wg.Done()
|
||||
|
||||
crTicker := time.NewTicker(defaultClientReportsTick)
|
||||
defer crTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.done:
|
||||
return
|
||||
case <-crTicker.C:
|
||||
t.sendClientReport()
|
||||
case envelope, open := <-t.queue:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
t.sendEnvelopeHTTP(envelope)
|
||||
case flushResponse, open := <-t.flushRequest:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
t.drainQueue()
|
||||
close(flushResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendClientReport sends a standalone envelope containing only a client report.
|
||||
func (t *AsyncTransport) sendClientReport() {
|
||||
r := t.provider.TakeReport()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
item, err := r.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to serialize client report: %v", err)
|
||||
return
|
||||
}
|
||||
header := &protocol.EnvelopeHeader{
|
||||
SentAt: time.Now(),
|
||||
Dsn: t.dsn,
|
||||
Sdk: t.resolveSdkInfo(),
|
||||
}
|
||||
envelope := protocol.NewEnvelope(header)
|
||||
envelope.AddItem(item)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to create client report request: %v", err)
|
||||
return
|
||||
}
|
||||
result, err := util.DoSendRequest(t.client, request, "client report")
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to send client report: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.limits.Merge(result.Limits)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) drainQueue() {
|
||||
for {
|
||||
select {
|
||||
case envelope, open := <-t.queue:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
t.sendEnvelopeHTTP(envelope)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { //nolint: unparam
|
||||
category := categoryFromEnvelope(envelope)
|
||||
if t.isRateLimited(category) {
|
||||
t.recorder.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope)
|
||||
return false
|
||||
}
|
||||
// attach to envelope after rate-limit check
|
||||
t.provider.AttachToEnvelope(envelope)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope)
|
||||
if err != nil {
|
||||
debuglog.Printf("Failed to create request from envelope: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonInternalError, envelope)
|
||||
return false
|
||||
}
|
||||
|
||||
identifier := util.EnvelopeIdentifier(envelope)
|
||||
result, err := util.DoSendRequest(t.client, request, identifier)
|
||||
if err != nil {
|
||||
debuglog.Printf("HTTP request failed: %v", err)
|
||||
t.recorder.RecordForEnvelope(report.ReasonNetworkError, envelope)
|
||||
return false
|
||||
}
|
||||
if result.IsSendError() {
|
||||
t.recorder.RecordForEnvelope(report.ReasonSendError, envelope)
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.limits.Merge(result.Limits)
|
||||
t.mu.Unlock()
|
||||
|
||||
return result.Success
|
||||
}
|
||||
|
||||
func (t *AsyncTransport) isRateLimited(category ratelimit.Category) bool {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
limited := t.limits.IsRateLimited(category)
|
||||
if limited {
|
||||
debuglog.Printf("Rate limited for category %q until %v", category, t.limits.Deadline(category))
|
||||
}
|
||||
return limited
|
||||
}
|
||||
|
||||
// NoopTransport is a transport implementation that drops all events.
|
||||
// Used internally when an empty or invalid DSN is provided.
|
||||
type NoopTransport struct{}
|
||||
|
||||
func NewNoopTransport() *NoopTransport {
|
||||
debuglog.Println("Transport initialized with invalid DSN. Using NoopTransport. No events will be delivered.")
|
||||
return &NoopTransport{}
|
||||
}
|
||||
|
||||
func (t *NoopTransport) SendEnvelope(_ *protocol.Envelope) error {
|
||||
debuglog.Println("Envelope dropped due to NoopTransport usage.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *NoopTransport) IsRateLimited(_ ratelimit.Category) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *NoopTransport) Flush(_ time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *NoopTransport) FlushWithContext(_ context.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *NoopTransport) Close() {
|
||||
// Nothing to close
|
||||
}
|
||||
|
||||
func (t *NoopTransport) HasCapacity() bool { return true }
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
## Why do we have this "otel/baggage" folder?
|
||||
|
||||
The root sentry-go SDK (namely, the Dynamic Sampling functionality) needs an implementation of the [baggage spec](https://www.w3.org/TR/baggage/).
|
||||
For that reason, we've taken the existing baggage implementation from the [opentelemetry-go](https://github.com/open-telemetry/opentelemetry-go/) repository, and fixed a few things that in our opinion were violating the specification.
|
||||
|
||||
These issues are:
|
||||
1. Baggage string value `one%20two` should be properly parsed as "one two"
|
||||
1. Baggage string value `one+two` should be parsed as "one+two"
|
||||
1. Go string value "one two" should be encoded as `one%20two` (percent encoding), and NOT as `one+two` (URL query encoding).
|
||||
1. Go string value "1=1" might be encoded as `1=1`, because the spec says: "Note, value MAY contain any number of the equal sign (=) characters. Parsers MUST NOT assume that the equal sign is only used to separate key and value.". `1%3D1` is also valid, but to simplify the implementation we're not doing it.
|
||||
|
||||
Changes were made in this PR: https://github.com/getsentry/sentry-go/pull/568
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
// Adapted from https://github.com/open-telemetry/opentelemetry-go/blob/c21b6b6bb31a2f74edd06e262f1690f3f6ea3d5c/baggage/baggage.go
|
||||
//
|
||||
// Copyright The OpenTelemetry Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package baggage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/otel/baggage/internal/baggage"
|
||||
)
|
||||
|
||||
const (
|
||||
maxMembers = 180
|
||||
maxBytesPerMembers = 4096
|
||||
maxBytesPerBaggageString = 8192
|
||||
|
||||
listDelimiter = ","
|
||||
keyValueDelimiter = "="
|
||||
propertyDelimiter = ";"
|
||||
|
||||
keyDef = `([\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5a\x5e-\x7a\x7c\x7e]+)`
|
||||
valueDef = `([\x21\x23-\x2b\x2d-\x3a\x3c-\x5B\x5D-\x7e]*)`
|
||||
keyValueDef = `\s*` + keyDef + `\s*` + keyValueDelimiter + `\s*` + valueDef + `\s*`
|
||||
)
|
||||
|
||||
var (
|
||||
keyRe = regexp.MustCompile(`^` + keyDef + `$`)
|
||||
valueRe = regexp.MustCompile(`^` + valueDef + `$`)
|
||||
propertyRe = regexp.MustCompile(`^(?:\s*` + keyDef + `\s*|` + keyValueDef + `)$`)
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidKey = errors.New("invalid key")
|
||||
errInvalidValue = errors.New("invalid value")
|
||||
errInvalidProperty = errors.New("invalid baggage list-member property")
|
||||
errInvalidMember = errors.New("invalid baggage list-member")
|
||||
errMemberNumber = errors.New("too many list-members in baggage-string")
|
||||
errMemberBytes = errors.New("list-member too large")
|
||||
errBaggageBytes = errors.New("baggage-string too large")
|
||||
)
|
||||
|
||||
// Property is an additional metadata entry for a baggage list-member.
|
||||
type Property struct {
|
||||
key, value string
|
||||
|
||||
// hasValue indicates if a zero-value value means the property does not
|
||||
// have a value or if it was the zero-value.
|
||||
hasValue bool
|
||||
|
||||
// hasData indicates whether the created property contains data or not.
|
||||
// Properties that do not contain data are invalid with no other check
|
||||
// required.
|
||||
hasData bool
|
||||
}
|
||||
|
||||
// NewKeyProperty returns a new Property for key.
|
||||
//
|
||||
// If key is invalid, an error will be returned.
|
||||
func NewKeyProperty(key string) (Property, error) {
|
||||
if !keyRe.MatchString(key) {
|
||||
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidKey, key)
|
||||
}
|
||||
|
||||
p := Property{key: key, hasData: true}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// NewKeyValueProperty returns a new Property for key with value.
|
||||
//
|
||||
// If key or value are invalid, an error will be returned.
|
||||
func NewKeyValueProperty(key, value string) (Property, error) {
|
||||
if !keyRe.MatchString(key) {
|
||||
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidKey, key)
|
||||
}
|
||||
if !valueRe.MatchString(value) {
|
||||
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidValue, value)
|
||||
}
|
||||
|
||||
p := Property{
|
||||
key: key,
|
||||
value: value,
|
||||
hasValue: true,
|
||||
hasData: true,
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func newInvalidProperty() Property {
|
||||
return Property{}
|
||||
}
|
||||
|
||||
// parseProperty attempts to decode a Property from the passed string. It
|
||||
// returns an error if the input is invalid according to the W3C Baggage
|
||||
// specification.
|
||||
func parseProperty(property string) (Property, error) {
|
||||
if property == "" {
|
||||
return newInvalidProperty(), nil
|
||||
}
|
||||
|
||||
match := propertyRe.FindStringSubmatch(property)
|
||||
if len(match) != 4 {
|
||||
return newInvalidProperty(), fmt.Errorf("%w: %q", errInvalidProperty, property)
|
||||
}
|
||||
|
||||
p := Property{hasData: true}
|
||||
if match[1] != "" {
|
||||
p.key = match[1]
|
||||
} else {
|
||||
p.key = match[2]
|
||||
p.value = match[3]
|
||||
p.hasValue = true
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// validate ensures p conforms to the W3C Baggage specification, returning an
|
||||
// error otherwise.
|
||||
func (p Property) validate() error {
|
||||
errFunc := func(err error) error {
|
||||
return fmt.Errorf("invalid property: %w", err)
|
||||
}
|
||||
|
||||
if !p.hasData {
|
||||
return errFunc(fmt.Errorf("%w: %q", errInvalidProperty, p))
|
||||
}
|
||||
|
||||
if !keyRe.MatchString(p.key) {
|
||||
return errFunc(fmt.Errorf("%w: %q", errInvalidKey, p.key))
|
||||
}
|
||||
if p.hasValue && !valueRe.MatchString(p.value) {
|
||||
return errFunc(fmt.Errorf("%w: %q", errInvalidValue, p.value))
|
||||
}
|
||||
if !p.hasValue && p.value != "" {
|
||||
return errFunc(errors.New("inconsistent value"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Key returns the Property key.
|
||||
func (p Property) Key() string {
|
||||
return p.key
|
||||
}
|
||||
|
||||
// Value returns the Property value. Additionally, a boolean value is returned
|
||||
// indicating if the returned value is the empty if the Property has a value
|
||||
// that is empty or if the value is not set.
|
||||
func (p Property) Value() (string, bool) {
|
||||
return p.value, p.hasValue
|
||||
}
|
||||
|
||||
// String encodes Property into a string compliant with the W3C Baggage
|
||||
// specification.
|
||||
func (p Property) String() string {
|
||||
if p.hasValue {
|
||||
return fmt.Sprintf("%s%s%v", p.key, keyValueDelimiter, p.value)
|
||||
}
|
||||
return p.key
|
||||
}
|
||||
|
||||
type properties []Property
|
||||
|
||||
func fromInternalProperties(iProps []baggage.Property) properties {
|
||||
if len(iProps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
props := make(properties, len(iProps))
|
||||
for i, p := range iProps {
|
||||
props[i] = Property{
|
||||
key: p.Key,
|
||||
value: p.Value,
|
||||
hasValue: p.HasValue,
|
||||
}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
func (p properties) asInternal() []baggage.Property {
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
iProps := make([]baggage.Property, len(p))
|
||||
for i, prop := range p {
|
||||
iProps[i] = baggage.Property{
|
||||
Key: prop.key,
|
||||
Value: prop.value,
|
||||
HasValue: prop.hasValue,
|
||||
}
|
||||
}
|
||||
return iProps
|
||||
}
|
||||
|
||||
func (p properties) Copy() properties {
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
props := make(properties, len(p))
|
||||
copy(props, p)
|
||||
return props
|
||||
}
|
||||
|
||||
// validate ensures each Property in p conforms to the W3C Baggage
|
||||
// specification, returning an error otherwise.
|
||||
func (p properties) validate() error {
|
||||
for _, prop := range p {
|
||||
if err := prop.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String encodes properties into a string compliant with the W3C Baggage
|
||||
// specification.
|
||||
func (p properties) String() string {
|
||||
props := make([]string, len(p))
|
||||
for i, prop := range p {
|
||||
props[i] = prop.String()
|
||||
}
|
||||
return strings.Join(props, propertyDelimiter)
|
||||
}
|
||||
|
||||
// Member is a list-member of a baggage-string as defined by the W3C Baggage
|
||||
// specification.
|
||||
type Member struct {
|
||||
key, value string
|
||||
properties properties
|
||||
|
||||
// hasData indicates whether the created property contains data or not.
|
||||
// Properties that do not contain data are invalid with no other check
|
||||
// required.
|
||||
hasData bool
|
||||
}
|
||||
|
||||
// NewMember returns a new Member from the passed arguments. The key will be
|
||||
// used directly while the value will be url decoded after validation. An error
|
||||
// is returned if the created Member would be invalid according to the W3C
|
||||
// Baggage specification.
|
||||
func NewMember(key, value string, props ...Property) (Member, error) {
|
||||
m := Member{
|
||||
key: key,
|
||||
value: value,
|
||||
properties: properties(props).Copy(),
|
||||
hasData: true,
|
||||
}
|
||||
if err := m.validate(); err != nil {
|
||||
return newInvalidMember(), err
|
||||
}
|
||||
//// NOTE(anton): I don't think we need to unescape here
|
||||
// decodedValue, err := url.PathUnescape(value)
|
||||
// if err != nil {
|
||||
// return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value)
|
||||
// }
|
||||
// m.value = decodedValue
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func newInvalidMember() Member {
|
||||
return Member{}
|
||||
}
|
||||
|
||||
// parseMember attempts to decode a Member from the passed string. It returns
|
||||
// an error if the input is invalid according to the W3C Baggage
|
||||
// specification.
|
||||
func parseMember(member string) (Member, error) {
|
||||
if n := len(member); n > maxBytesPerMembers {
|
||||
return newInvalidMember(), fmt.Errorf("%w: %d", errMemberBytes, n)
|
||||
}
|
||||
|
||||
var (
|
||||
key, value string
|
||||
props properties
|
||||
)
|
||||
|
||||
parts := strings.SplitN(member, propertyDelimiter, 2)
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
// Parse the member properties.
|
||||
for _, pStr := range strings.Split(parts[1], propertyDelimiter) {
|
||||
p, err := parseProperty(pStr)
|
||||
if err != nil {
|
||||
return newInvalidMember(), err
|
||||
}
|
||||
props = append(props, p)
|
||||
}
|
||||
fallthrough
|
||||
case 1:
|
||||
// Parse the member key/value pair.
|
||||
|
||||
// Take into account a value can contain equal signs (=).
|
||||
kv := strings.SplitN(parts[0], keyValueDelimiter, 2)
|
||||
if len(kv) != 2 {
|
||||
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidMember, member)
|
||||
}
|
||||
// "Leading and trailing whitespaces are allowed but MUST be trimmed
|
||||
// when converting the header into a data structure."
|
||||
key = strings.TrimSpace(kv[0])
|
||||
value = strings.TrimSpace(kv[1])
|
||||
var err error
|
||||
if !keyRe.MatchString(key) {
|
||||
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidKey, key)
|
||||
}
|
||||
if !valueRe.MatchString(value) {
|
||||
return newInvalidMember(), fmt.Errorf("%w: %q", errInvalidValue, value)
|
||||
}
|
||||
decodedValue, err := url.PathUnescape(value)
|
||||
if err != nil {
|
||||
return newInvalidMember(), fmt.Errorf("%w: %q", err, value)
|
||||
}
|
||||
value = decodedValue
|
||||
default:
|
||||
// This should never happen unless a developer has changed the string
|
||||
// splitting somehow. Panic instead of failing silently and allowing
|
||||
// the bug to slip past the CI checks.
|
||||
panic("failed to parse baggage member")
|
||||
}
|
||||
|
||||
return Member{key: key, value: value, properties: props, hasData: true}, nil
|
||||
}
|
||||
|
||||
// validate ensures m conforms to the W3C Baggage specification.
|
||||
// A key is just an ASCII string, but a value must be URL encoded UTF-8,
|
||||
// returning an error otherwise.
|
||||
func (m Member) validate() error {
|
||||
if !m.hasData {
|
||||
return fmt.Errorf("%w: %q", errInvalidMember, m)
|
||||
}
|
||||
|
||||
if !keyRe.MatchString(m.key) {
|
||||
return fmt.Errorf("%w: %q", errInvalidKey, m.key)
|
||||
}
|
||||
//// NOTE(anton): IMO it's too early to validate the value here.
|
||||
// if !valueRe.MatchString(m.value) {
|
||||
// return fmt.Errorf("%w: %q", errInvalidValue, m.value)
|
||||
// }
|
||||
return m.properties.validate()
|
||||
}
|
||||
|
||||
// Key returns the Member key.
|
||||
func (m Member) Key() string { return m.key }
|
||||
|
||||
// Value returns the Member value.
|
||||
func (m Member) Value() string { return m.value }
|
||||
|
||||
// Properties returns a copy of the Member properties.
|
||||
func (m Member) Properties() []Property { return m.properties.Copy() }
|
||||
|
||||
// String encodes Member into a string compliant with the W3C Baggage
|
||||
// specification.
|
||||
func (m Member) String() string {
|
||||
// A key is just an ASCII string, but a value is URL encoded UTF-8.
|
||||
s := fmt.Sprintf("%s%s%s", m.key, keyValueDelimiter, percentEncodeValue(m.value))
|
||||
if len(m.properties) > 0 {
|
||||
s = fmt.Sprintf("%s%s%s", s, propertyDelimiter, m.properties.String())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// percentEncodeValue encodes the baggage value, using percent-encoding for
|
||||
// disallowed octets.
|
||||
func percentEncodeValue(s string) string {
|
||||
const upperhex = "0123456789ABCDEF"
|
||||
var sb strings.Builder
|
||||
|
||||
for byteIndex, width := 0, 0; byteIndex < len(s); byteIndex += width {
|
||||
runeValue, w := utf8.DecodeRuneInString(s[byteIndex:])
|
||||
width = w
|
||||
char := string(runeValue)
|
||||
if valueRe.MatchString(char) && char != "%" {
|
||||
// The character is returned as is, no need to percent-encode
|
||||
sb.WriteString(char)
|
||||
} else {
|
||||
// We need to percent-encode each byte of the multi-octet character
|
||||
for j := 0; j < width; j++ {
|
||||
b := s[byteIndex+j]
|
||||
sb.WriteByte('%')
|
||||
// Bitwise operations are inspired by "net/url"
|
||||
sb.WriteByte(upperhex[b>>4])
|
||||
sb.WriteByte(upperhex[b&15])
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Baggage is a list of baggage members representing the baggage-string as
|
||||
// defined by the W3C Baggage specification.
|
||||
type Baggage struct { //nolint:golint
|
||||
list baggage.List
|
||||
}
|
||||
|
||||
// New returns a new valid Baggage. It returns an error if it results in a
|
||||
// Baggage exceeding limits set in that specification.
|
||||
//
|
||||
// It expects all the provided members to have already been validated.
|
||||
func New(members ...Member) (Baggage, error) {
|
||||
if len(members) == 0 {
|
||||
return Baggage{}, nil
|
||||
}
|
||||
|
||||
b := make(baggage.List)
|
||||
for _, m := range members {
|
||||
if !m.hasData {
|
||||
return Baggage{}, errInvalidMember
|
||||
}
|
||||
|
||||
// OpenTelemetry resolves duplicates by last-one-wins.
|
||||
b[m.key] = baggage.Item{
|
||||
Value: m.value,
|
||||
Properties: m.properties.asInternal(),
|
||||
}
|
||||
}
|
||||
|
||||
// Check member numbers after deduplication.
|
||||
if len(b) > maxMembers {
|
||||
return Baggage{}, errMemberNumber
|
||||
}
|
||||
|
||||
bag := Baggage{b}
|
||||
if n := len(bag.String()); n > maxBytesPerBaggageString {
|
||||
return Baggage{}, fmt.Errorf("%w: %d", errBaggageBytes, n)
|
||||
}
|
||||
|
||||
return bag, nil
|
||||
}
|
||||
|
||||
// Parse attempts to decode a baggage-string from the passed string. It
|
||||
// returns an error if the input is invalid according to the W3C Baggage
|
||||
// specification.
|
||||
//
|
||||
// If there are duplicate list-members contained in baggage, the last one
|
||||
// defined (reading left-to-right) will be the only one kept. This diverges
|
||||
// from the W3C Baggage specification which allows duplicate list-members, but
|
||||
// conforms to the OpenTelemetry Baggage specification.
|
||||
func Parse(bStr string) (Baggage, error) {
|
||||
if bStr == "" {
|
||||
return Baggage{}, nil
|
||||
}
|
||||
|
||||
if n := len(bStr); n > maxBytesPerBaggageString {
|
||||
return Baggage{}, fmt.Errorf("%w: %d", errBaggageBytes, n)
|
||||
}
|
||||
|
||||
b := make(baggage.List)
|
||||
for _, memberStr := range strings.Split(bStr, listDelimiter) {
|
||||
m, err := parseMember(memberStr)
|
||||
if err != nil {
|
||||
return Baggage{}, err
|
||||
}
|
||||
// OpenTelemetry resolves duplicates by last-one-wins.
|
||||
b[m.key] = baggage.Item{
|
||||
Value: m.value,
|
||||
Properties: m.properties.asInternal(),
|
||||
}
|
||||
}
|
||||
|
||||
// OpenTelemetry does not allow for duplicate list-members, but the W3C
|
||||
// specification does. Now that we have deduplicated, ensure the baggage
|
||||
// does not exceed list-member limits.
|
||||
if len(b) > maxMembers {
|
||||
return Baggage{}, errMemberNumber
|
||||
}
|
||||
|
||||
return Baggage{b}, nil
|
||||
}
|
||||
|
||||
// Member returns the baggage list-member identified by key.
|
||||
//
|
||||
// If there is no list-member matching the passed key the returned Member will
|
||||
// be a zero-value Member.
|
||||
// The returned member is not validated, as we assume the validation happened
|
||||
// when it was added to the Baggage.
|
||||
func (b Baggage) Member(key string) Member {
|
||||
v, ok := b.list[key]
|
||||
if !ok {
|
||||
// We do not need to worry about distinguishing between the situation
|
||||
// where a zero-valued Member is included in the Baggage because a
|
||||
// zero-valued Member is invalid according to the W3C Baggage
|
||||
// specification (it has an empty key).
|
||||
return newInvalidMember()
|
||||
}
|
||||
|
||||
return Member{
|
||||
key: key,
|
||||
value: v.Value,
|
||||
properties: fromInternalProperties(v.Properties),
|
||||
hasData: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Members returns all the baggage list-members.
|
||||
// The order of the returned list-members does not have significance.
|
||||
//
|
||||
// The returned members are not validated, as we assume the validation happened
|
||||
// when they were added to the Baggage.
|
||||
func (b Baggage) Members() []Member {
|
||||
if len(b.list) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
members := make([]Member, 0, len(b.list))
|
||||
for k, v := range b.list {
|
||||
members = append(members, Member{
|
||||
key: k,
|
||||
value: v.Value,
|
||||
properties: fromInternalProperties(v.Properties),
|
||||
hasData: true,
|
||||
})
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
// SetMember returns a copy the Baggage with the member included. If the
|
||||
// baggage contains a Member with the same key the existing Member is
|
||||
// replaced.
|
||||
//
|
||||
// If member is invalid according to the W3C Baggage specification, an error
|
||||
// is returned with the original Baggage.
|
||||
func (b Baggage) SetMember(member Member) (Baggage, error) {
|
||||
if !member.hasData {
|
||||
return b, errInvalidMember
|
||||
}
|
||||
|
||||
n := len(b.list)
|
||||
if _, ok := b.list[member.key]; !ok {
|
||||
n++
|
||||
}
|
||||
list := make(baggage.List, n)
|
||||
|
||||
for k, v := range b.list {
|
||||
// Do not copy if we are just going to overwrite.
|
||||
if k == member.key {
|
||||
continue
|
||||
}
|
||||
list[k] = v
|
||||
}
|
||||
|
||||
list[member.key] = baggage.Item{
|
||||
Value: member.value,
|
||||
Properties: member.properties.asInternal(),
|
||||
}
|
||||
|
||||
return Baggage{list: list}, nil
|
||||
}
|
||||
|
||||
// DeleteMember returns a copy of the Baggage with the list-member identified
|
||||
// by key removed.
|
||||
func (b Baggage) DeleteMember(key string) Baggage {
|
||||
n := len(b.list)
|
||||
if _, ok := b.list[key]; ok {
|
||||
n--
|
||||
}
|
||||
list := make(baggage.List, n)
|
||||
|
||||
for k, v := range b.list {
|
||||
if k == key {
|
||||
continue
|
||||
}
|
||||
list[k] = v
|
||||
}
|
||||
|
||||
return Baggage{list: list}
|
||||
}
|
||||
|
||||
// Len returns the number of list-members in the Baggage.
|
||||
func (b Baggage) Len() int {
|
||||
return len(b.list)
|
||||
}
|
||||
|
||||
// String encodes Baggage into a string compliant with the W3C Baggage
|
||||
// specification. The returned string will be invalid if the Baggage contains
|
||||
// any invalid list-members.
|
||||
func (b Baggage) String() string {
|
||||
members := make([]string, 0, len(b.list))
|
||||
for k, v := range b.list {
|
||||
members = append(members, Member{
|
||||
key: k,
|
||||
value: v.Value,
|
||||
properties: fromInternalProperties(v.Properties),
|
||||
}.String())
|
||||
}
|
||||
return strings.Join(members, listDelimiter)
|
||||
}
|
||||
Generated
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
// Adapted from https://github.com/open-telemetry/opentelemetry-go/blob/c21b6b6bb31a2f74edd06e262f1690f3f6ea3d5c/internal/baggage/baggage.go
|
||||
//
|
||||
// Copyright The OpenTelemetry Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/*
|
||||
Package baggage provides base types and functionality to store and retrieve
|
||||
baggage in Go context. This package exists because the OpenTracing bridge to
|
||||
OpenTelemetry needs to synchronize state whenever baggage for a context is
|
||||
modified and that context contains an OpenTracing span. If it were not for
|
||||
this need this package would not need to exist and the
|
||||
`go.opentelemetry.io/otel/baggage` package would be the singular place where
|
||||
W3C baggage is handled.
|
||||
*/
|
||||
package baggage
|
||||
|
||||
// List is the collection of baggage members. The W3C allows for duplicates,
|
||||
// but OpenTelemetry does not, therefore, this is represented as a map.
|
||||
type List map[string]Item
|
||||
|
||||
// Item is the value and metadata properties part of a list-member.
|
||||
type Item struct {
|
||||
Value string
|
||||
Properties []Property
|
||||
}
|
||||
|
||||
// Property is a metadata entry for a list-member.
|
||||
type Property struct {
|
||||
Key, Value string
|
||||
|
||||
// HasValue indicates if a zero-value value means the property does not
|
||||
// have a value or if it was the zero-value.
|
||||
HasValue bool
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiVersion is the version of the Sentry API.
|
||||
const apiVersion = "7"
|
||||
|
||||
type scheme string
|
||||
|
||||
const (
|
||||
SchemeHTTP scheme = "http"
|
||||
SchemeHTTPS scheme = "https"
|
||||
)
|
||||
|
||||
func (scheme scheme) defaultPort() int {
|
||||
switch scheme {
|
||||
case SchemeHTTPS:
|
||||
return 443
|
||||
case SchemeHTTP:
|
||||
return 80
|
||||
default:
|
||||
return 80
|
||||
}
|
||||
}
|
||||
|
||||
// DsnParseError represents an error that occurs if a Sentry
|
||||
// DSN cannot be parsed.
|
||||
type DsnParseError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e DsnParseError) Error() string {
|
||||
return "[Sentry] DsnParseError: " + e.Message
|
||||
}
|
||||
|
||||
// Dsn is used as the remote address source to client transport.
|
||||
type Dsn struct {
|
||||
scheme scheme
|
||||
publicKey string
|
||||
secretKey string
|
||||
host string
|
||||
port int
|
||||
path string
|
||||
projectID string
|
||||
orgID uint64
|
||||
}
|
||||
|
||||
// NewDsn creates a Dsn by parsing rawURL. Most users will never call this
|
||||
// function directly. It is provided for use in custom Transport
|
||||
// implementations.
|
||||
func NewDsn(rawURL string) (*Dsn, error) {
|
||||
// Parse
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, &DsnParseError{fmt.Sprintf("invalid url: %v", err)}
|
||||
}
|
||||
|
||||
// Scheme
|
||||
var scheme scheme
|
||||
switch parsedURL.Scheme {
|
||||
case "http":
|
||||
scheme = SchemeHTTP
|
||||
case "https":
|
||||
scheme = SchemeHTTPS
|
||||
default:
|
||||
return nil, &DsnParseError{"invalid scheme"}
|
||||
}
|
||||
|
||||
// PublicKey
|
||||
publicKey := parsedURL.User.Username()
|
||||
if publicKey == "" {
|
||||
return nil, &DsnParseError{"empty username"}
|
||||
}
|
||||
|
||||
// SecretKey
|
||||
var secretKey string
|
||||
if parsedSecretKey, ok := parsedURL.User.Password(); ok {
|
||||
secretKey = parsedSecretKey
|
||||
}
|
||||
|
||||
// Host
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return nil, &DsnParseError{"empty host"}
|
||||
}
|
||||
|
||||
// OrgID (optional)
|
||||
var orgID uint64
|
||||
parts := strings.Split(host, ".")
|
||||
orgPart := parts[0]
|
||||
if len(orgPart) >= 2 && orgPart[0] == 'o' {
|
||||
parsedOrgID, err := strconv.ParseUint(orgPart[1:], 10, 64)
|
||||
if err == nil {
|
||||
orgID = parsedOrgID
|
||||
}
|
||||
}
|
||||
|
||||
// Port
|
||||
var port int
|
||||
if p := parsedURL.Port(); p != "" {
|
||||
port, err = strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil, &DsnParseError{"invalid port"}
|
||||
}
|
||||
} else {
|
||||
port = scheme.defaultPort()
|
||||
}
|
||||
|
||||
// ProjectID
|
||||
if parsedURL.Path == "" || parsedURL.Path == "/" {
|
||||
return nil, &DsnParseError{"empty project id"}
|
||||
}
|
||||
pathSegments := strings.Split(parsedURL.Path[1:], "/")
|
||||
projectID := pathSegments[len(pathSegments)-1]
|
||||
|
||||
if projectID == "" {
|
||||
return nil, &DsnParseError{"empty project id"}
|
||||
}
|
||||
|
||||
// Path
|
||||
var path string
|
||||
if len(pathSegments) > 1 {
|
||||
path = "/" + strings.Join(pathSegments[0:len(pathSegments)-1], "/")
|
||||
}
|
||||
|
||||
return &Dsn{
|
||||
scheme: scheme,
|
||||
publicKey: publicKey,
|
||||
secretKey: secretKey,
|
||||
host: host,
|
||||
port: port,
|
||||
path: path,
|
||||
projectID: projectID,
|
||||
orgID: orgID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// String formats Dsn struct into a valid string url.
|
||||
func (dsn Dsn) String() string {
|
||||
var url string
|
||||
url += fmt.Sprintf("%s://%s", dsn.scheme, dsn.publicKey)
|
||||
if dsn.secretKey != "" {
|
||||
url += fmt.Sprintf(":%s", dsn.secretKey)
|
||||
}
|
||||
url += fmt.Sprintf("@%s", dsn.host)
|
||||
if dsn.port != dsn.scheme.defaultPort() {
|
||||
url += fmt.Sprintf(":%d", dsn.port)
|
||||
}
|
||||
if dsn.path != "" {
|
||||
url += dsn.path
|
||||
}
|
||||
url += fmt.Sprintf("/%s", dsn.projectID)
|
||||
return url
|
||||
}
|
||||
|
||||
// Get the scheme of the DSN.
|
||||
func (dsn Dsn) GetScheme() string {
|
||||
return string(dsn.scheme)
|
||||
}
|
||||
|
||||
// Get the public key of the DSN.
|
||||
func (dsn Dsn) GetPublicKey() string {
|
||||
return dsn.publicKey
|
||||
}
|
||||
|
||||
// Get the secret key of the DSN.
|
||||
func (dsn Dsn) GetSecretKey() string {
|
||||
return dsn.secretKey
|
||||
}
|
||||
|
||||
// Get the host of the DSN.
|
||||
func (dsn Dsn) GetHost() string {
|
||||
return dsn.host
|
||||
}
|
||||
|
||||
// Get the port of the DSN.
|
||||
func (dsn Dsn) GetPort() int {
|
||||
return dsn.port
|
||||
}
|
||||
|
||||
// Get the path of the DSN.
|
||||
func (dsn Dsn) GetPath() string {
|
||||
return dsn.path
|
||||
}
|
||||
|
||||
// Get the project ID of the DSN.
|
||||
func (dsn Dsn) GetProjectID() string {
|
||||
return dsn.projectID
|
||||
}
|
||||
|
||||
// GetOrgID returns the orgID that was parsed from the DSN.
|
||||
func (dsn Dsn) GetOrgID() uint64 {
|
||||
return dsn.orgID
|
||||
}
|
||||
|
||||
// SetOrgID sets the orgID used for trace continuation.
|
||||
//
|
||||
// This function is used for overriding the orgID parsed from the DSN.
|
||||
func (dsn *Dsn) SetOrgID(orgID uint64) {
|
||||
dsn.orgID = orgID
|
||||
}
|
||||
|
||||
// GetAPIURL returns the URL of the envelope endpoint of the project
|
||||
// associated with the DSN.
|
||||
func (dsn Dsn) GetAPIURL() *url.URL {
|
||||
var rawURL string
|
||||
rawURL += fmt.Sprintf("%s://%s", dsn.scheme, dsn.host)
|
||||
if dsn.port != dsn.scheme.defaultPort() {
|
||||
rawURL += fmt.Sprintf(":%d", dsn.port)
|
||||
}
|
||||
if dsn.path != "" {
|
||||
rawURL += dsn.path
|
||||
}
|
||||
rawURL += fmt.Sprintf("/api/%s/%s/", dsn.projectID, "envelope")
|
||||
parsedURL, _ := url.Parse(rawURL)
|
||||
return parsedURL
|
||||
}
|
||||
|
||||
// RequestHeaders returns all the necessary headers that have to be used in the transport when sending events
|
||||
// to the /store endpoint.
|
||||
//
|
||||
// Deprecated: This method shall only be used if you want to implement your own transport that sends events to
|
||||
// the /store endpoint. If you're using the transport provided by the SDK, all necessary headers to authenticate
|
||||
// against the /envelope endpoint are added automatically.
|
||||
func (dsn Dsn) RequestHeaders(sdkVersion string) map[string]string {
|
||||
auth := fmt.Sprintf("Sentry sentry_version=%s, sentry_timestamp=%d, "+
|
||||
"sentry_client=sentry.go/%s, sentry_key=%s", apiVersion, time.Now().Unix(), sdkVersion, dsn.publicKey)
|
||||
|
||||
if dsn.secretKey != "" {
|
||||
auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.secretKey)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-Sentry-Auth": auth,
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON converts the Dsn struct to JSON.
|
||||
func (dsn Dsn) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(dsn.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON converts JSON data to the Dsn struct.
|
||||
func (dsn *Dsn) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
_ = json.Unmarshal(data, &str)
|
||||
newDsn, err := NewDsn(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dsn = *newDsn
|
||||
return nil
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Envelope represents a Sentry envelope containing headers and items.
|
||||
type Envelope struct {
|
||||
Header *EnvelopeHeader `json:"-"`
|
||||
Items []*EnvelopeItem `json:"-"`
|
||||
}
|
||||
|
||||
// EnvelopeHeader represents the header of a Sentry envelope.
|
||||
type EnvelopeHeader struct {
|
||||
// EventID is the unique identifier for this event
|
||||
EventID string `json:"event_id"`
|
||||
|
||||
// SentAt is the timestamp when the event was sent from the SDK as string in RFC 3339 format.
|
||||
// Used for clock drift correction of the event timestamp. The time zone must be UTC.
|
||||
SentAt time.Time `json:"sent_at,omitzero"`
|
||||
|
||||
// Dsn can be used for self-authenticated envelopes.
|
||||
// This means that the envelope has all the information necessary to be sent to sentry.
|
||||
// In this case the full DSN must be stored in this key.
|
||||
Dsn *Dsn `json:"dsn,omitempty"`
|
||||
|
||||
// Sdk carries the same payload as the sdk interface in the event payload but can be carried for all events.
|
||||
// This means that SDK information can be carried for minidumps, session data and other submissions.
|
||||
Sdk *SdkInfo `json:"sdk,omitempty"`
|
||||
|
||||
// Trace contains the [Dynamic Sampling Context](https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/)
|
||||
Trace map[string]string `json:"trace,omitempty"`
|
||||
}
|
||||
|
||||
// EnvelopeItemType represents the type of envelope item.
|
||||
type EnvelopeItemType string
|
||||
|
||||
// Constants for envelope item types as defined in the Sentry documentation.
|
||||
const (
|
||||
EnvelopeItemTypeEvent EnvelopeItemType = "event"
|
||||
EnvelopeItemTypeTransaction EnvelopeItemType = "transaction"
|
||||
EnvelopeItemTypeCheckIn EnvelopeItemType = "check_in"
|
||||
EnvelopeItemTypeAttachment EnvelopeItemType = "attachment"
|
||||
EnvelopeItemTypeLog EnvelopeItemType = "log"
|
||||
EnvelopeItemTypeTraceMetric EnvelopeItemType = "trace_metric"
|
||||
EnvelopeItemTypeClientReport EnvelopeItemType = "client_report"
|
||||
)
|
||||
|
||||
// EnvelopeItemHeader represents the header of an envelope item.
|
||||
type EnvelopeItemHeader struct {
|
||||
// Type specifies the type of this Item and its contents.
|
||||
// Based on the Item type, more headers may be required.
|
||||
Type EnvelopeItemType `json:"type"`
|
||||
|
||||
// Length is the length of the payload in bytes.
|
||||
// If no length is specified, the payload implicitly goes to the next newline.
|
||||
// For payloads containing newline characters, the length must be specified.
|
||||
Length *int `json:"length,omitempty"`
|
||||
|
||||
// Filename is the name of the attachment file (used for attachments)
|
||||
Filename string `json:"filename,omitempty"`
|
||||
|
||||
// ContentType is the MIME type of the item payload (used for attachments and some other item types)
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
|
||||
// ItemCount is the number of items in a batch (used for logs)
|
||||
ItemCount *int `json:"item_count,omitempty"`
|
||||
|
||||
// SpanCount is the number of spans in a transaction (used for client reports)
|
||||
SpanCount int `json:"-"`
|
||||
}
|
||||
|
||||
// EnvelopeItem represents a single item or batch within an envelope.
|
||||
type EnvelopeItem struct {
|
||||
Header *EnvelopeItemHeader `json:"-"`
|
||||
Payload []byte `json:"-"`
|
||||
}
|
||||
|
||||
// NewEnvelope creates a new envelope with the given header.
|
||||
func NewEnvelope(header *EnvelopeHeader) *Envelope {
|
||||
return &Envelope{
|
||||
Header: header,
|
||||
Items: make([]*EnvelopeItem, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddItem adds an item to the envelope.
|
||||
func (e *Envelope) AddItem(item *EnvelopeItem) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
e.Items = append(e.Items, item)
|
||||
}
|
||||
|
||||
// Serialize serializes the envelope to the Sentry envelope format.
|
||||
//
|
||||
// Format: Headers "\n" { Item } [ "\n" ]
|
||||
// Item: Headers "\n" Payload "\n".
|
||||
func (e *Envelope) Serialize() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
headerBytes, err := json.Marshal(e.Header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal envelope header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.Write(headerBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to write envelope header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.WriteString("\n"); err != nil {
|
||||
return nil, fmt.Errorf("failed to write newline after envelope header: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range e.Items {
|
||||
if err := e.writeItem(&buf, item); err != nil {
|
||||
return nil, fmt.Errorf("failed to write envelope item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// WriteTo writes the envelope to the given writer in the Sentry envelope format.
|
||||
func (e *Envelope) WriteTo(w io.Writer) (int64, error) {
|
||||
data, err := e.Serialize()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err := w.Write(data)
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// writeItem writes a single envelope item to the buffer.
|
||||
func (e *Envelope) writeItem(buf *bytes.Buffer, item *EnvelopeItem) error {
|
||||
headerBytes, err := json.Marshal(item.Header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal item header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.Write(headerBytes); err != nil {
|
||||
return fmt.Errorf("failed to write item header: %w", err)
|
||||
}
|
||||
|
||||
if _, err := buf.WriteString("\n"); err != nil {
|
||||
return fmt.Errorf("failed to write newline after item header: %w", err)
|
||||
}
|
||||
|
||||
if len(item.Payload) > 0 {
|
||||
if _, err := buf.Write(item.Payload); err != nil {
|
||||
return fmt.Errorf("failed to write item payload: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := buf.WriteString("\n"); err != nil {
|
||||
return fmt.Errorf("failed to write newline after item payload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the total size of the envelope when serialized.
|
||||
func (e *Envelope) Size() (int, error) {
|
||||
data, err := e.Serialize()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
// NewEnvelopeItem creates a new envelope item with the specified type and payload.
|
||||
func NewEnvelopeItem(itemType EnvelopeItemType, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: itemType,
|
||||
Length: &length,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTransactionItem creates a new envelope item including the span count of the transaction.
|
||||
func NewTransactionItem(spanCount int, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeTransaction,
|
||||
Length: &length,
|
||||
SpanCount: spanCount,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAttachmentItem creates a new envelope item for an attachment.
|
||||
// Parameters: filename, contentType, payload.
|
||||
func NewAttachmentItem(filename, contentType string, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeAttachment,
|
||||
Length: &length,
|
||||
ContentType: contentType,
|
||||
Filename: filename,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLogItem creates a new envelope item for logs.
|
||||
func NewLogItem(itemCount int, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeLog,
|
||||
Length: &length,
|
||||
ItemCount: &itemCount,
|
||||
ContentType: "application/vnd.sentry.items.log+json",
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTraceMetricItem creates a new envelope item for trace metrics.
|
||||
func NewTraceMetricItem(itemCount int, payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeTraceMetric,
|
||||
Length: &length,
|
||||
ItemCount: &itemCount,
|
||||
ContentType: "application/vnd.sentry.items.trace-metric+json",
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientReportItem creates a new envelope item for client reports.
|
||||
func NewClientReportItem(payload []byte) *EnvelopeItem {
|
||||
length := len(payload)
|
||||
return &EnvelopeItem{
|
||||
Header: &EnvelopeItemHeader{
|
||||
Type: EnvelopeItemTypeClientReport,
|
||||
Length: &length,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// TelemetryItem represents any telemetry data that can be stored in buffers and sent to Sentry.
|
||||
// This is the base interface that all telemetry items must implement.
|
||||
type TelemetryItem interface {
|
||||
// GetCategory returns the rate limit category for this item.
|
||||
GetCategory() ratelimit.Category
|
||||
|
||||
// GetEventID returns the event ID for this item.
|
||||
GetEventID() string
|
||||
|
||||
// GetSdkInfo returns SDK information for the envelope header.
|
||||
GetSdkInfo() *SdkInfo
|
||||
|
||||
// GetDynamicSamplingContext returns trace context for the envelope header.
|
||||
GetDynamicSamplingContext() map[string]string
|
||||
|
||||
// MakeSerializationSafe prevents serialization races, by serializing user mutable data on the foreground.
|
||||
// Should be used before passing telemetry to the processor.
|
||||
MakeSerializationSafe()
|
||||
}
|
||||
|
||||
// EnvelopeItemConvertible represents items that can be converted directly to envelope items.
|
||||
type EnvelopeItemConvertible interface {
|
||||
TelemetryItem
|
||||
|
||||
// ToEnvelopeItem converts the item to a Sentry envelope item.
|
||||
ToEnvelopeItem() (*EnvelopeItem, error)
|
||||
}
|
||||
|
||||
// EnvelopeConvertible represents items that can convert themselves to complete envelopes.
|
||||
type EnvelopeConvertible interface {
|
||||
TelemetryItem
|
||||
|
||||
// ToEnvelope converts the item to a Sentry envelope using the provided header.
|
||||
ToEnvelope(*EnvelopeHeader) (*Envelope, error)
|
||||
}
|
||||
|
||||
// TelemetryTransport represents the envelope-first transport interface.
|
||||
// This interface is designed for the telemetry buffer system and provides
|
||||
// non-blocking sends with backpressure signals.
|
||||
type TelemetryTransport interface {
|
||||
// SendEnvelope sends an envelope to Sentry. Returns immediately with
|
||||
// backpressure error if the queue is full.
|
||||
SendEnvelope(envelope *Envelope) error
|
||||
|
||||
// HasCapacity reports whether the transport has capacity to accept at least one more envelope.
|
||||
HasCapacity() bool
|
||||
|
||||
// IsRateLimited checks if a specific category is currently rate limited
|
||||
IsRateLimited(category ratelimit.Category) bool
|
||||
|
||||
// Flush waits for all pending envelopes to be sent, with timeout
|
||||
Flush(timeout time.Duration) bool
|
||||
|
||||
// FlushWithContext waits for all pending envelopes to be sent
|
||||
FlushWithContext(ctx context.Context) bool
|
||||
|
||||
// Close shuts down the transport gracefully
|
||||
Close()
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// LogAttribute is the JSON representation for a single log attribute value.
|
||||
type LogAttribute struct {
|
||||
Value any `json:"value"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Logs is a container for multiple log items which knows how to convert
|
||||
// itself into a single batched log envelope item.
|
||||
type Logs []TelemetryItem
|
||||
|
||||
func (ls Logs) ToEnvelopeItem() (*EnvelopeItem, error) {
|
||||
// Convert each log to its JSON representation
|
||||
items := make([]json.RawMessage, 0, len(ls))
|
||||
for _, log := range ls {
|
||||
logPayload, err := json.Marshal(log)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, logPayload)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
wrapper := struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}{Items: items}
|
||||
|
||||
payload, err := json.Marshal(wrapper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewLogItem(len(ls), payload), nil
|
||||
}
|
||||
|
||||
func (Logs) GetCategory() ratelimit.Category { return ratelimit.CategoryLog }
|
||||
func (Logs) GetEventID() string { return "" }
|
||||
func (Logs) GetSdkInfo() *SdkInfo { return nil }
|
||||
func (Logs) GetDynamicSamplingContext() map[string]string { return nil }
|
||||
func (Logs) MakeSerializationSafe() {}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
type Metrics []TelemetryItem
|
||||
|
||||
func (ms Metrics) ToEnvelopeItem() (*EnvelopeItem, error) {
|
||||
// Convert each metric to its JSON representation
|
||||
items := make([]json.RawMessage, 0, len(ms))
|
||||
for _, metric := range ms {
|
||||
metricPayload, err := json.Marshal(metric)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, metricPayload)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
wrapper := struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}{Items: items}
|
||||
|
||||
payload, err := json.Marshal(wrapper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewTraceMetricItem(len(items), payload), nil
|
||||
}
|
||||
|
||||
func (Metrics) GetCategory() ratelimit.Category { return ratelimit.CategoryTraceMetric }
|
||||
func (Metrics) GetEventID() string { return "" }
|
||||
func (Metrics) GetSdkInfo() *SdkInfo { return nil }
|
||||
func (Metrics) GetDynamicSamplingContext() map[string]string { return nil }
|
||||
func (Metrics) MakeSerializationSafe() {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package protocol
|
||||
|
||||
// SdkInfo contains SDK metadata.
|
||||
type SdkInfo struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Integrations []string `json:"integrations,omitempty"`
|
||||
Packages []SdkPackage `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// SdkPackage describes a package that was installed.
|
||||
type SdkPackage struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateEventID generates a random UUID v4 for use as a Sentry event ID.
|
||||
func GenerateEventID() string {
|
||||
id := make([]byte, 16)
|
||||
// Prefer rand.Read over rand.Reader, see https://go-review.googlesource.com/c/go/+/272326/.
|
||||
_, _ = rand.Read(id)
|
||||
id[6] &= 0x0F // clear version
|
||||
id[6] |= 0x40 // set version to 4 (random uuid)
|
||||
id[8] &= 0x3F // clear variant
|
||||
id[8] |= 0x80 // set to IETF variant
|
||||
return hex.EncodeToString(id)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Reference:
|
||||
// https://github.com/getsentry/relay/blob/46dfaa850b8717a6e22c3e9a275ba17fe673b9da/relay-base-schema/src/data_category.rs#L231-L271
|
||||
|
||||
// Category classifies supported payload types that can be ingested by Sentry
|
||||
// and, therefore, rate limited.
|
||||
type Category string
|
||||
|
||||
// Known rate limit categories that are specified in rate limit headers.
|
||||
const (
|
||||
CategoryUnknown Category = "unknown" // Unknown category should not get rate limited
|
||||
CategoryAll Category = "" // Special category for empty categories (applies to all)
|
||||
CategoryError Category = "error"
|
||||
CategoryTransaction Category = "transaction"
|
||||
CategorySpan Category = "span"
|
||||
CategoryLog Category = "log_item"
|
||||
CategoryLogByte Category = "log_byte"
|
||||
CategoryMonitor Category = "monitor"
|
||||
CategoryTraceMetric Category = "trace_metric"
|
||||
)
|
||||
|
||||
// knownCategories is the set of currently known categories. Other categories
|
||||
// are ignored for the purpose of rate-limiting.
|
||||
var knownCategories = map[Category]struct{}{
|
||||
CategoryAll: {},
|
||||
CategoryError: {},
|
||||
CategoryTransaction: {},
|
||||
CategoryLog: {},
|
||||
CategoryMonitor: {},
|
||||
CategoryTraceMetric: {},
|
||||
}
|
||||
|
||||
// String returns the category formatted for debugging.
|
||||
func (c Category) String() string {
|
||||
switch c {
|
||||
case CategoryAll:
|
||||
return "CategoryAll"
|
||||
case CategoryError:
|
||||
return "CategoryError"
|
||||
case CategoryTransaction:
|
||||
return "CategoryTransaction"
|
||||
case CategorySpan:
|
||||
return "CategorySpan"
|
||||
case CategoryLog:
|
||||
return "CategoryLog"
|
||||
case CategoryLogByte:
|
||||
return "CategoryLogByte"
|
||||
case CategoryMonitor:
|
||||
return "CategoryMonitor"
|
||||
case CategoryTraceMetric:
|
||||
return "CategoryTraceMetric"
|
||||
default:
|
||||
// For unknown categories, use the original formatting logic
|
||||
caser := cases.Title(language.English)
|
||||
rv := "Category"
|
||||
for _, w := range strings.Fields(string(c)) {
|
||||
rv += caser.String(w)
|
||||
}
|
||||
return rv
|
||||
}
|
||||
}
|
||||
|
||||
// Priority represents the importance level of a category for buffer management.
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
PriorityCritical Priority = iota + 1
|
||||
PriorityHigh
|
||||
PriorityMedium
|
||||
PriorityLow
|
||||
PriorityLowest
|
||||
)
|
||||
|
||||
func (p Priority) String() string {
|
||||
switch p {
|
||||
case PriorityCritical:
|
||||
return "critical"
|
||||
case PriorityHigh:
|
||||
return "high"
|
||||
case PriorityMedium:
|
||||
return "medium"
|
||||
case PriorityLow:
|
||||
return "low"
|
||||
case PriorityLowest:
|
||||
return "lowest"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// GetPriority returns the priority level for this category.
|
||||
func (c Category) GetPriority() Priority {
|
||||
switch c {
|
||||
case CategoryError:
|
||||
return PriorityCritical
|
||||
case CategoryMonitor:
|
||||
return PriorityHigh
|
||||
case CategoryLog:
|
||||
return PriorityLow
|
||||
case CategoryTransaction:
|
||||
return PriorityMedium
|
||||
case CategoryTraceMetric:
|
||||
return PriorityLow
|
||||
default:
|
||||
return PriorityMedium
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package ratelimit
|
||||
|
||||
import "time"
|
||||
|
||||
// A Deadline is a time instant when a rate limit expires.
|
||||
type Deadline time.Time
|
||||
|
||||
// After reports whether the deadline d is after other.
|
||||
func (d Deadline) After(other Deadline) bool {
|
||||
return time.Time(d).After(time.Time(other))
|
||||
}
|
||||
|
||||
// Equal reports whether d and e represent the same deadline.
|
||||
func (d Deadline) Equal(e Deadline) bool {
|
||||
return time.Time(d).Equal(time.Time(e))
|
||||
}
|
||||
|
||||
// String returns the deadline formatted for debugging.
|
||||
func (d Deadline) String() string {
|
||||
// Like time.Time.String, but without the monotonic clock reading.
|
||||
return time.Time(d).Round(0).String()
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Package ratelimit provides tools to work with rate limits imposed by Sentry's
|
||||
// data ingestion pipeline.
|
||||
package ratelimit
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Map maps categories to rate limit deadlines.
|
||||
//
|
||||
// A rate limit is in effect for a given category if either the category's
|
||||
// deadline or the deadline for the special CategoryAll has not yet expired.
|
||||
//
|
||||
// Use IsRateLimited to check whether a category is rate-limited.
|
||||
type Map map[Category]Deadline
|
||||
|
||||
// IsRateLimited returns true if the category is currently rate limited.
|
||||
func (m Map) IsRateLimited(c Category) bool {
|
||||
return m.isRateLimited(c, time.Now())
|
||||
}
|
||||
|
||||
func (m Map) isRateLimited(c Category, now time.Time) bool {
|
||||
return m.Deadline(c).After(Deadline(now))
|
||||
}
|
||||
|
||||
// Deadline returns the deadline when the rate limit for the given category or
|
||||
// the special CategoryAll expire, whichever is furthest into the future.
|
||||
func (m Map) Deadline(c Category) Deadline {
|
||||
categoryDeadline := m[c]
|
||||
allDeadline := m[CategoryAll]
|
||||
if categoryDeadline.After(allDeadline) {
|
||||
return categoryDeadline
|
||||
}
|
||||
return allDeadline
|
||||
}
|
||||
|
||||
// Merge merges the other map into m.
|
||||
//
|
||||
// If a category appears in both maps, the deadline that is furthest into the
|
||||
// future is preserved.
|
||||
func (m Map) Merge(other Map) {
|
||||
for c, d := range other {
|
||||
if d.After(m[c]) {
|
||||
m[c] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FromResponse returns a rate limit map from an HTTP response.
|
||||
func FromResponse(r *http.Response) Map {
|
||||
return fromResponse(r, time.Now())
|
||||
}
|
||||
|
||||
func fromResponse(r *http.Response, now time.Time) Map {
|
||||
s := r.Header.Get("X-Sentry-Rate-Limits")
|
||||
if s != "" {
|
||||
return parseXSentryRateLimits(s, now)
|
||||
}
|
||||
if r.StatusCode == http.StatusTooManyRequests {
|
||||
s := r.Header.Get("Retry-After")
|
||||
deadline, _ := parseRetryAfter(s, now)
|
||||
return Map{CategoryAll: deadline}
|
||||
}
|
||||
return Map{}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errInvalidXSRLRetryAfter = errors.New("invalid retry-after value")
|
||||
|
||||
// parseXSentryRateLimits returns a RateLimits map by parsing an input string in
|
||||
// the format of the X-Sentry-Rate-Limits header.
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// X-Sentry-Rate-Limits: 60:transaction, 2700:default;error;security
|
||||
//
|
||||
// This will rate limit transactions for the next 60 seconds and errors for the
|
||||
// next 2700 seconds.
|
||||
//
|
||||
// Limits for unknown categories are ignored.
|
||||
func parseXSentryRateLimits(s string, now time.Time) Map {
|
||||
// https://github.com/getsentry/relay/blob/0424a2e017d193a93918053c90cdae9472d164bf/relay-server/src/utils/rate_limits.rs#L44-L82
|
||||
m := make(Map, len(knownCategories))
|
||||
for _, limit := range strings.Split(s, ",") {
|
||||
limit = strings.TrimSpace(limit)
|
||||
if limit == "" {
|
||||
continue
|
||||
}
|
||||
components := strings.Split(limit, ":")
|
||||
if len(components) == 0 {
|
||||
continue
|
||||
}
|
||||
retryAfter, err := parseXSRLRetryAfter(strings.TrimSpace(components[0]), now)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
categories := ""
|
||||
if len(components) > 1 {
|
||||
categories = components[1]
|
||||
}
|
||||
for _, category := range strings.Split(categories, ";") {
|
||||
c := Category(strings.ToLower(strings.TrimSpace(category)))
|
||||
if _, ok := knownCategories[c]; !ok {
|
||||
// skip unknown categories, keep m small
|
||||
continue
|
||||
}
|
||||
// always keep the deadline furthest into the future
|
||||
if retryAfter.After(m[c]) {
|
||||
m[c] = retryAfter
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseXSRLRetryAfter parses a string into a retry-after rate limit deadline.
|
||||
//
|
||||
// Valid input is a number, possibly signed and possibly floating-point,
|
||||
// indicating the number of seconds to wait before sending another request.
|
||||
// Negative values are treated as zero. Fractional values are rounded to the
|
||||
// next integer.
|
||||
func parseXSRLRetryAfter(s string, now time.Time) (Deadline, error) {
|
||||
// https://github.com/getsentry/relay/blob/0424a2e017d193a93918053c90cdae9472d164bf/relay-quotas/src/rate_limit.rs#L88-L96
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return Deadline{}, errInvalidXSRLRetryAfter
|
||||
}
|
||||
d := time.Duration(math.Ceil(math.Max(f, 0.0))) * time.Second
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return Deadline(now.Add(d)), nil
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultRetryAfter = 1 * time.Minute
|
||||
|
||||
var errInvalidRetryAfter = errors.New("invalid input")
|
||||
|
||||
// parseRetryAfter parses a string s as in the standard Retry-After HTTP header
|
||||
// and returns a deadline until when requests are rate limited and therefore new
|
||||
// requests should not be sent. The input may be either a date or a non-negative
|
||||
// integer number of seconds.
|
||||
//
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
||||
//
|
||||
// parseRetryAfter always returns a usable deadline, even in case of an error.
|
||||
//
|
||||
// This is the original rate limiting mechanism used by Sentry, superseeded by
|
||||
// the X-Sentry-Rate-Limits response header.
|
||||
func parseRetryAfter(s string, now time.Time) (Deadline, error) {
|
||||
if s == "" {
|
||||
goto invalid
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
if n < 0 {
|
||||
goto invalid
|
||||
}
|
||||
d := time.Duration(n) * time.Second
|
||||
return Deadline(now.Add(d)), nil
|
||||
}
|
||||
if date, err := time.Parse(time.RFC1123, s); err == nil {
|
||||
return Deadline(date), nil
|
||||
}
|
||||
invalid:
|
||||
return Deadline(now.Add(defaultRetryAfter)), errInvalidRetryAfter
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBucketedCapacity = 100
|
||||
perBucketItemLimit = 100
|
||||
)
|
||||
|
||||
type Bucket[T any] struct {
|
||||
traceID string
|
||||
items []T
|
||||
createdAt time.Time
|
||||
lastUpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BucketedBuffer groups items by trace id, flushing per bucket.
|
||||
type BucketedBuffer[T any] struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
buckets []*Bucket[T]
|
||||
traceIndex map[string]int
|
||||
|
||||
head int
|
||||
tail int
|
||||
|
||||
itemCapacity int
|
||||
bucketCapacity int
|
||||
|
||||
totalItems int
|
||||
bucketCount int
|
||||
|
||||
category ratelimit.Category
|
||||
priority ratelimit.Priority
|
||||
overflowPolicy OverflowPolicy
|
||||
recorder report.ClientReportRecorder
|
||||
batchSize int
|
||||
timeout time.Duration
|
||||
lastFlushTime time.Time
|
||||
|
||||
offered int64
|
||||
dropped int64
|
||||
onDropped func(item T, reason string)
|
||||
}
|
||||
|
||||
func NewBucketedBuffer[T any](
|
||||
category ratelimit.Category,
|
||||
capacity int,
|
||||
overflowPolicy OverflowPolicy,
|
||||
batchSize int,
|
||||
timeout time.Duration,
|
||||
recorder report.ClientReportRecorder,
|
||||
) *BucketedBuffer[T] {
|
||||
if capacity <= 0 {
|
||||
capacity = defaultBucketedCapacity
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
|
||||
bucketCapacity := capacity / 10
|
||||
if bucketCapacity < 10 {
|
||||
bucketCapacity = 10
|
||||
}
|
||||
|
||||
return &BucketedBuffer[T]{
|
||||
buckets: make([]*Bucket[T], bucketCapacity),
|
||||
traceIndex: make(map[string]int),
|
||||
itemCapacity: capacity,
|
||||
bucketCapacity: bucketCapacity,
|
||||
category: category,
|
||||
priority: category.GetPriority(),
|
||||
overflowPolicy: overflowPolicy,
|
||||
recorder: recorder,
|
||||
batchSize: batchSize,
|
||||
timeout: timeout,
|
||||
lastFlushTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Offer(item T) bool {
|
||||
atomic.AddInt64(&b.offered, 1)
|
||||
|
||||
traceID := ""
|
||||
if ta, ok := any(item).(TraceAware); ok {
|
||||
if tid, hasTrace := ta.GetTraceID(); hasTrace {
|
||||
traceID = tid
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.offerToBucket(item, traceID)
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) offerToBucket(item T, traceID string) bool {
|
||||
if traceID != "" {
|
||||
if idx, exists := b.traceIndex[traceID]; exists {
|
||||
bucket := b.buckets[idx]
|
||||
if len(bucket.items) >= perBucketItemLimit {
|
||||
delete(b.traceIndex, traceID)
|
||||
} else {
|
||||
bucket.items = append(bucket.items, item)
|
||||
bucket.lastUpdatedAt = time.Now()
|
||||
b.totalItems++
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if b.totalItems >= b.itemCapacity {
|
||||
return b.handleOverflow(item, traceID)
|
||||
}
|
||||
if b.bucketCount >= b.bucketCapacity {
|
||||
return b.handleOverflow(item, traceID)
|
||||
}
|
||||
|
||||
bucket := &Bucket[T]{
|
||||
traceID: traceID,
|
||||
items: []T{item},
|
||||
createdAt: time.Now(),
|
||||
lastUpdatedAt: time.Now(),
|
||||
}
|
||||
b.buckets[b.tail] = bucket
|
||||
if traceID != "" {
|
||||
b.traceIndex[traceID] = b.tail
|
||||
}
|
||||
b.tail = (b.tail + 1) % b.bucketCapacity
|
||||
b.bucketCount++
|
||||
b.totalItems++
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool {
|
||||
switch b.overflowPolicy {
|
||||
case OverflowPolicyDropOldest:
|
||||
oldestBucket := b.buckets[b.head]
|
||||
if oldestBucket == nil {
|
||||
b.recordDroppedItem(item)
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "buffer_full_invalid_state")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if oldestBucket.traceID != "" {
|
||||
delete(b.traceIndex, oldestBucket.traceID)
|
||||
}
|
||||
droppedCount := len(oldestBucket.items)
|
||||
atomic.AddInt64(&b.dropped, int64(droppedCount))
|
||||
for _, di := range oldestBucket.items {
|
||||
b.recordDroppedItem(di)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(di, "buffer_full_drop_oldest_bucket")
|
||||
}
|
||||
}
|
||||
b.totalItems -= droppedCount
|
||||
b.bucketCount--
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
// add new bucket
|
||||
bucket := &Bucket[T]{traceID: traceID, items: []T{item}, createdAt: time.Now(), lastUpdatedAt: time.Now()}
|
||||
b.buckets[b.tail] = bucket
|
||||
if traceID != "" {
|
||||
b.traceIndex[traceID] = b.tail
|
||||
}
|
||||
b.tail = (b.tail + 1) % b.bucketCapacity
|
||||
b.bucketCount++
|
||||
b.totalItems++
|
||||
return true
|
||||
case OverflowPolicyDropNewest:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "buffer_full_drop_newest")
|
||||
}
|
||||
return false
|
||||
default:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "unknown_overflow_policy")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Poll() (T, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var zero T
|
||||
if b.bucketCount == 0 {
|
||||
return zero, false
|
||||
}
|
||||
bucket := b.buckets[b.head]
|
||||
if bucket == nil || len(bucket.items) == 0 {
|
||||
return zero, false
|
||||
}
|
||||
item := bucket.items[0]
|
||||
bucket.items = bucket.items[1:]
|
||||
b.totalItems--
|
||||
if len(bucket.items) == 0 {
|
||||
if bucket.traceID != "" {
|
||||
delete(b.traceIndex, bucket.traceID)
|
||||
}
|
||||
b.buckets[b.head] = nil
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
b.bucketCount--
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) PollBatch(maxItems int) []T {
|
||||
if maxItems <= 0 {
|
||||
return nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.bucketCount == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]T, 0, maxItems)
|
||||
for len(res) < maxItems && b.bucketCount > 0 {
|
||||
bucket := b.buckets[b.head]
|
||||
if bucket == nil {
|
||||
break
|
||||
}
|
||||
n := maxItems - len(res)
|
||||
if n > len(bucket.items) {
|
||||
n = len(bucket.items)
|
||||
}
|
||||
res = append(res, bucket.items[:n]...)
|
||||
bucket.items = bucket.items[n:]
|
||||
b.totalItems -= n
|
||||
if len(bucket.items) == 0 {
|
||||
if bucket.traceID != "" {
|
||||
delete(b.traceIndex, bucket.traceID)
|
||||
}
|
||||
b.buckets[b.head] = nil
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
b.bucketCount--
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) PollIfReady() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.bucketCount == 0 {
|
||||
return nil
|
||||
}
|
||||
ready := b.totalItems >= b.batchSize || (b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout)
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
oldest := b.buckets[b.head]
|
||||
if oldest == nil {
|
||||
return nil
|
||||
}
|
||||
items := oldest.items
|
||||
if oldest.traceID != "" {
|
||||
delete(b.traceIndex, oldest.traceID)
|
||||
}
|
||||
b.buckets[b.head] = nil
|
||||
b.head = (b.head + 1) % b.bucketCapacity
|
||||
b.totalItems -= len(items)
|
||||
b.bucketCount--
|
||||
b.lastFlushTime = time.Now()
|
||||
return items
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Drain() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.bucketCount == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]T, 0, b.totalItems)
|
||||
for i := 0; i < b.bucketCount; i++ {
|
||||
idx := (b.head + i) % b.bucketCapacity
|
||||
bucket := b.buckets[idx]
|
||||
if bucket != nil {
|
||||
res = append(res, bucket.items...)
|
||||
b.buckets[idx] = nil
|
||||
}
|
||||
}
|
||||
b.traceIndex = make(map[string]int)
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.totalItems = 0
|
||||
b.bucketCount = 0
|
||||
return res
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Peek() (T, bool) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
var zero T
|
||||
if b.bucketCount == 0 {
|
||||
return zero, false
|
||||
}
|
||||
bucket := b.buckets[b.head]
|
||||
if bucket == nil || len(bucket.items) == 0 {
|
||||
return zero, false
|
||||
}
|
||||
return bucket.items[0], true
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) Size() int { b.mu.RLock(); defer b.mu.RUnlock(); return b.totalItems }
|
||||
func (b *BucketedBuffer[T]) Capacity() int { b.mu.RLock(); defer b.mu.RUnlock(); return b.itemCapacity }
|
||||
func (b *BucketedBuffer[T]) Category() ratelimit.Category {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.category
|
||||
}
|
||||
func (b *BucketedBuffer[T]) Priority() ratelimit.Priority {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.priority
|
||||
}
|
||||
func (b *BucketedBuffer[T]) IsEmpty() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.bucketCount == 0
|
||||
}
|
||||
func (b *BucketedBuffer[T]) IsFull() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.totalItems >= b.itemCapacity
|
||||
}
|
||||
func (b *BucketedBuffer[T]) Utilization() float64 {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if b.itemCapacity == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(b.totalItems) / float64(b.itemCapacity)
|
||||
}
|
||||
func (b *BucketedBuffer[T]) OfferedCount() int64 { return atomic.LoadInt64(&b.offered) }
|
||||
func (b *BucketedBuffer[T]) DroppedCount() int64 { return atomic.LoadInt64(&b.dropped) }
|
||||
func (b *BucketedBuffer[T]) AcceptedCount() int64 { return b.OfferedCount() - b.DroppedCount() }
|
||||
func (b *BucketedBuffer[T]) DropRate() float64 {
|
||||
off := b.OfferedCount()
|
||||
if off == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(b.DroppedCount()) / float64(off)
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) GetMetrics() BufferMetrics {
|
||||
b.mu.RLock()
|
||||
size := b.totalItems
|
||||
util := 0.0
|
||||
if b.itemCapacity > 0 {
|
||||
util = float64(b.totalItems) / float64(b.itemCapacity)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
return BufferMetrics{Category: b.category, Priority: b.priority, Capacity: b.itemCapacity, Size: size, Utilization: util, OfferedCount: b.OfferedCount(), DroppedCount: b.DroppedCount(), AcceptedCount: b.AcceptedCount(), DropRate: b.DropRate(), LastUpdated: time.Now()}
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) SetDroppedCallback(callback func(item T, reason string)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.onDropped = callback
|
||||
}
|
||||
func (b *BucketedBuffer[T]) Clear() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for i := 0; i < b.bucketCapacity; i++ {
|
||||
b.buckets[i] = nil
|
||||
}
|
||||
b.traceIndex = make(map[string]int)
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.totalItems = 0
|
||||
b.bucketCount = 0
|
||||
}
|
||||
func (b *BucketedBuffer[T]) IsReadyToFlush() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if b.bucketCount == 0 {
|
||||
return false
|
||||
}
|
||||
if b.totalItems >= b.batchSize {
|
||||
return true
|
||||
}
|
||||
if b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (b *BucketedBuffer[T]) MarkFlushed() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.lastFlushTime = time.Now()
|
||||
}
|
||||
|
||||
func (b *BucketedBuffer[T]) recordDroppedItem(item T) {
|
||||
if ti, ok := any(item).(protocol.TelemetryItem); ok {
|
||||
b.recorder.RecordItem(report.ReasonBufferOverflow, ti)
|
||||
} else {
|
||||
b.recorder.RecordOne(report.ReasonBufferOverflow, b.category)
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// Buffer defines the common interface for all buffer implementations.
|
||||
type Buffer[T any] interface {
|
||||
// Core operations
|
||||
Offer(item T) bool
|
||||
Poll() (T, bool)
|
||||
PollBatch(maxItems int) []T
|
||||
PollIfReady() []T
|
||||
Drain() []T
|
||||
Peek() (T, bool)
|
||||
|
||||
// State queries
|
||||
Size() int
|
||||
Capacity() int
|
||||
IsEmpty() bool
|
||||
IsFull() bool
|
||||
Utilization() float64
|
||||
|
||||
// Flush management
|
||||
IsReadyToFlush() bool
|
||||
MarkFlushed()
|
||||
|
||||
// Category/Priority
|
||||
Category() ratelimit.Category
|
||||
Priority() ratelimit.Priority
|
||||
|
||||
// Metrics
|
||||
OfferedCount() int64
|
||||
DroppedCount() int64
|
||||
AcceptedCount() int64
|
||||
DropRate() float64
|
||||
GetMetrics() BufferMetrics
|
||||
|
||||
// Configuration
|
||||
SetDroppedCallback(callback func(item T, reason string))
|
||||
Clear()
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
// Processor is the top-level object that wraps the scheduler and buffers.
|
||||
type Processor struct {
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
// NewProcessor creates a new Processor with the given configuration.
|
||||
func NewProcessor(
|
||||
buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem],
|
||||
transport protocol.TelemetryTransport,
|
||||
dsn *protocol.Dsn,
|
||||
sdkInfo func() *protocol.SdkInfo,
|
||||
recorder report.ClientReportRecorder,
|
||||
) *Processor {
|
||||
scheduler := NewScheduler(buffers, transport, dsn, sdkInfo, recorder)
|
||||
scheduler.Start()
|
||||
|
||||
return &Processor{
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a TelemetryItem to the appropriate buffer based on its category.
|
||||
//
|
||||
// The processor should call MakeSerializationSafe to eliminate any race on user mutable fields,
|
||||
// since the serialization happens on a background goroutine.
|
||||
func (b *Processor) Add(item protocol.TelemetryItem) bool {
|
||||
item.MakeSerializationSafe()
|
||||
return b.scheduler.Add(item)
|
||||
}
|
||||
|
||||
// Flush forces all buffers to flush within the given timeout.
|
||||
func (b *Processor) Flush(timeout time.Duration) bool {
|
||||
return b.scheduler.Flush(timeout)
|
||||
}
|
||||
|
||||
// FlushWithContext flushes with a custom context for cancellation.
|
||||
func (b *Processor) FlushWithContext(ctx context.Context) bool {
|
||||
return b.scheduler.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
// Close stops the buffer, flushes remaining data, and releases resources.
|
||||
func (b *Processor) Close(timeout time.Duration) {
|
||||
b.scheduler.Stop(timeout)
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
const defaultCapacity = 100
|
||||
|
||||
// RingBuffer is a thread-safe ring buffer with overflow policies.
|
||||
type RingBuffer[T any] struct {
|
||||
mu sync.RWMutex
|
||||
items []T
|
||||
head int
|
||||
tail int
|
||||
size int
|
||||
capacity int
|
||||
|
||||
category ratelimit.Category
|
||||
priority ratelimit.Priority
|
||||
overflowPolicy OverflowPolicy
|
||||
recorder report.ClientReportRecorder
|
||||
|
||||
batchSize int
|
||||
timeout time.Duration
|
||||
lastFlushTime time.Time
|
||||
|
||||
offered int64
|
||||
dropped int64
|
||||
onDropped func(item T, reason string)
|
||||
}
|
||||
|
||||
func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration, recorder report.ClientReportRecorder) *RingBuffer[T] {
|
||||
if capacity <= 0 {
|
||||
capacity = defaultCapacity
|
||||
}
|
||||
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
|
||||
return &RingBuffer[T]{
|
||||
items: make([]T, capacity),
|
||||
capacity: capacity,
|
||||
category: category,
|
||||
priority: category.GetPriority(),
|
||||
overflowPolicy: overflowPolicy,
|
||||
recorder: recorder,
|
||||
batchSize: batchSize,
|
||||
timeout: timeout,
|
||||
lastFlushTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) SetDroppedCallback(callback func(item T, reason string)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.onDropped = callback
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Offer(item T) bool {
|
||||
atomic.AddInt64(&b.offered, 1)
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size < b.capacity {
|
||||
b.items[b.tail] = item
|
||||
b.tail = (b.tail + 1) % b.capacity
|
||||
b.size++
|
||||
return true
|
||||
}
|
||||
|
||||
switch b.overflowPolicy {
|
||||
case OverflowPolicyDropOldest:
|
||||
oldItem := b.items[b.head]
|
||||
b.items[b.head] = item
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.tail = (b.tail + 1) % b.capacity
|
||||
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(oldItem)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(oldItem, "buffer_full_drop_oldest")
|
||||
}
|
||||
return true
|
||||
|
||||
case OverflowPolicyDropNewest:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "buffer_full_drop_newest")
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
atomic.AddInt64(&b.dropped, 1)
|
||||
b.recordDroppedItem(item)
|
||||
if b.onDropped != nil {
|
||||
b.onDropped(item, "unknown_overflow_policy")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Poll() (T, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var zero T
|
||||
if b.size == 0 {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
item := b.items[b.head]
|
||||
b.items[b.head] = zero
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.size--
|
||||
|
||||
return item, true
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) PollBatch(maxItems int) []T {
|
||||
if maxItems <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemCount := maxItems
|
||||
if itemCount > b.size {
|
||||
itemCount = b.size
|
||||
}
|
||||
|
||||
result := make([]T, itemCount)
|
||||
var zero T
|
||||
|
||||
for i := 0; i < itemCount; i++ {
|
||||
result[i] = b.items[b.head]
|
||||
b.items[b.head] = zero
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.size--
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Drain() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]T, b.size)
|
||||
index := 0
|
||||
var zero T
|
||||
|
||||
for i := 0; i < b.size; i++ {
|
||||
pos := (b.head + i) % b.capacity
|
||||
result[index] = b.items[pos]
|
||||
b.items[pos] = zero
|
||||
index++
|
||||
}
|
||||
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.size = 0
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Peek() (T, bool) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
var zero T
|
||||
if b.size == 0 {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return b.items[b.head], true
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Size() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.size
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Capacity() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.capacity
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Category() ratelimit.Category {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.category
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Priority() ratelimit.Priority {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.priority
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) IsEmpty() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.size == 0
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) IsFull() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.size == b.capacity
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Utilization() float64 {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return float64(b.size) / float64(b.capacity)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) OfferedCount() int64 {
|
||||
return atomic.LoadInt64(&b.offered)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) DroppedCount() int64 {
|
||||
return atomic.LoadInt64(&b.dropped)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) AcceptedCount() int64 {
|
||||
return b.OfferedCount() - b.DroppedCount()
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) DropRate() float64 {
|
||||
offered := b.OfferedCount()
|
||||
if offered == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(b.DroppedCount()) / float64(offered)
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) Clear() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var zero T
|
||||
for i := 0; i < b.capacity; i++ {
|
||||
b.items[i] = zero
|
||||
}
|
||||
|
||||
b.head = 0
|
||||
b.tail = 0
|
||||
b.size = 0
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) GetMetrics() BufferMetrics {
|
||||
b.mu.RLock()
|
||||
size := b.size
|
||||
util := float64(b.size) / float64(b.capacity)
|
||||
b.mu.RUnlock()
|
||||
|
||||
return BufferMetrics{
|
||||
Category: b.category,
|
||||
Priority: b.priority,
|
||||
Capacity: b.capacity,
|
||||
Size: size,
|
||||
Utilization: util,
|
||||
OfferedCount: b.OfferedCount(),
|
||||
DroppedCount: b.DroppedCount(),
|
||||
AcceptedCount: b.AcceptedCount(),
|
||||
DropRate: b.DropRate(),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) IsReadyToFlush() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if b.size >= b.batchSize {
|
||||
return true
|
||||
}
|
||||
|
||||
if b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) MarkFlushed() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.lastFlushTime = time.Now()
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) PollIfReady() []T {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.size == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ready := b.size >= b.batchSize ||
|
||||
(b.timeout > 0 && time.Since(b.lastFlushTime) >= b.timeout)
|
||||
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemCount := b.batchSize
|
||||
if itemCount > b.size {
|
||||
itemCount = b.size
|
||||
}
|
||||
|
||||
result := make([]T, itemCount)
|
||||
var zero T
|
||||
|
||||
for i := 0; i < itemCount; i++ {
|
||||
result[i] = b.items[b.head]
|
||||
b.items[b.head] = zero
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
b.size--
|
||||
}
|
||||
|
||||
b.lastFlushTime = time.Now()
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *RingBuffer[T]) recordDroppedItem(item T) {
|
||||
if ti, ok := any(item).(protocol.TelemetryItem); ok {
|
||||
b.recorder.RecordItem(report.ReasonBufferOverflow, ti)
|
||||
} else {
|
||||
b.recorder.RecordOne(report.ReasonBufferOverflow, b.category)
|
||||
}
|
||||
}
|
||||
|
||||
type BufferMetrics struct {
|
||||
Category ratelimit.Category `json:"category"`
|
||||
Priority ratelimit.Priority `json:"priority"`
|
||||
Capacity int `json:"capacity"`
|
||||
Size int `json:"size"`
|
||||
Utilization float64 `json:"utilization"`
|
||||
OfferedCount int64 `json:"offered_count"`
|
||||
DroppedCount int64 `json:"dropped_count"`
|
||||
AcceptedCount int64 `json:"accepted_count"`
|
||||
DropRate float64 `json:"drop_rate"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// OverflowPolicy defines how the ring buffer handles overflow.
|
||||
type OverflowPolicy int
|
||||
|
||||
const (
|
||||
OverflowPolicyDropOldest OverflowPolicy = iota
|
||||
OverflowPolicyDropNewest
|
||||
)
|
||||
|
||||
func (op OverflowPolicy) String() string {
|
||||
switch op {
|
||||
case OverflowPolicyDropOldest:
|
||||
return "drop_oldest"
|
||||
case OverflowPolicyDropNewest:
|
||||
return "drop_newest"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/debuglog"
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
"github.com/getsentry/sentry-go/report"
|
||||
)
|
||||
|
||||
// Scheduler implements a weighted round-robin scheduler for processing buffered events.
|
||||
type Scheduler struct {
|
||||
buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem]
|
||||
transport protocol.TelemetryTransport
|
||||
dsn *protocol.Dsn
|
||||
sdkInfo func() *protocol.SdkInfo
|
||||
recorder report.ClientReportRecorder
|
||||
|
||||
currentCycle []ratelimit.Priority
|
||||
cyclePos int
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
processingWg sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
startOnce sync.Once
|
||||
finishOnce sync.Once
|
||||
}
|
||||
|
||||
func NewScheduler(
|
||||
buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem],
|
||||
transport protocol.TelemetryTransport,
|
||||
dsn *protocol.Dsn,
|
||||
sdkInfo func() *protocol.SdkInfo,
|
||||
recorder report.ClientReportRecorder,
|
||||
) *Scheduler {
|
||||
if recorder == nil {
|
||||
recorder = report.NoopRecorder()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored in s.cancel and called in Shutdown()
|
||||
|
||||
priorityWeights := map[ratelimit.Priority]int{
|
||||
ratelimit.PriorityCritical: 5,
|
||||
ratelimit.PriorityHigh: 4,
|
||||
ratelimit.PriorityMedium: 3,
|
||||
ratelimit.PriorityLow: 2,
|
||||
ratelimit.PriorityLowest: 1,
|
||||
}
|
||||
|
||||
var currentCycle []ratelimit.Priority
|
||||
for priority, weight := range priorityWeights {
|
||||
hasBuffers := false
|
||||
for _, buffer := range buffers {
|
||||
if buffer.Priority() == priority {
|
||||
hasBuffers = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasBuffers {
|
||||
for i := 0; i < weight; i++ {
|
||||
currentCycle = append(currentCycle, priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s := &Scheduler{
|
||||
buffers: buffers,
|
||||
transport: transport,
|
||||
dsn: dsn,
|
||||
sdkInfo: sdkInfo,
|
||||
recorder: recorder,
|
||||
currentCycle: currentCycle,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.cond = sync.NewCond(&s.mu)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Scheduler) resolveSdkInfo() *protocol.SdkInfo {
|
||||
if s.sdkInfo == nil {
|
||||
return &protocol.SdkInfo{}
|
||||
}
|
||||
return s.sdkInfo()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.startOnce.Do(func() {
|
||||
s.processingWg.Add(1)
|
||||
go s.run()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop(timeout time.Duration) {
|
||||
s.finishOnce.Do(func() {
|
||||
s.Flush(timeout)
|
||||
|
||||
s.cancel()
|
||||
s.cond.Broadcast()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
s.processingWg.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(timeout):
|
||||
debuglog.Printf("scheduler stop timed out after %v", timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scheduler) Signal() {
|
||||
s.cond.Signal()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Add(item protocol.TelemetryItem) bool {
|
||||
category := item.GetCategory()
|
||||
buffer, exists := s.buffers[category]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
accepted := buffer.Offer(item)
|
||||
if accepted {
|
||||
s.Signal()
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func (s *Scheduler) Flush(timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return s.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Scheduler) FlushWithContext(ctx context.Context) bool {
|
||||
s.flushBuffers()
|
||||
return s.transport.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Scheduler) run() {
|
||||
defer s.processingWg.Done()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.cond.Broadcast()
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
s.mu.Lock()
|
||||
|
||||
for !s.hasWork() && s.ctx.Err() == nil {
|
||||
s.cond.Wait()
|
||||
}
|
||||
|
||||
if s.ctx.Err() != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
s.processNextBatch()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) hasWork() bool {
|
||||
for _, buffer := range s.buffers {
|
||||
if buffer.IsReadyToFlush() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Scheduler) processNextBatch() {
|
||||
if len(s.currentCycle) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
priority := s.currentCycle[s.cyclePos]
|
||||
s.cyclePos = (s.cyclePos + 1) % len(s.currentCycle)
|
||||
|
||||
var bufferToProcess Buffer[protocol.TelemetryItem]
|
||||
var categoryToProcess ratelimit.Category
|
||||
for category, buffer := range s.buffers {
|
||||
if buffer.Priority() == priority && buffer.IsReadyToFlush() {
|
||||
bufferToProcess = buffer
|
||||
categoryToProcess = category
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if bufferToProcess != nil {
|
||||
s.processItems(bufferToProcess, categoryToProcess, false)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category ratelimit.Category, force bool) {
|
||||
var items []protocol.TelemetryItem
|
||||
|
||||
if force {
|
||||
items = buffer.Drain()
|
||||
} else {
|
||||
items = buffer.PollIfReady()
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if s.isRateLimited(category) {
|
||||
for _, item := range items {
|
||||
s.recorder.RecordItem(report.ReasonRateLimitBackoff, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !s.transport.HasCapacity() {
|
||||
for _, item := range items {
|
||||
s.recorder.RecordItem(report.ReasonQueueOverflow, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch category {
|
||||
case ratelimit.CategoryLog:
|
||||
logs := protocol.Logs(items)
|
||||
header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Dsn: s.dsn, Sdk: s.resolveSdkInfo()}
|
||||
envelope := protocol.NewEnvelope(header)
|
||||
item, err := logs.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("error creating log batch envelope item: %v", err)
|
||||
return
|
||||
}
|
||||
envelope.AddItem(item)
|
||||
if err := s.transport.SendEnvelope(envelope); err != nil {
|
||||
debuglog.Printf("error sending envelope: %v", err)
|
||||
}
|
||||
return
|
||||
case ratelimit.CategoryTraceMetric:
|
||||
metrics := protocol.Metrics(items)
|
||||
header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Dsn: s.dsn, Sdk: s.resolveSdkInfo()}
|
||||
envelope := protocol.NewEnvelope(header)
|
||||
item, err := metrics.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("error creating trace metric batch envelope item: %v", err)
|
||||
return
|
||||
}
|
||||
envelope.AddItem(item)
|
||||
if err := s.transport.SendEnvelope(envelope); err != nil {
|
||||
debuglog.Printf("error sending envelope: %v", err)
|
||||
}
|
||||
return
|
||||
default:
|
||||
// if the buffers are properly configured, buffer.PollIfReady should return a single item for every category
|
||||
// other than logs. We still iterate over the items just in case, because we don't want to send broken envelopes.
|
||||
for _, it := range items {
|
||||
convertible, ok := it.(protocol.EnvelopeItemConvertible)
|
||||
if !ok {
|
||||
debuglog.Printf("item does not implement EnvelopeItemConvertible: %T", it)
|
||||
continue
|
||||
}
|
||||
s.sendItem(convertible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) sendItem(item protocol.EnvelopeItemConvertible) {
|
||||
header := &protocol.EnvelopeHeader{
|
||||
EventID: item.GetEventID(),
|
||||
SentAt: time.Now(),
|
||||
Dsn: s.dsn,
|
||||
Trace: item.GetDynamicSamplingContext(),
|
||||
Sdk: s.resolveSdkInfo(),
|
||||
}
|
||||
if header.EventID == "" {
|
||||
header.EventID = protocol.GenerateEventID()
|
||||
}
|
||||
|
||||
// TODO: need to restructure the abstraction layer to actually return envelopes instead of
|
||||
// envelope items. The problem with envelope items, is that for events and batches we might
|
||||
// need envelopes (eg. attachments need to be added separately and splitting the `Add` path
|
||||
// is problematic). Currently this is a workaround for adding attachments to events without
|
||||
// adding a circular dependency on telemetry/scheduler.
|
||||
var envelope *protocol.Envelope
|
||||
if convertible, ok := item.(protocol.EnvelopeConvertible); ok {
|
||||
var err error
|
||||
envelope, err = convertible.ToEnvelope(header)
|
||||
if err != nil {
|
||||
debuglog.Printf("error while converting to envelope: %v", err)
|
||||
s.recorder.RecordItem(report.ReasonInternalError, item)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
envItem, err := item.ToEnvelopeItem()
|
||||
if err != nil {
|
||||
debuglog.Printf("error while converting to envelope item: %v", err)
|
||||
s.recorder.RecordItem(report.ReasonInternalError, item)
|
||||
return
|
||||
}
|
||||
envelope = protocol.NewEnvelope(header)
|
||||
envelope.AddItem(envItem)
|
||||
}
|
||||
|
||||
if err := s.transport.SendEnvelope(envelope); err != nil {
|
||||
debuglog.Printf("error sending envelope: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) flushBuffers() {
|
||||
for category, buffer := range s.buffers {
|
||||
if !buffer.IsEmpty() {
|
||||
s.processItems(buffer, category, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) isRateLimited(category ratelimit.Category) bool {
|
||||
return s.transport.IsRateLimited(category)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package telemetry
|
||||
|
||||
// TraceAware is implemented by items that can expose a trace ID.
|
||||
// BucketedBuffer uses this to group items by trace.
|
||||
type TraceAware interface {
|
||||
GetTraceID() (string, bool)
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package util
|
||||
|
||||
import "sync"
|
||||
|
||||
type SyncMap[K comparable, V any] struct {
|
||||
m sync.Map
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Store(key K, value V) {
|
||||
s.m.Store(key, value)
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) CompareAndDelete(key K, value V) {
|
||||
s.m.CompareAndDelete(key, value)
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Load(key K) (V, bool) {
|
||||
v, ok := s.m.Load(key)
|
||||
if !ok {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
return v.(V), true
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Delete(key K) {
|
||||
s.m.Delete(key)
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
|
||||
actual, loaded := s.m.LoadOrStore(key, value)
|
||||
return actual.(V), loaded
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Clear() {
|
||||
s.m.Clear()
|
||||
}
|
||||
|
||||
func (s *SyncMap[K, V]) Range(f func(key K, value V) bool) {
|
||||
s.m.Range(func(key, value any) bool {
|
||||
return f(key.(K), value.(V))
|
||||
})
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go/internal/debuglog"
|
||||
"github.com/getsentry/sentry-go/internal/protocol"
|
||||
"github.com/getsentry/sentry-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
// MaxDrainResponseBytes is the maximum number of bytes that transport
|
||||
// implementations will read from response bodies when draining them.
|
||||
const MaxDrainResponseBytes = 16 << 10
|
||||
|
||||
// HandleHTTPResponse is a helper method that reads the HTTP response and handles debug output.
|
||||
func HandleHTTPResponse(response *http.Response, identifier string) bool {
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return true
|
||||
}
|
||||
|
||||
if response.StatusCode >= 400 && response.StatusCode <= 599 {
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, MaxDrainResponseBytes))
|
||||
if err != nil {
|
||||
debuglog.Printf("Error while reading response body: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case response.StatusCode == http.StatusRequestEntityTooLarge:
|
||||
debuglog.Printf("Sending %s failed because the request was too large: %s", identifier, string(body))
|
||||
case response.StatusCode >= 500:
|
||||
debuglog.Printf("Sending %s failed with server error %d: %s", identifier, response.StatusCode, string(body))
|
||||
default:
|
||||
debuglog.Printf("Sending %s failed with client error %d: %s", identifier, response.StatusCode, string(body))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
debuglog.Printf("Unexpected status code %d for event %s", response.StatusCode, identifier)
|
||||
return false
|
||||
}
|
||||
|
||||
// EnvelopeIdentifier returns a human-readable identifier for the event to be used in log messages.
|
||||
// Format: "<description> [<event-id>]".
|
||||
func EnvelopeIdentifier(envelope *protocol.Envelope) string {
|
||||
if envelope == nil || len(envelope.Items) == 0 {
|
||||
return "empty envelope"
|
||||
}
|
||||
|
||||
var description string
|
||||
// we don't currently support mixed envelope types, so all event types would have the same type.
|
||||
itemType := envelope.Items[0].Header.Type
|
||||
|
||||
switch itemType {
|
||||
case protocol.EnvelopeItemTypeEvent:
|
||||
description = "error"
|
||||
case protocol.EnvelopeItemTypeTransaction:
|
||||
description = "transaction"
|
||||
case protocol.EnvelopeItemTypeCheckIn:
|
||||
description = "check-in"
|
||||
case protocol.EnvelopeItemTypeLog:
|
||||
logCount := 0
|
||||
for _, item := range envelope.Items {
|
||||
if item != nil && item.Header != nil && item.Header.Type == protocol.EnvelopeItemTypeLog && item.Header.ItemCount != nil {
|
||||
logCount += *item.Header.ItemCount
|
||||
}
|
||||
}
|
||||
description = fmt.Sprintf("%d log events", logCount)
|
||||
case protocol.EnvelopeItemTypeTraceMetric:
|
||||
metricCount := 0
|
||||
for _, item := range envelope.Items {
|
||||
if item != nil && item.Header != nil && item.Header.Type == protocol.EnvelopeItemTypeTraceMetric && item.Header.ItemCount != nil {
|
||||
metricCount += *item.Header.ItemCount
|
||||
}
|
||||
}
|
||||
description = fmt.Sprintf("%d metric events", metricCount)
|
||||
default:
|
||||
description = fmt.Sprintf("%s event", itemType)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s [%s]", description, envelope.Header.EventID)
|
||||
}
|
||||
|
||||
// SendResult holds the outcome of an HTTP request sent to Sentry.
|
||||
type SendResult struct {
|
||||
Success bool
|
||||
StatusCode int
|
||||
Limits ratelimit.Map
|
||||
}
|
||||
|
||||
// IsSendError returns true if the response indicates a server/client error that should be recorded
|
||||
// as send_error for client report outcomes (non-429 failures).
|
||||
func (r *SendResult) IsSendError() bool {
|
||||
return !r.Success && r.StatusCode != http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
// DoSendRequest executes an HTTP request, handles response logging, extracts rate limits, and
|
||||
// drains+closes the response body.
|
||||
func DoSendRequest(client *http.Client, request *http.Request, identifier string) (*SendResult, error) {
|
||||
response, err := client.Do(request) //nolint:gosec // G704: request is constructed internally, not from user input
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
success := HandleHTTPResponse(response, identifier)
|
||||
limits := ratelimit.FromResponse(response)
|
||||
|
||||
// Drain body up to a limit and close it, allowing the transport to reuse TCP connections.
|
||||
_, _ = io.CopyN(io.Discard, response.Body, MaxDrainResponseBytes)
|
||||
response.Body.Close()
|
||||
|
||||
return &SendResult{
|
||||
Success: success,
|
||||
StatusCode: response.StatusCode,
|
||||
Limits: limits,
|
||||
}, nil
|
||||
}
|
||||
+354
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+964
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user