feat_: add Sentry panic reporting (#6054)
* feat_: report panics to sentry * test_: sentry options, params and utils * feat_: toggle sentry with centralized metrics * test_: sentry init, report and close * refactor_: rename public api to generic * docs_: sentry * fix_: typo in internal/sentry/README.md Co-authored-by: osmaczko <33099791+osmaczko@users.noreply.github.com> * fix_: linter --------- Co-authored-by: osmaczko <33099791+osmaczko@users.noreply.github.com>
This commit is contained in:
parent
474658b6b1
commit
987a9e8707
|
@ -119,6 +119,9 @@ cmd/status-backend/server/endpoints.go
|
||||||
protocol/messenger_handlers.go
|
protocol/messenger_handlers.go
|
||||||
internal/version/VERSION
|
internal/version/VERSION
|
||||||
internal/version/GIT_COMMIT
|
internal/version/GIT_COMMIT
|
||||||
|
internal/sentry/SENTRY_CONTEXT_NAME
|
||||||
|
internal/sentry/SENTRY_CONTEXT_VERSION
|
||||||
|
internal/sentry/SENTRY_PRODUCTION
|
||||||
|
|
||||||
# Don't ignore generated files in the vendor/ directory
|
# Don't ignore generated files in the vendor/ directory
|
||||||
!vendor/**/*.pb.go
|
!vendor/**/*.pb.go
|
||||||
|
|
|
@ -35,6 +35,7 @@ import (
|
||||||
"github.com/status-im/status-go/eth-node/crypto"
|
"github.com/status-im/status-go/eth-node/crypto"
|
||||||
"github.com/status-im/status-go/eth-node/types"
|
"github.com/status-im/status-go/eth-node/types"
|
||||||
"github.com/status-im/status-go/images"
|
"github.com/status-im/status-go/images"
|
||||||
|
"github.com/status-im/status-go/internal/sentry"
|
||||||
"github.com/status-im/status-go/internal/version"
|
"github.com/status-im/status-go/internal/version"
|
||||||
"github.com/status-im/status-go/logutils"
|
"github.com/status-im/status-go/logutils"
|
||||||
"github.com/status-im/status-go/multiaccounts"
|
"github.com/status-im/status-go/multiaccounts"
|
||||||
|
@ -101,6 +102,7 @@ type GethStatusBackend struct {
|
||||||
allowAllRPC bool // used only for tests, disables api method restrictions
|
allowAllRPC bool // used only for tests, disables api method restrictions
|
||||||
LocalPairingStateManager *statecontrol.ProcessStateManager
|
LocalPairingStateManager *statecontrol.ProcessStateManager
|
||||||
centralizedMetrics *centralizedmetrics.MetricService
|
centralizedMetrics *centralizedmetrics.MetricService
|
||||||
|
sentryDSN string
|
||||||
|
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
@ -2119,6 +2121,7 @@ func (b *GethStatusBackend) startNode(config *params.NodeConfig) (err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
err = fmt.Errorf("node crashed on start: %v", err)
|
err = fmt.Errorf("node crashed on start: %v", err)
|
||||||
|
sentry.RecoverError(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -2845,3 +2848,25 @@ func (b *GethStatusBackend) getWalletDBPath(keyUID string) (string, error) {
|
||||||
|
|
||||||
return filepath.Join(b.rootDataDir, fmt.Sprintf("%s-wallet.db", keyUID)), nil
|
return filepath.Join(b.rootDataDir, fmt.Sprintf("%s-wallet.db", keyUID)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *GethStatusBackend) SetSentryDSN(dsn string) {
|
||||||
|
b.sentryDSN = dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *GethStatusBackend) EnablePanicReporting() error {
|
||||||
|
return sentry.Init(
|
||||||
|
sentry.WithDSN(b.sentryDSN),
|
||||||
|
sentry.WithDefaultContext(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *GethStatusBackend) DisablePanicReporting() error {
|
||||||
|
return sentry.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *GethStatusBackend) TogglePanicReporting(enabled bool) error {
|
||||||
|
if enabled {
|
||||||
|
return b.EnablePanicReporting()
|
||||||
|
}
|
||||||
|
return b.DisablePanicReporting()
|
||||||
|
}
|
||||||
|
|
|
@ -10,12 +10,13 @@ import (
|
||||||
"github.com/ethereum/go-ethereum/log"
|
"github.com/ethereum/go-ethereum/log"
|
||||||
|
|
||||||
"github.com/status-im/status-go/cmd/status-backend/server"
|
"github.com/status-im/status-go/cmd/status-backend/server"
|
||||||
|
"github.com/status-im/status-go/internal/sentry"
|
||||||
"github.com/status-im/status-go/internal/version"
|
"github.com/status-im/status-go/internal/version"
|
||||||
"github.com/status-im/status-go/logutils"
|
"github.com/status-im/status-go/logutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
address = flag.String("address", "", "host:port to listen")
|
address = flag.String("address", "127.0.0.1:0", "host:port to listen")
|
||||||
logger = log.New("package", "status-go/cmd/status-backend")
|
logger = log.New("package", "status-go/cmd/status-backend")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -32,6 +33,12 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
sentry.MustInit(
|
||||||
|
sentry.WithDefaultEnvironmentDSN(),
|
||||||
|
sentry.WithContext("status-backend", version.Version()),
|
||||||
|
)
|
||||||
|
defer sentry.Recover()
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
srv := server.NewServer()
|
srv := server.NewServer()
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/status-im/status-go/eth-node/crypto"
|
"github.com/status-im/status-go/eth-node/crypto"
|
||||||
|
"github.com/status-im/status-go/internal/sentry"
|
||||||
"github.com/status-im/status-go/logutils"
|
"github.com/status-im/status-go/logutils"
|
||||||
"github.com/status-im/status-go/protocol/identity/alias"
|
"github.com/status-im/status-go/protocol/identity/alias"
|
||||||
"github.com/status-im/status-go/protocol/protobuf"
|
"github.com/status-im/status-go/protocol/protobuf"
|
||||||
|
@ -89,8 +90,16 @@ func IsNil(i interface{}) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func LogOnPanic() {
|
func LogOnPanic() {
|
||||||
if err := recover(); err != nil {
|
err := recover()
|
||||||
logutils.ZapLogger().Error("panic in goroutine", zap.Any("error", err), zap.Stack("stacktrace"))
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logutils.ZapLogger().Error("panic in goroutine",
|
||||||
|
zap.Any("error", err),
|
||||||
|
zap.Stack("stacktrace"))
|
||||||
|
|
||||||
|
sentry.RecoverError(err)
|
||||||
|
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Description
|
||||||
|
|
||||||
|
This package encapsulates Sentry integration. So far:
|
||||||
|
- only for status-go (including when running as part of desktop and mobile)
|
||||||
|
- only for panics (no other error reporting)
|
||||||
|
|
||||||
|
Sentry is only enabled for users that **both**:
|
||||||
|
1. Opted-in for metrics
|
||||||
|
2. Use builds from our release CI
|
||||||
|
|
||||||
|
## 🛬 Where
|
||||||
|
|
||||||
|
We use self-hosted Sentry: https://sentry.infra.status.im/
|
||||||
|
|
||||||
|
## 🕐 When
|
||||||
|
|
||||||
|
### Which panics are reported:
|
||||||
|
|
||||||
|
- When running inside `status-desktop`/`status-mobile`:
|
||||||
|
- during API calls in `/mobile/status.go`
|
||||||
|
- inside all goroutines
|
||||||
|
- When running `status-backend`:
|
||||||
|
- any panic
|
||||||
|
|
||||||
|
### Which panics are NOT reported:
|
||||||
|
|
||||||
|
- When running inside `status-desktop`/`status-mobile`:
|
||||||
|
- during API calls in `/services/**/api.go` \
|
||||||
|
NOTE: These endpoints are executed through `go-ethereum`'s JSON-RPC server, which internally recovers all panics and doesn't provide any events or option to set an interceptor.
|
||||||
|
The only way to catch these panics is to replace the JSON-RPC server implementation.
|
||||||
|
- When running `status-go` unit tests:
|
||||||
|
- any panic \
|
||||||
|
NOTE: Go internally runs tests in a goroutine. The only way to catch these panics in tests is to manually `defer sentry.Recover()` in each test. This also requires a linter (similar to `lint-panics`) that checks this call is present. \
|
||||||
|
This is not a priority right now, because:
|
||||||
|
1. We have direct access to failed tests logs, which contain the panic stacktrace.
|
||||||
|
2. Panics are not expected to happen. Test must be passing to be able to merge the code. So it's only possible with a flaky test.
|
||||||
|
3. This would only apply to nightly/develop jobs, as we don't gather panic reports from PR-level jobs.
|
||||||
|
|
||||||
|
|
||||||
|
## 📦 What
|
||||||
|
|
||||||
|
Full list can be found in `sentry.Event`.
|
||||||
|
|
||||||
|
Notes regarding identity-disclosing properties:
|
||||||
|
- `ServerName` - completely removed from all events
|
||||||
|
- `Stacktrace`:
|
||||||
|
- No private user paths are exposed, as we only gather reports from CI-built binaries.
|
||||||
|
- `TraceID` - so far will be unique for each event
|
||||||
|
>Trace: A collection of spans representing the end-to-end journey of a request through your system that all share the same trace ID.
|
||||||
|
|
||||||
|
More details in [sentry docs](https://docs.sentry.io/product/explore/traces/#key-concepts).
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
There are 2 main tags to identify the error. The configuration is a bit complicated, but provides full information.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### `Environment`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| Defining question | Where it is running? |
|
||||||
|
| Set time | - `production` can only be set at build time to prevent users from hacking the environment<br>- All others can be set at runtime, because on CI we sometimes use same build for multiple environments |
|
||||||
|
| Expected values | <table><thead><tr><th>Value</th><th>Description</th></tr></thead><tr><td>`production`</td><td>End user machine</td></tr><tr><td>~~`development`~~</td><td>Developer machine</td></tr><tr><td>~~`ci-pr`~~</td><td>PR-level CI runs</td></tr><tr><td>`ci-main`</td><td>CI runs for stable branch</td></tr><tr><td>`ci-nightly`</td><td>CI nightly jobs on stable branch</td></tr></table>`development` and `ci-pr` are dropped, because we only want to consider panics from stable code |
|
||||||
|
|
||||||
|
### `Context`
|
||||||
|
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| Defining question | What is the executable for the library? |
|
||||||
|
| Set time | Always at build-time |
|
||||||
|
| Expected values | <table><thead><tr><th>Value</th><th>Running as...</th></tr></thead><tr><td>`status-desktop`</td><td>Library embedded into [status-desktop](https://github.com/status-im/status-desktop)</td></tr><tr><td>`status-mobile`</td><td>Library embedded into [status-mobile](https://github.com/status-im/status-mobile)</td></tr><tr><td>`status-backend`</td><td>Part of `cmd/status-backend`<br>Can be other `cmd/*` as well.</td></tr><tr><td>`matterbridge`</td><td>Part of [Status/Discord bridge app](https://github.com/status-im/matterbridge)</td></tr><tr><td>`status-go-tests`</td><td>Inside status-go tests</td></tr></table> |
|
||||||
|
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
To cover these requirements, I added these environment variables:
|
||||||
|
|
||||||
|
| Environment variable | Provide time | Description |
|
||||||
|
|---------------------------------------------------|---------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `SENTRY_DSN` | - At build time with direct call to `sentry.Init`<br>- At runtime with `InitializeApplication` endpoint | [Sentry DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) to be used |
|
||||||
|
| `SENTRY_CONTEXT_NAME`<br>`SENTRY_CONTEXT_VERSION` | Build time | Execution context of status-go |
|
||||||
|
| `SENTRY_PRODUCTION` | Build time | When `true` or `1`:<br>-Defines if this is a production build<br>-Sets environment to `production`<br>-Has precedence over runtime `SENTRY_ENVIRONMENT` |
|
||||||
|
| `SENTRY_ENVIRONMENT` | Run time | Sets the environment. Has no effect when `SENTRY_PRODUCTION` is set |
|
||||||
|
|
||||||
|
# Client integration
|
||||||
|
|
||||||
|
1. Set `SENTRY_CONTEXT_NAME` and `SENTRY_CONTEXT_VERSION` at status-go build time
|
||||||
|
2. Provide `sentryDSN` to the `InitializeApplication` call.
|
||||||
|
DSN must be kept private and will be provided by CI. Expect a `STATUS_GO_SENTRY_DSN` environment variable to be provided by CI.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Why can't we consume `STATUS_GO_SENTRY_DSN` directly in status-go build?</summary>
|
||||||
|
|
||||||
|
In theory, we could. But this would require us to mix approaches of getting the env variable to the code.
|
||||||
|
Right now we prefer `go:generate + go:embed` approach (e.g. https://github.com/status-im/status-go/pull/6014), but we can't do it in this case, because we must not write the DSN to a local file, which would be a bit vulnerable. And I don't want to go back to `-ldflags="-X github.com/status-im/status-go/internal/sentry.sentryDSN=$(STATUS_GO_SENTRY_DSN:v%=%)` approach.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
# Implementation details
|
||||||
|
|
||||||
|
- We recover from panics in here:
|
||||||
|
https://github.com/status-im/status-go/blob/fcedb013166785e7def8710118086f4b650c33b1/common/utils.go#L102 https://github.com/status-im/status-go/blob/fcedb013166785e7def8710118086f4b650c33b1/mobile/callog/status_request_log.go#L69 https://github.com/status-im/status-go/blob/fcedb013166785e7def8710118086f4b650c33b1/cmd/status-backend/main.go#L40
|
||||||
|
This covers all goroutines, because we have a linter to check that all goroutines have `defer common.LogOnPanic`.
|
||||||
|
- Sentry is currently initialized in 2 places:
|
||||||
|
- `InitializeApplication` - covers desktop/mobile clients
|
||||||
|
https://github.com/status-im/status-go/blob/fcedb013166785e7def8710118086f4b650c33b1/mobile/status.go#L105-L108
|
||||||
|
- in `status-backend` - covers functional tests:
|
||||||
|
https://github.com/status-im/status-go/blob/fcedb013166785e7def8710118086f4b650c33b1/cmd/status-backend/main.go#L36-L39
|
|
@ -0,0 +1,46 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultEnvVarDSN = "SENTRY_DSN_STATUS_GO"
|
||||||
|
const DefaultEnvVarEnvironment = "SENTRY_ENVIRONMENT"
|
||||||
|
|
||||||
|
type Option func(*sentry.ClientOptions)
|
||||||
|
|
||||||
|
func WithDSN(dsn string) Option {
|
||||||
|
return func(o *sentry.ClientOptions) {
|
||||||
|
o.Dsn = dsn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithEnvironmentDSN(name string) Option {
|
||||||
|
return WithDSN(os.Getenv(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDefaultEnvironmentDSN() Option {
|
||||||
|
return WithEnvironmentDSN(DefaultEnvVarDSN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithContext(name string, version string) Option {
|
||||||
|
return func(o *sentry.ClientOptions) {
|
||||||
|
if o.Tags == nil {
|
||||||
|
o.Tags = make(map[string]string)
|
||||||
|
}
|
||||||
|
o.Tags["context.name"] = name
|
||||||
|
o.Tags["context.version"] = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDefaultContext() Option {
|
||||||
|
return WithContext(DefaultContext(), DefaultContextVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOptions(cfg *sentry.ClientOptions, opts ...Option) {
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/brianvoe/gofakeit/v6"
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWithDSN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dsn := "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||||
|
option := WithDSN(dsn)
|
||||||
|
cfg := &sentry.ClientOptions{}
|
||||||
|
option(cfg)
|
||||||
|
require.Equal(t, dsn, cfg.Dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithEnvironmentDSN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expectedDSN := gofakeit.LetterN(10)
|
||||||
|
envVar := gofakeit.LetterN(10)
|
||||||
|
|
||||||
|
err := os.Setenv(envVar, expectedDSN)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err = os.Unsetenv(envVar)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
option := WithEnvironmentDSN(envVar)
|
||||||
|
cfg := &sentry.ClientOptions{}
|
||||||
|
option(cfg)
|
||||||
|
require.Equal(t, expectedDSN, cfg.Dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
name := "test-context"
|
||||||
|
version := "v1.0.0"
|
||||||
|
option := WithContext(name, version)
|
||||||
|
cfg := &sentry.ClientOptions{}
|
||||||
|
option(cfg)
|
||||||
|
require.Equal(t, name, cfg.Tags["context.name"])
|
||||||
|
require.Equal(t, version, cfg.Tags["context.version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyOptions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dsn := gofakeit.LetterN(10)
|
||||||
|
name := gofakeit.LetterN(10)
|
||||||
|
version := gofakeit.LetterN(5)
|
||||||
|
|
||||||
|
options := []Option{
|
||||||
|
WithDSN(dsn),
|
||||||
|
WithContext(name, version),
|
||||||
|
}
|
||||||
|
cfg := &sentry.ClientOptions{}
|
||||||
|
applyOptions(cfg, options...)
|
||||||
|
|
||||||
|
require.Equal(t, dsn, cfg.Dsn)
|
||||||
|
require.Equal(t, name, cfg.Tags["context.name"])
|
||||||
|
require.Equal(t, version, cfg.Tags["context.version"])
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate sh -c "echo $SENTRY_CONTEXT_NAME > SENTRY_CONTEXT_NAME"
|
||||||
|
//go:generate sh -c "echo $SENTRY_CONTEXT_VERSION > SENTRY_CONTEXT_VERSION"
|
||||||
|
//go:generate sh -c "echo $SENTRY_PRODUCTION > SENTRY_PRODUCTION"
|
||||||
|
|
||||||
|
const productionEnvironment = "production"
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed SENTRY_CONTEXT_NAME
|
||||||
|
defaultContextName string
|
||||||
|
|
||||||
|
//go:embed SENTRY_CONTEXT_VERSION
|
||||||
|
defaultContextVersion string
|
||||||
|
|
||||||
|
//go:embed SENTRY_PRODUCTION
|
||||||
|
production string
|
||||||
|
)
|
||||||
|
|
||||||
|
func DefaultContext() string {
|
||||||
|
return defaultContextName
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultContextVersion() string {
|
||||||
|
return defaultContextVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func Production() bool {
|
||||||
|
return production == "true" || production == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func Environment() string {
|
||||||
|
return environment(Production(), DefaultEnvVarEnvironment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func environment(production bool, envvar string) string {
|
||||||
|
if production {
|
||||||
|
return productionEnvironment
|
||||||
|
}
|
||||||
|
env := os.Getenv(envvar)
|
||||||
|
if env == productionEnvironment {
|
||||||
|
// Production environment can only be set during build
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/brianvoe/gofakeit/v6"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setEnvVar(t *testing.T, varName, value string) {
|
||||||
|
err := os.Setenv(varName, value)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err = os.Unsetenv(varName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvironment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("returns production environment when production is true", func(t *testing.T) {
|
||||||
|
varName := gofakeit.LetterN(10)
|
||||||
|
setEnvVar(t, varName, "development")
|
||||||
|
|
||||||
|
// Expect production although the env variable is set to development
|
||||||
|
result := environment(true, varName)
|
||||||
|
require.Equal(t, productionEnvironment, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns empty string when env is productionEnvironment", func(t *testing.T) {
|
||||||
|
varName := gofakeit.LetterN(10)
|
||||||
|
setEnvVar(t, varName, productionEnvironment)
|
||||||
|
|
||||||
|
result := environment(false, varName)
|
||||||
|
require.Equal(t, "", result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns environment variable when production is false", func(t *testing.T) {
|
||||||
|
varName := gofakeit.LetterN(10)
|
||||||
|
expectedEnv := "development"
|
||||||
|
setEnvVar(t, varName, expectedEnv)
|
||||||
|
|
||||||
|
result := environment(false, varName)
|
||||||
|
require.Equal(t, expectedEnv, result)
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,67 @@
|
||||||
package sentry
|
package sentry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/brianvoe/gofakeit/v6"
|
"time"
|
||||||
_ "github.com/getsentry/sentry-go"
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
|
||||||
|
"github.com/status-im/status-go/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func Init(opts ...Option) error {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
applyOptions(cfg, opts...)
|
||||||
|
return sentry.Init(*cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustInit(options ...Option) {
|
||||||
|
if err := Init(options...); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Close() error {
|
||||||
|
sentry.Flush(time.Second * 2)
|
||||||
|
// Set DSN to empty string to disable sending events
|
||||||
|
return sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Recover() {
|
||||||
|
err := recover()
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RecoverError(err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecoverError(err interface{}) {
|
||||||
|
sentry.CurrentHub().Recover(err)
|
||||||
|
sentry.Flush(time.Second * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() *sentry.ClientOptions {
|
||||||
|
return &sentry.ClientOptions{
|
||||||
|
EnableTracing: false,
|
||||||
|
Debug: false,
|
||||||
|
SendDefaultPII: false,
|
||||||
|
Release: version.Version(),
|
||||||
|
Environment: Environment(),
|
||||||
|
Tags: make(map[string]string),
|
||||||
|
BeforeSend: beforeSend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beforeSend(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||||
|
event.Modules = nil // Clear modules as we know all dependencies by commit hash
|
||||||
|
event.ServerName = "" // Clear server name as it might be sensitive
|
||||||
|
|
||||||
|
// Cleanup the stacktrace from last Recover/LogOnPanic frames
|
||||||
|
for _, exception := range event.Exception {
|
||||||
|
trimStacktrace(exception.Stacktrace)
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/brianvoe/gofakeit/v6"
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBeforeSend(t *testing.T) {
|
||||||
|
// Initialize a sample event with a stacktrace
|
||||||
|
event := &sentry.Event{
|
||||||
|
Modules: map[string]string{"example": "1.0.0"},
|
||||||
|
ServerName: "test-server",
|
||||||
|
Exception: []sentry.Exception{
|
||||||
|
{
|
||||||
|
Stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunction"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "RecoverError"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the beforeSend function
|
||||||
|
result := beforeSend(event, nil)
|
||||||
|
|
||||||
|
// Verify that the stacktrace frames are correctly trimmed
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.Len(t, result.Exception[0].Stacktrace.Frames, 1)
|
||||||
|
require.Equal(t, "OtherFunction", result.Exception[0].Stacktrace.Frames[0].Function)
|
||||||
|
|
||||||
|
// Verify that Modules and ServerName are empty
|
||||||
|
require.Empty(t, result.Modules)
|
||||||
|
require.Empty(t, result.ServerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSentry(t *testing.T) {
|
||||||
|
dsn := fmt.Sprintf("https://%s@sentry.example.com/%d",
|
||||||
|
gofakeit.LetterN(32),
|
||||||
|
gofakeit.Number(0, 1000),
|
||||||
|
)
|
||||||
|
context := gofakeit.LetterN(5)
|
||||||
|
version := gofakeit.LetterN(5)
|
||||||
|
|
||||||
|
var producedEvent *sentry.Event
|
||||||
|
interceptor := func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||||
|
producedEvent = event
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Init(
|
||||||
|
WithDSN(dsn),
|
||||||
|
WithContext(context, version),
|
||||||
|
func(o *sentry.ClientOptions) {
|
||||||
|
o.BeforeSend = interceptor
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
message := gofakeit.LetterN(5)
|
||||||
|
err := errors.New(message)
|
||||||
|
defer func() {
|
||||||
|
recoveredError := recover().(error)
|
||||||
|
require.NotNil(t, recoveredError)
|
||||||
|
require.ErrorIs(t, err, recoveredError)
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
defer Recover()
|
||||||
|
panic(err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
require.NotNil(t, producedEvent)
|
||||||
|
|
||||||
|
err = Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stacktraceFilters = []struct {
|
||||||
|
Module string
|
||||||
|
Functions []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Module: "github.com/status-im/status-go/internal/sentry",
|
||||||
|
Functions: []string{"Recover", "RecoverError"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Module: "github.com/status-im/status-go/common",
|
||||||
|
Functions: []string{"LogOnPanic"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Module: "github.com/status-im/status-go/mobile/callog",
|
||||||
|
Functions: []string{"Call.func1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimStacktrace(stacktrace *sentry.Stacktrace) {
|
||||||
|
if stacktrace == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stacktrace.Frames) <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trim := 0
|
||||||
|
|
||||||
|
// Trim max 2 frames from the end
|
||||||
|
for i := len(stacktrace.Frames) - 1; i >= 0; i-- {
|
||||||
|
if !matchFilter(stacktrace.Frames[i]) {
|
||||||
|
// break as soon as we find a frame that doesn't match
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
trim++
|
||||||
|
if trim == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stacktrace.Frames = stacktrace.Frames[:len(stacktrace.Frames)-trim]
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchFilter(frame sentry.Frame) bool {
|
||||||
|
for _, filter := range stacktraceFilters {
|
||||||
|
if frame.Module != filter.Module {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(filter.Functions, frame.Function) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
package sentry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrimStacktrace(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stacktrace *sentry.Stacktrace
|
||||||
|
expected []sentry.Frame
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil stacktrace",
|
||||||
|
stacktrace: nil,
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single frame",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple frames with matching filters",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
{Module: "github.com/status-im/status-go/mobile/callog", Function: "Call.func1"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "RecoverError"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching module but not function",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Init"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Init"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple frames without matching filters",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc2"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc2"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching filters only at the end",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
{Module: "github.com/status-im/status-go/common", Function: "LogOnPanic"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove at most 2 frames",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc1"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc2"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc3"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
{Module: "github.com/status-im/status-go/mobile/callog", Function: "Call.func1"},
|
||||||
|
{Module: "github.com/status-im/status-go/common", Function: "LogOnPanic"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc1"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc2"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc3"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "Recover"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "break if non-matching frame found",
|
||||||
|
stacktrace: &sentry.Stacktrace{
|
||||||
|
Frames: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/mobile/callog", Function: "Call.func1"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "RecoverError"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []sentry.Frame{
|
||||||
|
{Module: "github.com/status-im/status-go/mobile/callog", Function: "Call.func1"},
|
||||||
|
{Module: "github.com/status-im/status-go/internal/sentry", Function: "RecoverError"},
|
||||||
|
{Module: "github.com/status-im/status-go/other", Function: "OtherFunc1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
trimStacktrace(tt.stacktrace)
|
||||||
|
if tt.stacktrace != nil {
|
||||||
|
require.Equal(t, tt.expected, tt.stacktrace.Frames)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/status-im/status-go/internal/sentry"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sensitiveKeys = []string{
|
var sensitiveKeys = []string{
|
||||||
|
@ -64,6 +66,7 @@ func Call(logger, requestLogger *zap.Logger, fn any, params ...any) any {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
logger.Error("panic found in call", zap.Any("error", r), zap.Stack("stacktrace"))
|
logger.Error("panic found in call", zap.Any("error", r), zap.Stack("stacktrace"))
|
||||||
|
sentry.RecoverError(r)
|
||||||
panic(r)
|
panic(r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -99,6 +99,16 @@ func initializeApplication(requestJSON string) string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return makeJSONResponse(err)
|
return makeJSONResponse(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initialize sentry
|
||||||
|
statusBackend.SetSentryDSN(request.SentryDSN)
|
||||||
|
if centralizedMetricsInfo.Enabled {
|
||||||
|
err = statusBackend.EnablePanicReporting()
|
||||||
|
if err != nil {
|
||||||
|
return makeJSONResponse(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response := &InitializeApplicationResponse{
|
response := &InitializeApplicationResponse{
|
||||||
Accounts: accs,
|
Accounts: accs,
|
||||||
CentralizedMetricsInfo: centralizedMetricsInfo,
|
CentralizedMetricsInfo: centralizedMetricsInfo,
|
||||||
|
@ -2202,6 +2212,11 @@ func toggleCentralizedMetrics(requestJSON string) string {
|
||||||
return makeJSONResponse(err)
|
return makeJSONResponse(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = statusBackend.TogglePanicReporting(request.Enabled)
|
||||||
|
if err != nil {
|
||||||
|
return makeJSONResponse(err)
|
||||||
|
}
|
||||||
|
|
||||||
return makeJSONResponse(nil)
|
return makeJSONResponse(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ type InitializeApplication struct {
|
||||||
MixpanelToken string `json:"mixpanelToken"`
|
MixpanelToken string `json:"mixpanelToken"`
|
||||||
// MediaServerEnableTLS is optional, if not provided, media server will use TLS by default
|
// MediaServerEnableTLS is optional, if not provided, media server will use TLS by default
|
||||||
MediaServerEnableTLS *bool `json:"mediaServerEnableTLS"`
|
MediaServerEnableTLS *bool `json:"mediaServerEnableTLS"`
|
||||||
|
SentryDSN string `json:"sentryDSN"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *InitializeApplication) Validate() error {
|
func (i *InitializeApplication) Validate() error {
|
||||||
|
|
Loading…
Reference in New Issue