From 1f7a57f8e3092ed3325ebb49263f37eed8d0d4ce Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 10 Dec 2025 09:31:55 +0200 Subject: [PATCH] Tracking provider --- go.mod | 1 + go.sum | 2 + pkg/config/config.go | 27 +++-- pkg/errortracking/README.md | 150 +++++++++++++++++++++++ pkg/errortracking/errortracking_test.go | 67 +++++++++++ pkg/errortracking/factory.go | 33 +++++ pkg/errortracking/interfaces.go | 33 +++++ pkg/errortracking/noop.go | 37 ++++++ pkg/errortracking/sentry.go | 154 ++++++++++++++++++++++++ pkg/logger/logger.go | 79 +++++++++--- 10 files changed, 561 insertions(+), 22 deletions(-) create mode 100644 pkg/errortracking/README.md create mode 100644 pkg/errortracking/errortracking_test.go create mode 100644 pkg/errortracking/factory.go create mode 100644 pkg/errortracking/interfaces.go create mode 100644 pkg/errortracking/noop.go create mode 100644 pkg/errortracking/sentry.go diff --git a/go.mod b/go.mod index c5c14d6..3e8ceac 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/getsentry/sentry-go v0.40.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index ba513da..e10c562 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo= +github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= diff --git a/pkg/config/config.go b/pkg/config/config.go index 684833d..b47266f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,13 +4,14 @@ import "time" // Config represents the complete application configuration type Config struct { - Server ServerConfig `mapstructure:"server"` - Tracing TracingConfig `mapstructure:"tracing"` - Cache CacheConfig `mapstructure:"cache"` - Logger LoggerConfig `mapstructure:"logger"` - Middleware MiddlewareConfig `mapstructure:"middleware"` - CORS CORSConfig `mapstructure:"cors"` - Database DatabaseConfig `mapstructure:"database"` + Server ServerConfig `mapstructure:"server"` + Tracing TracingConfig `mapstructure:"tracing"` + Cache CacheConfig `mapstructure:"cache"` + Logger LoggerConfig `mapstructure:"logger"` + ErrorTracking ErrorTrackingConfig `mapstructure:"error_tracking"` + Middleware MiddlewareConfig `mapstructure:"middleware"` + CORS CORSConfig `mapstructure:"cors"` + Database DatabaseConfig `mapstructure:"database"` } // ServerConfig holds server-related configuration @@ -78,3 +79,15 @@ type CORSConfig struct { type DatabaseConfig struct { URL string `mapstructure:"url"` } + +// ErrorTrackingConfig holds error tracking configuration +type ErrorTrackingConfig struct { + Enabled bool `mapstructure:"enabled"` + Provider string `mapstructure:"provider"` // sentry, noop + DSN string `mapstructure:"dsn"` // Sentry DSN + Environment string `mapstructure:"environment"` // e.g., production, staging, development + Release string `mapstructure:"release"` // Application version/release + Debug bool `mapstructure:"debug"` // Enable debug mode + SampleRate float64 `mapstructure:"sample_rate"` // Error sample rate (0.0-1.0) + TracesSampleRate float64 `mapstructure:"traces_sample_rate"` // Traces sample rate (0.0-1.0) +} diff --git a/pkg/errortracking/README.md b/pkg/errortracking/README.md new file mode 100644 index 0000000..a9950c2 --- /dev/null +++ b/pkg/errortracking/README.md @@ -0,0 +1,150 @@ +# Error Tracking + +This package provides error tracking integration for ResolveSpec, with built-in support for Sentry. + +## Features + +- **Provider Interface**: Flexible design supporting multiple error tracking backends +- **Sentry Integration**: Full-featured Sentry support with automatic error, warning, and panic tracking +- **Automatic Logger Integration**: All `logger.Error()` and `logger.Warn()` calls are automatically sent to the error tracker +- **Panic Tracking**: Automatic panic capture with stack traces +- **NoOp Provider**: Zero-overhead when error tracking is disabled + +## Configuration + +Add error tracking configuration to your config file: + +```yaml +error_tracking: + enabled: true + provider: "sentry" # Currently supports: "sentry" or "noop" + dsn: "https://your-sentry-dsn@sentry.io/project-id" + environment: "production" # e.g., production, staging, development + release: "v1.0.0" # Your application version + debug: false + sample_rate: 1.0 # Error sample rate (0.0-1.0) + traces_sample_rate: 0.1 # Traces sample rate (0.0-1.0) +``` + +## Usage + +### Initialization + +Initialize error tracking in your application startup: + +```go +package main + +import ( + "github.com/bitechdev/ResolveSpec/pkg/config" + "github.com/bitechdev/ResolveSpec/pkg/errortracking" + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +func main() { + // Load your configuration + cfg := config.Config{ + ErrorTracking: config.ErrorTrackingConfig{ + Enabled: true, + Provider: "sentry", + DSN: "https://your-sentry-dsn@sentry.io/project-id", + Environment: "production", + Release: "v1.0.0", + SampleRate: 1.0, + }, + } + + // Initialize logger + logger.Init(false) + + // Initialize error tracking + provider, err := errortracking.NewProviderFromConfig(cfg.ErrorTracking) + if err != nil { + logger.Error("Failed to initialize error tracking: %v", err) + } else { + logger.InitErrorTracking(provider) + } + + // Your application code... + + // Cleanup on shutdown + defer logger.CloseErrorTracking() +} +``` + +### Automatic Tracking + +Once initialized, all logger errors and warnings are automatically sent to the error tracker: + +```go +// This will be logged AND sent to Sentry +logger.Error("Database connection failed: %v", err) + +// This will also be logged AND sent to Sentry +logger.Warn("Cache miss for key: %s", key) +``` + +### Panic Tracking + +Panics are automatically captured when using the logger's panic handlers: + +```go +// Using CatchPanic +defer logger.CatchPanic("MyFunction") + +// Using CatchPanicCallback +defer logger.CatchPanicCallback("MyFunction", func(err any) { + // Custom cleanup +}) + +// Using HandlePanic +defer func() { + if r := recover(); r != nil { + err = logger.HandlePanic("MyMethod", r) + } +}() +``` + +### Manual Tracking + +You can also use the provider directly for custom error tracking: + +```go +import ( + "context" + "github.com/bitechdev/ResolveSpec/pkg/errortracking" + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +func someFunction() { + tracker := logger.GetErrorTracker() + if tracker != nil { + // Capture an error + tracker.CaptureError(context.Background(), err, errortracking.SeverityError, map[string]interface{}{ + "user_id": userID, + "request_id": requestID, + }) + + // Capture a message + tracker.CaptureMessage(context.Background(), "Important event occurred", errortracking.SeverityInfo, map[string]interface{}{ + "event_type": "user_signup", + }) + + // Capture a panic + tracker.CapturePanic(context.Background(), recovered, stackTrace, map[string]interface{}{ + "context": "background_job", + }) + } +} +``` + +## Severity Levels + +The package supports the following severity levels: + +- `SeverityError`: For errors that should be tracked and investigated +- `SeverityWarning`: For warnings that may indicate potential issues +- `SeverityInfo`: For informational messages +- `SeverityDebug`: For debug-level information + +``` diff --git a/pkg/errortracking/errortracking_test.go b/pkg/errortracking/errortracking_test.go new file mode 100644 index 0000000..06cb347 --- /dev/null +++ b/pkg/errortracking/errortracking_test.go @@ -0,0 +1,67 @@ +package errortracking + +import ( + "context" + "errors" + "testing" +) + +func TestNoOpProvider(t *testing.T) { + provider := NewNoOpProvider() + + // Test that all methods can be called without panicking + t.Run("CaptureError", func(t *testing.T) { + provider.CaptureError(context.Background(), errors.New("test error"), SeverityError, nil) + }) + + t.Run("CaptureMessage", func(t *testing.T) { + provider.CaptureMessage(context.Background(), "test message", SeverityWarning, nil) + }) + + t.Run("CapturePanic", func(t *testing.T) { + provider.CapturePanic(context.Background(), "panic!", []byte("stack trace"), nil) + }) + + t.Run("Flush", func(t *testing.T) { + result := provider.Flush(5) + if !result { + t.Error("Expected Flush to return true") + } + }) + + t.Run("Close", func(t *testing.T) { + err := provider.Close() + if err != nil { + t.Errorf("Expected Close to return nil, got %v", err) + } + }) +} + +func TestSeverityLevels(t *testing.T) { + tests := []struct { + name string + severity Severity + expected string + }{ + {"Error", SeverityError, "error"}, + {"Warning", SeverityWarning, "warning"}, + {"Info", SeverityInfo, "info"}, + {"Debug", SeverityDebug, "debug"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if string(tt.severity) != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, string(tt.severity)) + } + }) + } +} + +func TestProviderInterface(t *testing.T) { + // Test that NoOpProvider implements Provider interface + var _ Provider = (*NoOpProvider)(nil) + + // Test that SentryProvider implements Provider interface + var _ Provider = (*SentryProvider)(nil) +} diff --git a/pkg/errortracking/factory.go b/pkg/errortracking/factory.go new file mode 100644 index 0000000..2661e13 --- /dev/null +++ b/pkg/errortracking/factory.go @@ -0,0 +1,33 @@ +package errortracking + +import ( + "fmt" + + "github.com/bitechdev/ResolveSpec/pkg/config" +) + +// NewProviderFromConfig creates an error tracking provider based on the configuration +func NewProviderFromConfig(cfg config.ErrorTrackingConfig) (Provider, error) { + if !cfg.Enabled { + return NewNoOpProvider(), nil + } + + switch cfg.Provider { + case "sentry": + if cfg.DSN == "" { + return nil, fmt.Errorf("sentry DSN is required when error tracking is enabled") + } + return NewSentryProvider(SentryConfig{ + DSN: cfg.DSN, + Environment: cfg.Environment, + Release: cfg.Release, + Debug: cfg.Debug, + SampleRate: cfg.SampleRate, + TracesSampleRate: cfg.TracesSampleRate, + }) + case "noop", "": + return NewNoOpProvider(), nil + default: + return nil, fmt.Errorf("unknown error tracking provider: %s", cfg.Provider) + } +} diff --git a/pkg/errortracking/interfaces.go b/pkg/errortracking/interfaces.go new file mode 100644 index 0000000..99b78f4 --- /dev/null +++ b/pkg/errortracking/interfaces.go @@ -0,0 +1,33 @@ +package errortracking + +import ( + "context" +) + +// Severity represents the severity level of an error +type Severity string + +const ( + SeverityError Severity = "error" + SeverityWarning Severity = "warning" + SeverityInfo Severity = "info" + SeverityDebug Severity = "debug" +) + +// Provider defines the interface for error tracking providers +type Provider interface { + // CaptureError captures an error with the given severity and additional context + CaptureError(ctx context.Context, err error, severity Severity, extra map[string]interface{}) + + // CaptureMessage captures a message with the given severity and additional context + CaptureMessage(ctx context.Context, message string, severity Severity, extra map[string]interface{}) + + // CapturePanic captures a panic with stack trace + CapturePanic(ctx context.Context, recovered interface{}, stackTrace []byte, extra map[string]interface{}) + + // Flush waits for all events to be sent (useful for graceful shutdown) + Flush(timeout int) bool + + // Close closes the provider and releases resources + Close() error +} diff --git a/pkg/errortracking/noop.go b/pkg/errortracking/noop.go new file mode 100644 index 0000000..6803318 --- /dev/null +++ b/pkg/errortracking/noop.go @@ -0,0 +1,37 @@ +package errortracking + +import "context" + +// NoOpProvider is a no-op implementation of the Provider interface +// Used when error tracking is disabled +type NoOpProvider struct{} + +// NewNoOpProvider creates a new NoOp provider +func NewNoOpProvider() *NoOpProvider { + return &NoOpProvider{} +} + +// CaptureError does nothing +func (n *NoOpProvider) CaptureError(ctx context.Context, err error, severity Severity, extra map[string]interface{}) { + // No-op +} + +// CaptureMessage does nothing +func (n *NoOpProvider) CaptureMessage(ctx context.Context, message string, severity Severity, extra map[string]interface{}) { + // No-op +} + +// CapturePanic does nothing +func (n *NoOpProvider) CapturePanic(ctx context.Context, recovered interface{}, stackTrace []byte, extra map[string]interface{}) { + // No-op +} + +// Flush does nothing and returns true +func (n *NoOpProvider) Flush(timeout int) bool { + return true +} + +// Close does nothing +func (n *NoOpProvider) Close() error { + return nil +} diff --git a/pkg/errortracking/sentry.go b/pkg/errortracking/sentry.go new file mode 100644 index 0000000..cce7e66 --- /dev/null +++ b/pkg/errortracking/sentry.go @@ -0,0 +1,154 @@ +package errortracking + +import ( + "context" + "fmt" + "time" + + "github.com/getsentry/sentry-go" +) + +// SentryProvider implements the Provider interface using Sentry +type SentryProvider struct { + hub *sentry.Hub +} + +// SentryConfig holds the configuration for Sentry +type SentryConfig struct { + DSN string + Environment string + Release string + Debug bool + SampleRate float64 + TracesSampleRate float64 +} + +// NewSentryProvider creates a new Sentry provider +func NewSentryProvider(config SentryConfig) (*SentryProvider, error) { + err := sentry.Init(sentry.ClientOptions{ + Dsn: config.DSN, + Environment: config.Environment, + Release: config.Release, + Debug: config.Debug, + AttachStacktrace: true, + SampleRate: config.SampleRate, + TracesSampleRate: config.TracesSampleRate, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize Sentry: %w", err) + } + + return &SentryProvider{ + hub: sentry.CurrentHub(), + }, nil +} + +// CaptureError captures an error with the given severity and additional context +func (s *SentryProvider) CaptureError(ctx context.Context, err error, severity Severity, extra map[string]interface{}) { + if err == nil { + return + } + + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = s.hub + } + + event := sentry.NewEvent() + event.Level = s.convertSeverity(severity) + event.Message = err.Error() + event.Exception = []sentry.Exception{ + { + Value: err.Error(), + Type: fmt.Sprintf("%T", err), + Stacktrace: sentry.ExtractStacktrace(err), + }, + } + + if extra != nil { + event.Extra = extra + } + + hub.CaptureEvent(event) +} + +// CaptureMessage captures a message with the given severity and additional context +func (s *SentryProvider) CaptureMessage(ctx context.Context, message string, severity Severity, extra map[string]interface{}) { + if message == "" { + return + } + + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = s.hub + } + + event := sentry.NewEvent() + event.Level = s.convertSeverity(severity) + event.Message = message + + if extra != nil { + event.Extra = extra + } + + hub.CaptureEvent(event) +} + +// CapturePanic captures a panic with stack trace +func (s *SentryProvider) CapturePanic(ctx context.Context, recovered interface{}, stackTrace []byte, extra map[string]interface{}) { + if recovered == nil { + return + } + + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = s.hub + } + + event := sentry.NewEvent() + event.Level = sentry.LevelError + event.Message = fmt.Sprintf("Panic: %v", recovered) + event.Exception = []sentry.Exception{ + { + Value: fmt.Sprintf("%v", recovered), + Type: "panic", + }, + } + + if extra != nil { + event.Extra = extra + } + + if stackTrace != nil { + event.Extra["stack_trace"] = string(stackTrace) + } + + hub.CaptureEvent(event) +} + +// Flush waits for all events to be sent (useful for graceful shutdown) +func (s *SentryProvider) Flush(timeout int) bool { + return sentry.Flush(time.Duration(timeout) * time.Second) +} + +// Close closes the provider and releases resources +func (s *SentryProvider) Close() error { + sentry.Flush(2 * time.Second) + return nil +} + +// convertSeverity converts our Severity to Sentry's Level +func (s *SentryProvider) convertSeverity(severity Severity) sentry.Level { + switch severity { + case SeverityError: + return sentry.LevelError + case SeverityWarning: + return sentry.LevelWarning + case SeverityInfo: + return sentry.LevelInfo + case SeverityDebug: + return sentry.LevelDebug + default: + return sentry.LevelError + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index a12c522..60c0ecc 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,15 +1,18 @@ package logger import ( + "context" "fmt" "log" "os" "runtime/debug" + "github.com/bitechdev/ResolveSpec/pkg/errortracking" "go.uber.org/zap" ) var Logger *zap.SugaredLogger +var errorTracker errortracking.Provider func Init(dev bool) { @@ -49,6 +52,28 @@ func UpdateLogger(config *zap.Config) { Info("ResolveSpec Logger initialized") } +// InitErrorTracking initializes the error tracking provider +func InitErrorTracking(provider errortracking.Provider) { + errorTracker = provider + if errorTracker != nil { + Info("Error tracking initialized") + } +} + +// GetErrorTracker returns the current error tracking provider +func GetErrorTracker() errortracking.Provider { + return errorTracker +} + +// CloseErrorTracking flushes and closes the error tracking provider +func CloseErrorTracking() error { + if errorTracker != nil { + errorTracker.Flush(5) + return errorTracker.Close() + } + return nil +} + func Info(template string, args ...interface{}) { if Logger == nil { log.Printf(template, args...) @@ -58,19 +83,35 @@ func Info(template string, args ...interface{}) { } func Warn(template string, args ...interface{}) { + message := fmt.Sprintf(template, args...) if Logger == nil { - log.Printf(template, args...) - return + log.Printf("%s", message) + } else { + Logger.Warnw(message, "process_id", os.Getpid()) + } + + // Send to error tracker + if errorTracker != nil { + errorTracker.CaptureMessage(context.Background(), message, errortracking.SeverityWarning, map[string]interface{}{ + "process_id": os.Getpid(), + }) } - Logger.Warnw(fmt.Sprintf(template, args...), "process_id", os.Getpid()) } func Error(template string, args ...interface{}) { + message := fmt.Sprintf(template, args...) if Logger == nil { - log.Printf(template, args...) - return + log.Printf("%s", message) + } else { + Logger.Errorw(message, "process_id", os.Getpid()) + } + + // Send to error tracker + if errorTracker != nil { + errorTracker.CaptureMessage(context.Background(), message, errortracking.SeverityError, map[string]interface{}{ + "process_id": os.Getpid(), + }) } - Logger.Errorw(fmt.Sprintf(template, args...), "process_id", os.Getpid()) } func Debug(template string, args ...interface{}) { @@ -84,7 +125,7 @@ func Debug(template string, args ...interface{}) { // CatchPanic - Handle panic func CatchPanicCallback(location string, cb func(err any)) { if err := recover(); err != nil { - // callstack := debug.Stack() + callstack := debug.Stack() if Logger != nil { Error("Panic in %s : %v", location, err) @@ -93,14 +134,13 @@ func CatchPanicCallback(location string, cb func(err any)) { debug.PrintStack() } - // push to sentry - // hub := sentry.CurrentHub() - // if hub != nil { - // evtID := hub.Recover(err) - // if evtID != nil { - // sentry.Flush(time.Second * 2) - // } - // } + // Send to error tracker + if errorTracker != nil { + errorTracker.CapturePanic(context.Background(), err, callstack, map[string]interface{}{ + "location": location, + "process_id": os.Getpid(), + }) + } if cb != nil { cb(err) @@ -125,5 +165,14 @@ func CatchPanic(location string) { func HandlePanic(methodName string, r any) error { stack := debug.Stack() Error("Panic in %s: %v\nStack trace:\n%s", methodName, r, string(stack)) + + // Send to error tracker + if errorTracker != nil { + errorTracker.CapturePanic(context.Background(), r, stack, map[string]interface{}{ + "method": methodName, + "process_id": os.Getpid(), + }) + } + return fmt.Errorf("panic in %s: %v", methodName, r) }