diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index df90e94..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(go build:*)", - "Bash(go test:*)", - "Bash(git add:*)", - "Bash(git commit:*)" - ] - } -} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 91a7090..34e117d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Lint on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: golangci: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da5c8e0..f59da3b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Tests on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: test: diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c2c6a9f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,15 @@ +linters: + enable-all: false + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck diff --git a/CHANGELOG.md b/CHANGELOG.md index 52bd408..a4659ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.4] - 2026-03-22 + +### Fixed + +- `Scope.ApplyToEntry` no longer mutates the caller's `Metadata` map when the scope has a user but no scope-level metadata +- `otelexport`: completed span trace IDs are now correctly preserved; an ambient active span in the exporter context can no longer override them +- `retry.Do` now drains the response body and returns an error when retries are exhausted on a retryable HTTP status (was returning `nil` error with an open body) +- `tryExtractPkgErrorsStack` no longer panics when an error interface holds a nil concrete pointer +- Client-level event processor slice is now safely copied under lock before iteration, preventing a data race with concurrent `AddEventProcessor` calls +- `Client.Options()` Tags map is now copied inside the read lock, preventing a race if the caller mutates the original map concurrently +- `EnvironmentIntegration` processor copies the metadata map before adding the `runtime` key instead of mutating the entry's existing map +- `Hub.Init` flush timeout now correctly uses `client.Options().FlushTimeout` instead of a hardcoded value + +### Changed + +- `Client` log methods (`Debug`, `Info`, `Warn`, `Error`, `Critical`) now return `EventID` instead of `error`, consistent with the `Hub` API +- `ClientOptions.AttachStacktrace` changed from `bool` to `*bool`; use the new `Bool(v)` helper to set it explicitly and distinguish `false` from the zero value +- `retry.Do` returns an error (instead of `(resp, nil)`) when the maximum retry count is exhausted on a retryable HTTP status + +### Removed + +- `Scope.SetTags` and `Scope.SetMetadata` removed (use `SetTag` and pass metadata directly to log methods) +- `HasHubOnContext` removed (use `GetHubFromContext(ctx) != nil` directly) + ## [0.1.0] - 2026-01-13 ### Added @@ -28,4 +52,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Quick start guide - Framework integrations guide +[0.8.4]: https://github.com/logtide-dev/logtide-sdk-go/releases/tag/v0.8.4 [0.1.0]: https://github.com/logtide-dev/logtide-sdk-go/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 219342d..6d0933f 100644 --- a/README.md +++ b/README.md @@ -12,26 +12,26 @@
- Official Go SDK for LogTide with automatic batching, retry logic, circuit breaker, OpenTelemetry integration, and production-ready features. + Official Go SDK for LogTide — structured logging with automatic batching, retry, circuit breaker, and OpenTelemetry integration.
--- ## Features -- **Leveled Logging** - Debug, Info, Warn, Error, Critical methods -- **Automatic Batching** - Configurable batch size and flush interval -- **Retry Logic** - Exponential backoff with jitter -- **Circuit Breaker** - Prevents cascading failures -- **Graceful Shutdown** - Flushes buffered logs on Close() -- **Context Support** - Respects context cancellation -- **OpenTelemetry Integration** - Automatic trace ID extraction -- **Production Ready** - Thread-safe, well-tested (~87% coverage) +- **Leveled logging** — Debug, Info, Warn, Error, Critical, CaptureError +- **Hub / Scope model** — per-request context isolation with breadcrumbs, tags, and user metadata +- **Automatic batching** — configurable batch size and flush interval +- **Retry with backoff** — exponential backoff with jitter +- **Circuit breaker** — prevents cascading failures +- **OpenTelemetry** — trace/span IDs extracted automatically; span exporter included +- **net/http middleware** — per-request scope isolation out of the box +- **Thread-safe** — safe for concurrent use ## Requirements -- Go 1.21 or later -- LogTide account and API key +- Go 1.23 or later +- A LogTide account and DSN ## Installation @@ -39,8 +39,12 @@ go get github.com/logtide-dev/logtide-sdk-go ``` +--- + ## Quick Start +### Global singleton (recommended for most apps) + ```go package main @@ -50,211 +54,242 @@ import ( ) func main() { - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("my-service"), - ) - defer client.Close() - - client.Info(context.Background(), "Hello LogTide!", nil) + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key@api.logtide.dev", + Service: "my-service", + Environment: "production", + Release: "v1.2.3", + }) + defer flush() + + logtide.Info(context.Background(), "Hello LogTide!", nil) + logtide.Error(context.Background(), "Something went wrong", map[string]any{ + "user_id": 42, + }) } ``` -**That's it!** See [Quick Start Guide](./docs/QUICKSTART.md) for detailed tutorial. +### Explicit client + +```go +opts := logtide.NewClientOptions() +opts.DSN = "https://lp_your_api_key@api.logtide.dev" +opts.Service = "my-service" + +client, err := logtide.NewClient(opts) +if err != nil { + log.Fatal(err) +} +defer client.Close() + +id := client.Info(context.Background(), "Hello", nil) +fmt.Println("event id:", id) +``` --- -## Documentation +## DSN format -Complete documentation is available in the [docs](./docs) directory: +``` +https://{api_key}@{host} +``` -- **[Installation Guide](./docs/INSTALLATION.md)** - Detailed installation instructions, API keys, troubleshooting -- **[Quick Start Guide](./docs/QUICKSTART.md)** - Tutorial with patterns and best practices -- **[Framework Integrations](./docs/INTEGRATIONS.md)** - Gin, Echo, Chi, Fiber, Standard Library, OpenTelemetry +Example: `https://lp_abc123@api.logtide.dev` --- -## Configuration Options - -Customize the client behavior: +## Configuration ```go -client, err := logtide.New( - // Required - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("my-service"), - - // Optional customization - logtide.WithBaseURL("https://api.logtide.dev"), - logtide.WithBatchSize(100), // Max logs per batch - logtide.WithFlushInterval(5*time.Second), // Flush interval - logtide.WithTimeout(30*time.Second), // HTTP timeout - logtide.WithRetry(3, 1*time.Second, 60*time.Second), // Max retries, min/max backoff - logtide.WithCircuitBreaker(5, 30*time.Second), // Failure threshold, timeout -) +opts := logtide.NewClientOptions() +opts.DSN = "https://lp_abc@api.logtide.dev" +opts.Service = "my-service" // required +opts.Release = "v1.2.3" +opts.Environment = "production" +opts.Tags = map[string]string{"region": "eu-west-1"} +opts.BatchSize = 100 // entries per HTTP batch +opts.FlushInterval = 5 * time.Second +opts.FlushTimeout = 10 * time.Second +opts.MaxRetries = 3 +opts.RetryMinBackoff = 1 * time.Second +opts.RetryMaxBackoff = 60 * time.Second +opts.CircuitBreakerThreshold = 5 // consecutive failures before open +opts.CircuitBreakerTimeout = 30 * time.Second +opts.AttachStacktrace = logtide.Bool(true) ``` -**Defaults:** -- Base URL: `https://api.logtide.dev` -- Batch Size: 100 logs -- Flush Interval: 5 seconds -- Timeout: 30 seconds -- Max Retries: 3 attempts with exponential backoff -- Circuit Breaker: Opens after 5 failures for 30 seconds - --- -## Logging Methods +## Logging -### Basic Logging +All log methods return the `EventID` assigned to the entry, or `""` if the entry was dropped. ```go ctx := context.Background() -client.Debug(ctx, "Debug message", nil) -client.Info(ctx, "Info message", map[string]any{"userId": 123}) -client.Warn(ctx, "Warning message", nil) -client.Error(ctx, "Error message", map[string]any{"custom": "data"}) -client.Critical(ctx, "Critical message", nil) -``` - -### With Metadata +client.Debug(ctx, "cache miss", map[string]any{"key": "user:42"}) +client.Info(ctx, "request handled", map[string]any{"status": 200, "ms": 12}) +client.Warn(ctx, "rate limit approaching", nil) +client.Error(ctx, "db query failed", map[string]any{"query": "SELECT ..."}) +client.Critical(ctx, "out of memory", nil) -```go -client.Info(ctx, "User logged in", map[string]any{ - "userId": 123, - "email": "user@example.com", - "ip": "192.168.1.1", - "userAgent": "Mozilla/5.0...", -}) +// Capture an error with full stack trace +if err := doSomething(); err != nil { + client.CaptureError(ctx, err, map[string]any{"op": "doSomething"}) +} ``` --- -## OpenTelemetry Integration +## Hub & Scope -Trace IDs are automatically extracted from context: +The Hub/Scope model lets you attach contextual data (tags, breadcrumbs, user info, trace context) to all log entries within a logical unit of work. ```go -ctx, span := tracer.Start(ctx, "operation") -defer span.End() +// Configure the global scope +logtide.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("region", "eu-west-1") + s.SetUser(logtide.User{ID: "u123", Email: "alice@example.com"}) +}) -// trace_id and span_id automatically included! -client.Info(ctx, "Processing", metadata) -``` +// Per-request isolation via PushScope / PopScope +logtide.PushScope() +defer logtide.PopScope() + +logtide.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("request_id", requestID) + s.AddBreadcrumb(&logtide.Breadcrumb{ + Category: "auth", + Message: "user authenticated", + Level: logtide.LevelInfo, + Timestamp: time.Now(), + }, nil) +}) -See [examples/otel](./examples/otel) for complete example. +logtide.Info(ctx, "processing order", nil) // includes request_id tag + breadcrumb +``` --- -## Error Handling +## net/http middleware ```go -err := client.Info(ctx, "message", nil) -if err != nil { - switch { - case errors.Is(err, logtide.ErrClientClosed): - // Client was closed - case errors.Is(err, logtide.ErrCircuitOpen): - // Circuit breaker is open (too many failures) - case errors.Is(err, logtide.ErrInvalidAPIKey): - // Invalid API key - default: - // Handle other errors - } -} +import lnethttp "github.com/logtide-dev/logtide-sdk-go/integrations/nethttp" + +http.Handle("/", lnethttp.Middleware(myHandler)) ``` +The middleware automatically: +- clones the Hub for each request (scope isolation) +- sets `http.method`, `http.url`, `http.host`, `http.client_ip` tags +- parses the `Traceparent` header and stores trace/span IDs on the scope +- adds request and response breadcrumbs + --- -## Framework Integration +## OpenTelemetry -Quick integration examples (full code in [docs/INTEGRATIONS.md](./docs/INTEGRATIONS.md)): +### Automatic trace context extraction -### Gin +Trace and span IDs are extracted automatically from any active OTel span in the context: ```go -r.Use(LogtideMiddleware(client)) +ctx, span := tracer.Start(ctx, "process-order") +defer span.End() + +// trace_id and span_id are included automatically +client.Info(ctx, "order processed", map[string]any{"order_id": 99}) ``` -### Echo +### Span exporter + +Export completed spans to LogTide: ```go -e.Use(LogtideMiddleware(client)) -``` +import "github.com/logtide-dev/logtide-sdk-go/integrations/otelexport" -### Standard Library +integration := otelexport.New() -```go -handler := LoggingMiddleware(client, mux) -http.ListenAndServe(":8080", handler) +flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_abc@api.logtide.dev", + Service: "my-service", + Integrations: func(defaults []logtide.Integration) []logtide.Integration { + return append(defaults, integration) + }, +}) +defer flush() + +tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(integration.Exporter()), +) ``` --- -## Examples - -Complete working examples with full source code: +## Flush & shutdown -| Example | Description | Link | -|---------|-------------|------| -| **Basic** | Simple usage with all log levels | [examples/basic](./examples/basic) | -| **Gin** | Gin framework middleware integration | [examples/gin](./examples/gin) | -| **Echo** | Echo framework middleware integration | [examples/echo](./examples/echo) | -| **Standard Library** | net/http middleware | [examples/stdlib](./examples/stdlib) | -| **OpenTelemetry** | Distributed tracing integration | [examples/otel](./examples/otel) | +```go +// Flush with deadline +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() +client.Flush(ctx) -Each example includes a README with running instructions. +// Close flushes and releases all resources +client.Close() +``` --- -## Key Features Explained +## BeforeSend hook -### Automatic Batching +Inspect or drop entries before they are sent: -Logs are automatically batched for optimal performance: -- Batches flush when size limit is reached (default: 100 logs) -- Batches flush on interval (default: 5 seconds) -- Manual flush with `client.Flush(ctx)` -- All pending logs flushed on `client.Close()` +```go +opts.BeforeSend = func(entry *logtide.LogEntry, hint *logtide.EventHint) *logtide.LogEntry { + // drop health-check noise + if entry.Message == "health check" { + return nil + } + return entry +} +``` -### Circuit Breaker +--- -Prevents cascading failures when the logging service is unavailable: -- Opens after consecutive failures (default: 5) -- Allows test request after timeout (default: 30s) -- Automatically closes when service recovers +## Testing -### Performance +Use `NoopTransport` to silence all output in tests: -- **Non-blocking** - Logging doesn't block your application -- **Automatic batching** - Reduces HTTP overhead -- **Connection pooling** - Reuses HTTP connections -- **Thread-safe** - Safe for concurrent use -- **Context-aware** - Respects cancellation +```go +client, _ := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, +}) +``` --- -## API Reference - -Full API documentation with godoc: +## Examples -- **Online:** [pkg.go.dev/github.com/logtide-dev/logtide-sdk-go](https://pkg.go.dev/github.com/logtide-dev/logtide-sdk-go) -- **Local:** Run `godoc -http=:6060` and visit http://localhost:6060/pkg/github.com/logtide-dev/logtide-sdk-go/ +| Example | Description | +|---------|-------------| +| [examples/basic](./examples/basic) | All log levels, metadata, CaptureError | +| [examples/gin](./examples/gin) | Gin framework integration | +| [examples/echo](./examples/echo) | Echo framework integration | +| [examples/stdlib](./examples/stdlib) | Standard library net/http | +| [examples/otel](./examples/otel) | OpenTelemetry distributed tracing | --- -## Contributing +## API reference -Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +- **Online:** [pkg.go.dev/github.com/logtide-dev/logtide-sdk-go](https://pkg.go.dev/github.com/logtide-dev/logtide-sdk-go) +- **Local:** `godoc -http=:6060` -## License +## Contributing -MIT License - see [LICENSE](LICENSE) for details. +See [CONTRIBUTING.md](CONTRIBUTING.md). -## Links +## License -- [LogTide Website](https://logtide.dev) -- [Documentation](https://logtide.dev/docs/sdks/go/) -- [API Reference](https://pkg.go.dev/github.com/logtide-dev/logtide-sdk-go) -- [GitHub Issues](https://github.com/logtide-dev/logtide-sdk-go/issues) +MIT — see [LICENSE](LICENSE). diff --git a/batch.go b/batch.go deleted file mode 100644 index 300e06f..0000000 --- a/batch.go +++ /dev/null @@ -1,180 +0,0 @@ -package logtide - -import ( - "context" - "sync" - "time" -) - -// FlushFunc is a function that flushes a batch of logs. -type FlushFunc func(ctx context.Context, logs []Log) error - -// Batcher handles automatic batching of logs with size and time-based flushing. -type Batcher struct { - mu sync.Mutex - logs []Log - maxSize int - flushInterval time.Duration - flushFunc FlushFunc - - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - flushChan chan struct{} - stopped bool -} - -// BatcherConfig holds the configuration for a batcher. -type BatcherConfig struct { - MaxSize int - FlushInterval time.Duration - FlushFunc FlushFunc -} - -// DefaultBatcherConfig returns the default batcher configuration. -func DefaultBatcherConfig(flushFunc FlushFunc) *BatcherConfig { - return &BatcherConfig{ - MaxSize: 100, - FlushInterval: 5 * time.Second, - FlushFunc: flushFunc, - } -} - -// NewBatcher creates a new batcher with the specified configuration. -func NewBatcher(config *BatcherConfig) *Batcher { - if config == nil { - panic("batcher config cannot be nil") - } - if config.FlushFunc == nil { - panic("flush function cannot be nil") - } - if config.MaxSize <= 0 { - config.MaxSize = 100 - } - if config.FlushInterval <= 0 { - config.FlushInterval = 5 * time.Second - } - - ctx, cancel := context.WithCancel(context.Background()) - - b := &Batcher{ - logs: make([]Log, 0, config.MaxSize), - maxSize: config.MaxSize, - flushInterval: config.FlushInterval, - flushFunc: config.FlushFunc, - ctx: ctx, - cancel: cancel, - flushChan: make(chan struct{}, 1), - } - - // Start background flusher - b.wg.Add(1) - go b.backgroundFlusher() - - return b -} - -// Add adds a log to the batch. If the batch size reaches maxSize, it triggers a flush. -func (b *Batcher) Add(log Log) error { - b.mu.Lock() - defer b.mu.Unlock() - - if b.stopped { - return ErrClientClosed - } - - // Add log to batch - b.logs = append(b.logs, log) - - // Check if we need to flush based on size - if len(b.logs) >= b.maxSize { - // Trigger immediate flush - select { - case b.flushChan <- struct{}{}: - default: - // Flush already pending - } - } - - return nil -} - -// Flush immediately flushes all pending logs. -func (b *Batcher) Flush(ctx context.Context) error { - b.mu.Lock() - - if len(b.logs) == 0 { - b.mu.Unlock() - return nil - } - - // Take logs and reset batch - logs := make([]Log, len(b.logs)) - copy(logs, b.logs) - b.logs = b.logs[:0] // Reset slice but keep capacity - - b.mu.Unlock() - - // Flush logs - return b.flushFunc(ctx, logs) -} - -// Stop stops the batcher and flushes any remaining logs. -func (b *Batcher) Stop() error { - b.mu.Lock() - if b.stopped { - b.mu.Unlock() - return nil - } - b.stopped = true - b.mu.Unlock() - - // Cancel background goroutine - b.cancel() - - // Wait for background goroutine to finish - b.wg.Wait() - - // Flush remaining logs - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - return b.Flush(ctx) -} - -// backgroundFlusher runs in a goroutine and periodically flushes logs. -func (b *Batcher) backgroundFlusher() { - defer b.wg.Done() - - ticker := time.NewTicker(b.flushInterval) - defer ticker.Stop() - - for { - select { - case <-b.ctx.Done(): - // Batcher stopped - return - - case <-ticker.C: - // Time-based flush - if err := b.Flush(b.ctx); err != nil { - // TODO: Consider adding error callback - // For now, silently continue - } - - case <-b.flushChan: - // Size-based flush - if err := b.Flush(b.ctx); err != nil { - // TODO: Consider adding error callback - // For now, silently continue - } - } - } -} - -// Size returns the current number of logs in the batch. -func (b *Batcher) Size() int { - b.mu.Lock() - defer b.mu.Unlock() - return len(b.logs) -} diff --git a/batch_test.go b/batch_test.go deleted file mode 100644 index a109186..0000000 --- a/batch_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package logtide - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestBatcherSizeBasedFlushing(t *testing.T) { - var flushedCount int32 - var mu sync.Mutex - var allLogs []Log - - flushFunc := func(ctx context.Context, logs []Log) error { - atomic.AddInt32(&flushedCount, 1) - mu.Lock() - allLogs = append(allLogs, logs...) - mu.Unlock() - return nil - } - - config := &BatcherConfig{ - MaxSize: 3, - FlushInterval: 1 * time.Minute, // Long interval to test size-based flushing - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - defer batcher.Stop() - - // Add logs - for i := 0; i < 10; i++ { - err := batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "test message", - }) - if err != nil { - t.Fatalf("Add() error = %v", err) - } - } - - // Wait for flushes to complete - time.Sleep(300 * time.Millisecond) - - // Stop will flush remaining logs - batcher.Stop() - - mu.Lock() - totalLogs := len(allLogs) - mu.Unlock() - - // All 10 logs should be flushed eventually - if totalLogs != 10 { - t.Errorf("total flushed logs = %d, want 10", totalLogs) - } -} - -func TestBatcherTimeBasedFlushing(t *testing.T) { - var flushedCount int32 - - flushFunc := func(ctx context.Context, logs []Log) error { - atomic.AddInt32(&flushedCount, 1) - return nil - } - - config := &BatcherConfig{ - MaxSize: 100, - FlushInterval: 100 * time.Millisecond, - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - defer batcher.Stop() - - // Add a few logs (not enough to trigger size-based flush) - for i := 0; i < 5; i++ { - batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "test message", - }) - } - - // Wait for time-based flush - time.Sleep(150 * time.Millisecond) - - count := atomic.LoadInt32(&flushedCount) - if count < 1 { - t.Errorf("flushed count = %d, want >= 1", count) - } -} - -func TestBatcherManualFlush(t *testing.T) { - var flushedLogs []Log - var mu sync.Mutex - - flushFunc := func(ctx context.Context, logs []Log) error { - mu.Lock() - flushedLogs = append(flushedLogs, logs...) - mu.Unlock() - return nil - } - - config := &BatcherConfig{ - MaxSize: 100, - FlushInterval: 1 * time.Minute, - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - defer batcher.Stop() - - // Add logs - for i := 0; i < 5; i++ { - batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "test message", - }) - } - - // Manual flush - ctx := context.Background() - err := batcher.Flush(ctx) - if err != nil { - t.Fatalf("Flush() error = %v", err) - } - - mu.Lock() - count := len(flushedLogs) - mu.Unlock() - - if count != 5 { - t.Errorf("flushed logs = %d, want 5", count) - } - - // Batch should be empty now - if batcher.Size() != 0 { - t.Errorf("batch size after flush = %d, want 0", batcher.Size()) - } -} - -func TestBatcherStop(t *testing.T) { - var flushedLogs []Log - var mu sync.Mutex - - flushFunc := func(ctx context.Context, logs []Log) error { - mu.Lock() - flushedLogs = append(flushedLogs, logs...) - mu.Unlock() - return nil - } - - config := &BatcherConfig{ - MaxSize: 100, - FlushInterval: 1 * time.Minute, - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - - // Add logs - for i := 0; i < 10; i++ { - batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "test message", - }) - } - - // Stop should flush remaining logs - err := batcher.Stop() - if err != nil { - t.Fatalf("Stop() error = %v", err) - } - - mu.Lock() - count := len(flushedLogs) - mu.Unlock() - - if count != 10 { - t.Errorf("flushed logs on stop = %d, want 10", count) - } - - // Adding logs after stop should fail - err = batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "test message", - }) - if err != ErrClientClosed { - t.Errorf("Add() after stop error = %v, want %v", err, ErrClientClosed) - } -} - -func TestBatcherConcurrentAdds(t *testing.T) { - var totalFlushed int32 - - flushFunc := func(ctx context.Context, logs []Log) error { - atomic.AddInt32(&totalFlushed, int32(len(logs))) - return nil - } - - config := &BatcherConfig{ - MaxSize: 50, - FlushInterval: 100 * time.Millisecond, - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - defer batcher.Stop() - - // Concurrent adds - var wg sync.WaitGroup - numGoroutines := 10 - logsPerGoroutine := 20 - - for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < logsPerGoroutine; j++ { - batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "concurrent message", - }) - } - }() - } - - wg.Wait() - - // Stop and flush - batcher.Stop() - - total := atomic.LoadInt32(&totalFlushed) - expected := int32(numGoroutines * logsPerGoroutine) - - if total != expected { - t.Errorf("total flushed logs = %d, want %d", total, expected) - } -} - -func TestBatcherEmptyFlush(t *testing.T) { - called := false - - flushFunc := func(ctx context.Context, logs []Log) error { - called = true - return nil - } - - config := &BatcherConfig{ - MaxSize: 100, - FlushInterval: 1 * time.Minute, - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - defer batcher.Stop() - - // Flush empty batch - err := batcher.Flush(context.Background()) - if err != nil { - t.Fatalf("Flush() error = %v", err) - } - - if called { - t.Error("flush function should not be called for empty batch") - } -} - -func TestBatcherSize(t *testing.T) { - flushFunc := func(ctx context.Context, logs []Log) error { - return nil - } - - config := &BatcherConfig{ - MaxSize: 100, - FlushInterval: 1 * time.Minute, - FlushFunc: flushFunc, - } - - batcher := NewBatcher(config) - defer batcher.Stop() - - if batcher.Size() != 0 { - t.Errorf("initial size = %d, want 0", batcher.Size()) - } - - // Add logs - for i := 0; i < 5; i++ { - batcher.Add(Log{ - Time: time.Now(), - Service: "test", - Level: LogLevelInfo, - Message: "test", - }) - } - - if batcher.Size() != 5 { - t.Errorf("size after adding 5 logs = %d, want 5", batcher.Size()) - } -} diff --git a/circuit_breaker.go b/circuit_breaker.go deleted file mode 100644 index 4b9f8a4..0000000 --- a/circuit_breaker.go +++ /dev/null @@ -1,156 +0,0 @@ -package logtide - -import ( - "sync" - "time" -) - -// CircuitState represents the state of the circuit breaker. -type CircuitState int - -const ( - // CircuitClosed means requests are allowed through. - CircuitClosed CircuitState = iota - - // CircuitOpen means requests are blocked. - CircuitOpen - - // CircuitHalfOpen means the circuit is testing if the service has recovered. - CircuitHalfOpen -) - -// String returns the string representation of the circuit state. -func (s CircuitState) String() string { - switch s { - case CircuitClosed: - return "closed" - case CircuitOpen: - return "open" - case CircuitHalfOpen: - return "half-open" - default: - return "unknown" - } -} - -// CircuitBreaker implements the circuit breaker pattern to prevent cascading failures. -type CircuitBreaker struct { - mu sync.RWMutex - - // Configuration - failureThreshold int // Number of consecutive failures before opening - timeout time.Duration // Time to wait before transitioning to half-open - - // State - state CircuitState - failures int // Consecutive failure count - lastFailureTime time.Time // Time of last failure - lastStateChange time.Time // Time of last state change -} - -// CircuitBreakerConfig holds the configuration for a circuit breaker. -type CircuitBreakerConfig struct { - FailureThreshold int - Timeout time.Duration -} - -// DefaultCircuitBreakerConfig returns the default circuit breaker configuration. -func DefaultCircuitBreakerConfig() *CircuitBreakerConfig { - return &CircuitBreakerConfig{ - FailureThreshold: 5, - Timeout: 30 * time.Second, - } -} - -// NewCircuitBreaker creates a new circuit breaker with the specified configuration. -func NewCircuitBreaker(config *CircuitBreakerConfig) *CircuitBreaker { - if config == nil { - config = DefaultCircuitBreakerConfig() - } - - return &CircuitBreaker{ - failureThreshold: config.FailureThreshold, - timeout: config.Timeout, - state: CircuitClosed, - lastStateChange: time.Now(), - } -} - -// Allow checks if a request is allowed based on the circuit breaker state. -func (cb *CircuitBreaker) Allow() error { - cb.mu.Lock() - defer cb.mu.Unlock() - - // Check if we should transition from open to half-open - if cb.state == CircuitOpen { - if time.Since(cb.lastStateChange) >= cb.timeout { - cb.state = CircuitHalfOpen - cb.lastStateChange = time.Now() - } else { - return ErrCircuitOpen - } - } - - return nil -} - -// RecordSuccess records a successful request. -func (cb *CircuitBreaker) RecordSuccess() { - cb.mu.Lock() - defer cb.mu.Unlock() - - // Reset failure count - cb.failures = 0 - - // If we were in half-open state, transition to closed - if cb.state == CircuitHalfOpen { - cb.state = CircuitClosed - cb.lastStateChange = time.Now() - } -} - -// RecordFailure records a failed request. -func (cb *CircuitBreaker) RecordFailure() { - cb.mu.Lock() - defer cb.mu.Unlock() - - cb.failures++ - cb.lastFailureTime = time.Now() - - // If we're in half-open state, a single failure trips the circuit - if cb.state == CircuitHalfOpen { - cb.state = CircuitOpen - cb.lastStateChange = time.Now() - return - } - - // Check if we've exceeded the failure threshold - if cb.failures >= cb.failureThreshold { - cb.state = CircuitOpen - cb.lastStateChange = time.Now() - } -} - -// State returns the current state of the circuit breaker. -func (cb *CircuitBreaker) State() CircuitState { - cb.mu.RLock() - defer cb.mu.RUnlock() - return cb.state -} - -// Failures returns the current consecutive failure count. -func (cb *CircuitBreaker) Failures() int { - cb.mu.RLock() - defer cb.mu.RUnlock() - return cb.failures -} - -// Reset resets the circuit breaker to the closed state. -func (cb *CircuitBreaker) Reset() { - cb.mu.Lock() - defer cb.mu.Unlock() - - cb.state = CircuitClosed - cb.failures = 0 - cb.lastStateChange = time.Now() -} diff --git a/circuit_breaker_test.go b/circuit_breaker_test.go deleted file mode 100644 index 5479333..0000000 --- a/circuit_breaker_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package logtide - -import ( - "errors" - "testing" - "time" -) - -func TestCircuitBreakerStateClosed(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 3, - Timeout: 100 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Initially closed - if cb.State() != CircuitClosed { - t.Errorf("initial state = %v, want %v", cb.State(), CircuitClosed) - } - - // Should allow requests - if err := cb.Allow(); err != nil { - t.Errorf("Allow() error = %v, want nil", err) - } - - // Record success - cb.RecordSuccess() - if cb.Failures() != 0 { - t.Errorf("failures = %d, want 0", cb.Failures()) - } -} - -func TestCircuitBreakerOpensAfterFailures(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 3, - Timeout: 100 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Record failures below threshold - cb.RecordFailure() - cb.RecordFailure() - - if cb.State() != CircuitClosed { - t.Errorf("state after 2 failures = %v, want %v", cb.State(), CircuitClosed) - } - if cb.Failures() != 2 { - t.Errorf("failures = %d, want 2", cb.Failures()) - } - - // Third failure should open the circuit - cb.RecordFailure() - - if cb.State() != CircuitOpen { - t.Errorf("state after 3 failures = %v, want %v", cb.State(), CircuitOpen) - } - if cb.Failures() != 3 { - t.Errorf("failures = %d, want 3", cb.Failures()) - } - - // Should not allow requests - err := cb.Allow() - if !errors.Is(err, ErrCircuitOpen) { - t.Errorf("Allow() error = %v, want %v", err, ErrCircuitOpen) - } -} - -func TestCircuitBreakerTransitionsToHalfOpen(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 2, - Timeout: 50 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Open the circuit - cb.RecordFailure() - cb.RecordFailure() - - if cb.State() != CircuitOpen { - t.Errorf("state = %v, want %v", cb.State(), CircuitOpen) - } - - // Wait for timeout - time.Sleep(60 * time.Millisecond) - - // Next allow should transition to half-open - err := cb.Allow() - if err != nil { - t.Errorf("Allow() error = %v, want nil", err) - } - - if cb.State() != CircuitHalfOpen { - t.Errorf("state after timeout = %v, want %v", cb.State(), CircuitHalfOpen) - } -} - -func TestCircuitBreakerHalfOpenSuccess(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 2, - Timeout: 50 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Open the circuit - cb.RecordFailure() - cb.RecordFailure() - - // Wait for timeout and transition to half-open - time.Sleep(60 * time.Millisecond) - cb.Allow() - - if cb.State() != CircuitHalfOpen { - t.Errorf("state = %v, want %v", cb.State(), CircuitHalfOpen) - } - - // Success in half-open should close the circuit - cb.RecordSuccess() - - if cb.State() != CircuitClosed { - t.Errorf("state after success = %v, want %v", cb.State(), CircuitClosed) - } - if cb.Failures() != 0 { - t.Errorf("failures = %d, want 0", cb.Failures()) - } -} - -func TestCircuitBreakerHalfOpenFailure(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 2, - Timeout: 50 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Open the circuit - cb.RecordFailure() - cb.RecordFailure() - - // Wait for timeout and transition to half-open - time.Sleep(60 * time.Millisecond) - cb.Allow() - - if cb.State() != CircuitHalfOpen { - t.Errorf("state = %v, want %v", cb.State(), CircuitHalfOpen) - } - - // Failure in half-open should re-open the circuit - cb.RecordFailure() - - if cb.State() != CircuitOpen { - t.Errorf("state after failure = %v, want %v", cb.State(), CircuitOpen) - } - - // Should not allow requests - err := cb.Allow() - if !errors.Is(err, ErrCircuitOpen) { - t.Errorf("Allow() error = %v, want %v", err, ErrCircuitOpen) - } -} - -func TestCircuitBreakerReset(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 2, - Timeout: 100 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Open the circuit - cb.RecordFailure() - cb.RecordFailure() - - if cb.State() != CircuitOpen { - t.Errorf("state = %v, want %v", cb.State(), CircuitOpen) - } - - // Reset - cb.Reset() - - if cb.State() != CircuitClosed { - t.Errorf("state after reset = %v, want %v", cb.State(), CircuitClosed) - } - if cb.Failures() != 0 { - t.Errorf("failures after reset = %d, want 0", cb.Failures()) - } - - // Should allow requests - if err := cb.Allow(); err != nil { - t.Errorf("Allow() error = %v, want nil", err) - } -} - -func TestCircuitBreakerSuccessResetsFailures(t *testing.T) { - config := &CircuitBreakerConfig{ - FailureThreshold: 3, - Timeout: 100 * time.Millisecond, - } - cb := NewCircuitBreaker(config) - - // Record some failures - cb.RecordFailure() - cb.RecordFailure() - - if cb.Failures() != 2 { - t.Errorf("failures = %d, want 2", cb.Failures()) - } - - // Success should reset failure count - cb.RecordSuccess() - - if cb.Failures() != 0 { - t.Errorf("failures after success = %d, want 0", cb.Failures()) - } - if cb.State() != CircuitClosed { - t.Errorf("state = %v, want %v", cb.State(), CircuitClosed) - } -} - -func TestCircuitStateString(t *testing.T) { - tests := []struct { - state CircuitState - want string - }{ - {CircuitClosed, "closed"}, - {CircuitOpen, "open"}, - {CircuitHalfOpen, "half-open"}, - {CircuitState(999), "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - got := tt.state.String() - if got != tt.want { - t.Errorf("CircuitState.String() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/client.go b/client.go index f3cd89b..a8dc8c7 100644 --- a/client.go +++ b/client.go @@ -2,199 +2,336 @@ package logtide import ( "context" + "errors" "fmt" - "net/http" + "math/rand" "sync" "time" - - internalhttp "github.com/logtide-dev/logtide-sdk-go/internal/http" ) -// Client is the LogTide SDK client for sending logs. +// sdkVersion is embedded in the User-Agent header and SDK metadata. +const sdkVersion = "0.8.4" + +// Client sends log entries to the LogTide ingest endpoint. +// Use NewClient for the explicit-lifecycle pattern, or Init + package-level +// functions for the global singleton pattern. +// +// Client is safe for concurrent use. type Client struct { - config *Config - httpClient *internalhttp.Client - batcher *Batcher - circuitBreaker *CircuitBreaker - retryConfig *RetryConfig + opts ClientOptions + dsn *DSN + serverName string + transport Transport + integrations []Integration + processors []EventProcessor mu sync.RWMutex closed bool } -// New creates a new LogTide client with the specified options. -func New(opts ...Option) (*Client, error) { - // Start with default config - config := DefaultConfig() - - // Apply options - for _, opt := range opts { - opt(config) +// NewClient creates and configures a Client from opts. +// +// It parses opts.DSN, constructs the default HTTPTransport (unless +// opts.Transport is set), installs integrations, and validates required fields. +func NewClient(opts ClientOptions) (*Client, error) { + if opts.Service == "" { + return nil, ErrServiceRequired } - // Validate config - if err := config.validate(); err != nil { - return nil, fmt.Errorf("invalid configuration: %w", err) + // Apply defaults for zero values. + defaults := NewClientOptions() + if opts.MaxBreadcrumbs <= 0 { + opts.MaxBreadcrumbs = defaults.MaxBreadcrumbs + } + if opts.SampleRate == 0 { + opts.SampleRate = defaults.SampleRate + } + if opts.BatchSize <= 0 { + opts.BatchSize = defaults.BatchSize + } + if opts.FlushInterval <= 0 { + opts.FlushInterval = defaults.FlushInterval + } + if opts.FlushTimeout <= 0 { + opts.FlushTimeout = defaults.FlushTimeout + } + if opts.MaxRetries <= 0 { + opts.MaxRetries = defaults.MaxRetries + } + if opts.RetryMinBackoff <= 0 { + opts.RetryMinBackoff = defaults.RetryMinBackoff + } + if opts.RetryMaxBackoff <= 0 { + opts.RetryMaxBackoff = defaults.RetryMaxBackoff + } + if opts.CircuitBreakerThreshold <= 0 { + opts.CircuitBreakerThreshold = defaults.CircuitBreakerThreshold + } + if opts.CircuitBreakerTimeout <= 0 { + opts.CircuitBreakerTimeout = defaults.CircuitBreakerTimeout + } + if opts.AttachStacktrace == nil { + opts.AttachStacktrace = defaults.AttachStacktrace } - // Create HTTP client - httpClient := internalhttp.NewClient(&internalhttp.Config{ - BaseURL: config.BaseURL, - APIKey: config.APIKey, - Timeout: config.Timeout, - }) + var dsn *DSN + if opts.Transport == nil { + // DSN is required when using the default transport. + if opts.DSN == "" { + return nil, fmt.Errorf("logtide: DSN is required (set ClientOptions.DSN or provide a custom Transport)") + } + var err error + dsn, err = ParseDSN(opts.DSN) + if err != nil { + return nil, err + } + } - // Create circuit breaker - circuitBreaker := NewCircuitBreaker(config.CircuitBreakerConfig) + c := &Client{ + opts: opts, + dsn: dsn, + serverName: resolveServerName(opts.ServerName), + } - // Create client - client := &Client{ - config: config, - httpClient: httpClient, - circuitBreaker: circuitBreaker, - retryConfig: config.RetryConfig, + if opts.Transport != nil { + c.transport = opts.Transport + } else { + c.transport = newHTTPTransport(dsn, opts) } - // Create batcher with flush function - batcherConfig := &BatcherConfig{ - MaxSize: config.BatchSize, - FlushInterval: config.FlushInterval, - FlushFunc: client.sendBatch, + setupIntegrations(c, opts) + return c, nil +} + +// Integrations returns the list of integrations installed on this client, +// in the order they were registered. The slice is a copy. +func (c *Client) Integrations() []Integration { + c.mu.RLock() + result := make([]Integration, len(c.integrations)) + copy(result, c.integrations) + c.mu.RUnlock() + return result +} + +// AddEventProcessor appends a processor to the client-level pipeline. +// Called by integrations from their Setup method. +func (c *Client) AddEventProcessor(p EventProcessor) { + c.mu.Lock() + c.processors = append(c.processors, p) + c.mu.Unlock() +} + +// Options returns a copy of the client's configuration. +// The Tags map is copied so callers cannot inadvertently mutate internal state. +func (c *Client) Options() ClientOptions { + c.mu.RLock() + opts := c.opts + if len(opts.Tags) > 0 { + tags := make(map[string]string, len(opts.Tags)) + for k, v := range opts.Tags { + tags[k] = v + } + opts.Tags = tags } - client.batcher = NewBatcher(batcherConfig) + c.mu.RUnlock() + return opts +} - return client, nil +// Debug captures a debug-level log entry. +// Returns the EventID assigned to the entry, or "" if it was dropped or the client is closed. +func (c *Client) Debug(ctx context.Context, message string, metadata map[string]any) EventID { + return c.log(ctx, LevelDebug, message, metadata) } -// Debug sends a debug-level log. -func (c *Client) Debug(ctx context.Context, message string, metadata map[string]interface{}) error { - return c.log(ctx, LogLevelDebug, message, metadata) +// Info captures an info-level log entry. +// Returns the EventID assigned to the entry, or "" if it was dropped or the client is closed. +func (c *Client) Info(ctx context.Context, message string, metadata map[string]any) EventID { + return c.log(ctx, LevelInfo, message, metadata) } -// Info sends an info-level log. -func (c *Client) Info(ctx context.Context, message string, metadata map[string]interface{}) error { - return c.log(ctx, LogLevelInfo, message, metadata) +// Warn captures a warn-level log entry. +// Returns the EventID assigned to the entry, or "" if it was dropped or the client is closed. +func (c *Client) Warn(ctx context.Context, message string, metadata map[string]any) EventID { + return c.log(ctx, LevelWarn, message, metadata) } -// Warn sends a warn-level log. -func (c *Client) Warn(ctx context.Context, message string, metadata map[string]interface{}) error { - return c.log(ctx, LogLevelWarn, message, metadata) +// Error captures an error-level log entry. +// Returns the EventID assigned to the entry, or "" if it was dropped or the client is closed. +func (c *Client) Error(ctx context.Context, message string, metadata map[string]any) EventID { + return c.log(ctx, LevelError, message, metadata) } -// Error sends an error-level log. -func (c *Client) Error(ctx context.Context, message string, metadata map[string]interface{}) error { - return c.log(ctx, LogLevelError, message, metadata) +// Critical captures a critical-level log entry. +// Returns the EventID assigned to the entry, or "" if it was dropped or the client is closed. +func (c *Client) Critical(ctx context.Context, message string, metadata map[string]any) EventID { + return c.log(ctx, LevelCritical, message, metadata) } -// Critical sends a critical-level log. -func (c *Client) Critical(ctx context.Context, message string, metadata map[string]interface{}) error { - return c.log(ctx, LogLevelCritical, message, metadata) +// CaptureError captures err as an error-level log entry with an attached +// stack trace. It serialises the full error chain via errors.Unwrap. +// Returns the EventID of the entry, or "" if it was dropped. +func (c *Client) CaptureError(ctx context.Context, err error, metadata map[string]any) EventID { + if err == nil { + return "" + } + + exceptions := extractExceptions(err, c.opts.AttachStacktrace != nil && *c.opts.AttachStacktrace) + + entry := &LogEntry{ + Level: LevelError, + Message: err.Error(), + Errors: exceptions, + } + + if metadata != nil { + entry.Metadata = metadata + } + + return c.captureEntry(ctx, entry, &EventHint{OriginalError: err}) } -// log creates and adds a log entry to the batcher. -func (c *Client) log(ctx context.Context, level LogLevel, message string, metadata map[string]interface{}) error { +// Flush blocks until all buffered entries are delivered or ctx is cancelled. +// Returns true if all entries were flushed before ctx expired. +func (c *Client) Flush(ctx context.Context) bool { c.mu.RLock() defer c.mu.RUnlock() + return c.transport.Flush(ctx) +} +// Close flushes pending entries and shuts down the transport. +// After Close(), log-level methods silently drop entries and return "". +func (c *Client) Close() { + c.mu.Lock() if c.closed { - return ErrClientClosed + c.mu.Unlock() + return } + c.closed = true + c.mu.Unlock() - // Create log entry - log := Log{ - Time: time.Now(), - Service: c.config.Service, - Level: level, - Message: message, - Metadata: metadata, - } + c.transport.Close() +} + +// --- internal --- - // Enrich with context (OpenTelemetry trace/span IDs) - enrichLogWithContext(ctx, &log) +func (c *Client) log(ctx context.Context, level Level, message string, metadata map[string]any) EventID { + c.mu.RLock() + closed := c.closed + c.mu.RUnlock() + if closed { + return "" + } - // Validate log - if err := validateLog(&log); err != nil { - return fmt.Errorf("invalid log: %w", err) + entry := &LogEntry{ + Level: level, + Message: message, + } + if len(metadata) > 0 { + entry.Metadata = metadata } - // Add to batcher - return c.batcher.Add(log) + return c.captureEntry(ctx, entry, nil) } -// sendBatch sends a batch of logs to the LogTide API. -func (c *Client) sendBatch(ctx context.Context, logs []Log) error { - // Validate batch - if err := validateBatch(logs); err != nil { - return fmt.Errorf("invalid batch: %w", err) +// captureEntry runs the full pipeline for a pre-built LogEntry and dispatches it. +// Returns the assigned EventID, or "" if the entry was dropped. +func (c *Client) captureEntry(ctx context.Context, entry *LogEntry, hint *EventHint) EventID { + // 1. Stamp mandatory fields. + entry.EventID = newEventID() + entry.Timestamp = time.Now() + entry.Service = c.opts.Service + entry.Release = c.opts.Release + entry.Environment = c.opts.Environment + entry.ServerName = c.serverName + + // 2. Enrich with trace context from OTel span or scope. + traceID, spanID := traceContextFromContext(ctx) + if entry.TraceID == "" { + entry.TraceID = traceID } - - // Check circuit breaker - if err := c.circuitBreaker.Allow(); err != nil { - return err + if entry.SpanID == "" { + entry.SpanID = spanID } - // Create request - req := &IngestRequest{ - Logs: logs, - } + // 3. Merge active scope. + if scope := scopeFromContextOrHub(ctx); scope != nil { + if entry = scope.ApplyToEntry(entry); entry == nil { + return "" + } - // Send with retry - resp, err := withRetry(ctx, c.retryConfig, func(ctx context.Context) (*http.Response, error) { - return c.httpClient.Post(ctx, "/api/v1/ingest", req) - }) + // Run scope-level processors. + // Copy the slice to avoid a data race when AddEventProcessor runs concurrently + // and append grows into the same backing array. + scope.mu.RLock() + procs := make([]EventProcessor, len(scope.eventProcessors)) + copy(procs, scope.eventProcessors) + scope.mu.RUnlock() + for _, p := range procs { + if entry = p(entry, hint); entry == nil { + return "" + } + } + } - // Record circuit breaker result - if err != nil || (resp != nil && resp.StatusCode >= 500) { - c.circuitBreaker.RecordFailure() - } else { - c.circuitBreaker.RecordSuccess() + // 4. Validate. + if err := validateEntry(entry); err != nil { + return "" } - if err != nil { - return fmt.Errorf("failed to send batch: %w", err) + // 5. Run client-level processors (integrations). + // Copy the slice to avoid a data race when AddEventProcessor runs concurrently + // and append grows into the same backing array. + c.mu.RLock() + procs := make([]EventProcessor, len(c.processors)) + copy(procs, c.processors) + c.mu.RUnlock() + for _, p := range procs { + if entry = p(entry, hint); entry == nil { + return "" + } } - // Check response status - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := internalhttp.ReadResponseBody(resp) - return &HTTPError{ - StatusCode: resp.StatusCode, - Message: fmt.Sprintf("unexpected status code: %d", resp.StatusCode), - Body: body, + // 6. Apply BeforeSend hook. + if c.opts.BeforeSend != nil { + if entry = c.opts.BeforeSend(entry, hint); entry == nil { + return "" } } - // Decode response - var ingestResp IngestResponse - if err := internalhttp.DecodeResponse(resp, &ingestResp); err != nil { - return fmt.Errorf("failed to decode response: %w", err) + // 7. Sample. + if c.opts.SampleRate < 1.0 && rand.Float64() > c.opts.SampleRate { + return "" } - return nil + // 8. Dispatch. + c.transport.Send(entry) + return entry.EventID } -// Flush immediately flushes all pending logs. -func (c *Client) Flush(ctx context.Context) error { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.closed { - return ErrClientClosed +// scopeFromContextOrHub resolves the active scope from context or current hub. +func scopeFromContextOrHub(ctx context.Context) *Scope { + if s := ScopeFromContext(ctx); s != nil { + return s } - - return c.batcher.Flush(ctx) + if h := GetHubFromContext(ctx); h != nil { + return h.Scope() + } + return nil } -// Close stops the client and flushes all pending logs. -func (c *Client) Close() error { - c.mu.Lock() - if c.closed { - c.mu.Unlock() - return nil +// extractExceptions serialises an error chain into a slice of Exception values. +func extractExceptions(err error, attachStack bool) []Exception { + var result []Exception + for err != nil { + ex := Exception{ + Type: fmt.Sprintf("%T", err), + Value: err.Error(), + } + if attachStack { + ex.Stacktrace = ExtractStacktrace(err) + } + result = append(result, ex) + err = errors.Unwrap(err) } - c.closed = true - c.mu.Unlock() - - // Stop batcher (will flush remaining logs) - return c.batcher.Stop() + return result } diff --git a/client_test.go b/client_test.go index 6b380c2..804ea94 100644 --- a/client_test.go +++ b/client_test.go @@ -1,4 +1,4 @@ -package logtide +package logtide_test import ( "context" @@ -8,293 +8,372 @@ import ( "sync/atomic" "testing" "time" + + logtide "github.com/logtide-dev/logtide-sdk-go" ) -func TestNewClient(t *testing.T) { - t.Run("creates client with valid config", func(t *testing.T) { - client, err := New( - WithAPIKey("lp_test_key"), - WithService("test-service"), - ) - if err != nil { - t.Fatalf("New() error = %v", err) +func newTestServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(srv.Close) + return srv +} + +func newTestDSN(serverURL string) string { + // Replace https with http and inject a fake API key. + return "http://lp_testkey@" + serverURL[len("http://"):] +} + +// TestClientLogMethodsReturnEventID verifies that all five log methods return EventID. +func TestClientLogMethodsReturnEventID(t *testing.T) { + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "svc", + Transport: logtide.NoopTransport{}, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + ctx := context.Background() + funcs := []struct { + name string + fn func() logtide.EventID + }{ + {"Debug", func() logtide.EventID { return client.Debug(ctx, "msg", nil) }}, + {"Info", func() logtide.EventID { return client.Info(ctx, "msg", nil) }}, + {"Warn", func() logtide.EventID { return client.Warn(ctx, "msg", nil) }}, + {"Error", func() logtide.EventID { return client.Error(ctx, "msg", nil) }}, + {"Critical", func() logtide.EventID { return client.Critical(ctx, "msg", nil) }}, + } + for _, f := range funcs { + if id := f.fn(); id == "" { + t.Errorf("%s() returned empty EventID, want non-empty", f.name) } - defer client.Close() + } +} + +// TestClientAttachStacktraceDefault verifies that AttachStacktrace is true by default. +func TestClientAttachStacktraceDefault(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + w.WriteHeader(http.StatusOK) + }) + + // Use zero-value ClientOptions (not NewClientOptions) to trigger the default. + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.CaptureError(ctx, context.DeadlineExceeded, nil) + client.Flush(ctx) + time.Sleep(50 * time.Millisecond) + client.Close() + + if len(captured) == 0 { + t.Fatal("no entries received") + } + if len(captured[0].Errors) == 0 { + t.Fatal("expected Errors field to be populated by CaptureError") + } + if captured[0].Errors[0].Stacktrace == nil { + t.Error("AttachStacktrace should be true by default — stacktrace should be attached") + } +} - if client == nil { - t.Fatal("New() returned nil client") +// TestNewClientValidation checks that NewClient enforces required fields. +func TestNewClientValidation(t *testing.T) { + t.Run("missing service", func(t *testing.T) { + _, err := logtide.NewClient(logtide.ClientOptions{DSN: "https://key@api.logtide.dev"}) + if err == nil { + t.Fatal("expected error for missing service") } }) - t.Run("fails with missing API key", func(t *testing.T) { - _, err := New( - WithService("test-service"), - ) + t.Run("missing DSN without custom transport", func(t *testing.T) { + _, err := logtide.NewClient(logtide.ClientOptions{Service: "svc"}) if err == nil { - t.Fatal("New() error = nil, want error") + t.Fatal("expected error for missing DSN") } }) - t.Run("fails with missing service", func(t *testing.T) { - _, err := New( - WithAPIKey("lp_test_key"), - ) + t.Run("invalid DSN", func(t *testing.T) { + _, err := logtide.NewClient(logtide.ClientOptions{Service: "svc", DSN: "not-a-dsn"}) if err == nil { - t.Fatal("New() error = nil, want error") + t.Fatal("expected error for invalid DSN") } }) - t.Run("applies custom configuration", func(t *testing.T) { - client, err := New( - WithAPIKey("lp_custom_key"), - WithService("custom-service"), - WithBaseURL("https://custom.example.com"), - WithBatchSize(50), - WithFlushInterval(10*time.Second), - ) + t.Run("valid with NoopTransport", func(t *testing.T) { + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "svc", + Transport: logtide.NoopTransport{}, + }) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("unexpected error: %v", err) } defer client.Close() - - if client.config.APIKey != "lp_custom_key" { - t.Errorf("APIKey = %q, want %q", client.config.APIKey, "lp_custom_key") - } - if client.config.Service != "custom-service" { - t.Errorf("Service = %q, want %q", client.config.Service, "custom-service") - } - if client.config.BaseURL != "https://custom.example.com" { - t.Errorf("BaseURL = %q, want %q", client.config.BaseURL, "https://custom.example.com") - } - if client.config.BatchSize != 50 { - t.Errorf("BatchSize = %d, want 50", client.config.BatchSize) - } }) } +// TestClientLeveledLogging sends all five log levels and verifies delivery. func TestClientLeveledLogging(t *testing.T) { - // Create mock server - var receivedLogs []Log - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req IngestRequest + var received int32 + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } json.NewDecoder(r.Body).Decode(&req) - receivedLogs = append(receivedLogs, req.Logs...) + atomic.AddInt32(&received, int32(len(req.Logs))) + json.NewEncoder(w).Encode(map[string]any{"received": len(req.Logs)}) + }) - resp := IngestResponse{ - Received: len(req.Logs), - Timestamp: time.Now().Format(time.RFC3339), - } - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - // Create client - client, err := New( - WithAPIKey("lp_test_key"), - WithService("test-service"), - WithBaseURL(server.URL), - WithBatchSize(10), - WithFlushInterval(100*time.Millisecond), - ) + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test-service", + BatchSize: 10, + FlushInterval: 100 * time.Millisecond, + }) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("NewClient: %v", err) } defer client.Close() ctx := context.Background() + client.Debug(ctx, "debug msg", nil) + client.Info(ctx, "info msg", nil) + client.Warn(ctx, "warn msg", nil) + client.Error(ctx, "error msg", nil) + client.Critical(ctx, "critical msg", nil) + + ctx2, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + client.Flush(ctx2) + time.Sleep(50 * time.Millisecond) + + if n := atomic.LoadInt32(&received); n != 5 { + t.Errorf("received %d logs, want 5", n) + } +} - // Test each log level - tests := []struct { - name string - logFunc func(context.Context, string, map[string]interface{}) error - level LogLevel - message string - metadata map[string]interface{} - }{ - { - name: "Debug", - logFunc: client.Debug, - level: LogLevelDebug, - message: "debug message", - metadata: map[string]interface{}{"key": "value"}, - }, - { - name: "Info", - logFunc: client.Info, - level: LogLevelInfo, - message: "info message", - metadata: nil, - }, - { - name: "Warn", - logFunc: client.Warn, - level: LogLevelWarn, - message: "warn message", - metadata: map[string]interface{}{"warning_code": 123}, - }, - { - name: "Error", - logFunc: client.Error, - level: LogLevelError, - message: "error message", - metadata: map[string]interface{}{"error": "details"}, - }, - { - name: "Critical", - logFunc: client.Critical, - level: LogLevelCritical, - message: "critical message", - metadata: map[string]interface{}{"severity": "high"}, - }, +// TestClientBatching verifies that logs are grouped into batches. +func TestClientBatching(t *testing.T) { + var requests int32 + var totalLogs int32 + + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requests, 1) + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + atomic.AddInt32(&totalLogs, int32(len(req.Logs))) + json.NewEncoder(w).Encode(map[string]any{"received": len(req.Logs)}) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test-service", + BatchSize: 3, + FlushInterval: time.Minute, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.logFunc(ctx, tt.message, tt.metadata) - if err != nil { - t.Errorf("%s() error = %v", tt.name, err) - } - }) + ctx := context.Background() + for i := 0; i < 10; i++ { + client.Info(ctx, "test", nil) } - // Flush to ensure all logs are sent - client.Flush(ctx) time.Sleep(200 * time.Millisecond) + client.Close() + time.Sleep(100 * time.Millisecond) - // Verify logs were received - if len(receivedLogs) != len(tests) { - t.Errorf("received %d logs, want %d", len(receivedLogs), len(tests)) + if n := atomic.LoadInt32(&totalLogs); n != 10 { + t.Errorf("total logs = %d, want 10", n) } - - // Verify each log - for i, test := range tests { - if i >= len(receivedLogs) { - break - } - log := receivedLogs[i] - if log.Level != test.level { - t.Errorf("log[%d].Level = %v, want %v", i, log.Level, test.level) - } - if log.Message != test.message { - t.Errorf("log[%d].Message = %q, want %q", i, log.Message, test.message) - } - if log.Service != "test-service" { - t.Errorf("log[%d].Service = %q, want %q", i, log.Service, "test-service") - } + if r := atomic.LoadInt32(&requests); r < 1 { + t.Errorf("requests = %d, want >= 1", r) } } -func TestClientBatching(t *testing.T) { - var requestCount int32 - var totalLogsReceived int32 - - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&requestCount, 1) - - var req IngestRequest +// TestClientCloseFlushes verifies that Close delivers buffered logs. +func TestClientCloseFlushes(t *testing.T) { + var received int32 + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } json.NewDecoder(r.Body).Decode(&req) - atomic.AddInt32(&totalLogsReceived, int32(len(req.Logs))) + atomic.AddInt32(&received, int32(len(req.Logs))) + json.NewEncoder(w).Encode(map[string]any{"received": len(req.Logs)}) + }) - resp := IngestResponse{ - Received: len(req.Logs), - Timestamp: time.Now().Format(time.RFC3339), - } - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - // Create client with small batch size - client, err := New( - WithAPIKey("lp_test_key"), - WithService("test-service"), - WithBaseURL(server.URL), - WithBatchSize(3), - WithFlushInterval(1*time.Minute), // Long interval to test size-based batching - ) + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test-service", + BatchSize: 100, + FlushInterval: time.Minute, + }) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("NewClient: %v", err) } ctx := context.Background() - - // Send 10 logs for i := 0; i < 10; i++ { - client.Info(ctx, "test message", nil) + client.Info(ctx, "test", nil) } - - // Wait for batches to be sent - time.Sleep(300 * time.Millisecond) - - // Close (which will flush remaining) client.Close() time.Sleep(100 * time.Millisecond) - // Should have received all 10 logs - total := atomic.LoadInt32(&totalLogsReceived) - if total != 10 { - t.Errorf("total logs received = %d, want 10", total) + if n := atomic.LoadInt32(&received); n != 10 { + t.Errorf("received %d, want 10", n) } - // Should have sent multiple batches (at least 3 with batch size of 3) - count := atomic.LoadInt32(&requestCount) - if count < 1 { - t.Errorf("request count = %d, want >= 1", count) + // Log after close silently drops the entry and returns empty EventID. + if id := client.Info(ctx, "after close", nil); id != "" { + t.Errorf("Info after close = %q, want empty EventID", id) } } -func TestClientClose(t *testing.T) { - var receivedCount int32 - - // Create mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req IngestRequest +// TestClientBeforeSend verifies the BeforeSend hook can drop entries. +func TestClientBeforeSend(t *testing.T) { + var received int32 + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } json.NewDecoder(r.Body).Decode(&req) - atomic.AddInt32(&receivedCount, int32(len(req.Logs))) + atomic.AddInt32(&received, int32(len(req.Logs))) + json.NewEncoder(w).Encode(map[string]any{"received": len(req.Logs)}) + }) - resp := IngestResponse{ - Received: len(req.Logs), - Timestamp: time.Now().Format(time.RFC3339), - } - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - // Create client - client, err := New( - WithAPIKey("lp_test_key"), - WithService("test-service"), - WithBaseURL(server.URL), - WithBatchSize(100), - WithFlushInterval(1*time.Minute), - ) + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test-service", + BeforeSend: func(entry *logtide.LogEntry, _ *logtide.EventHint) *logtide.LogEntry { + // Drop debug entries. + if entry.Level == logtide.LevelDebug { + return nil + } + return entry + }, + BatchSize: 10, + FlushInterval: 100 * time.Millisecond, + }) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("NewClient: %v", err) } + defer client.Close() ctx := context.Background() + client.Debug(ctx, "dropped", nil) + client.Info(ctx, "kept", nil) - // Send logs - for i := 0; i < 10; i++ { - client.Info(ctx, "test message", nil) + ctx2, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + client.Flush(ctx2) + time.Sleep(50 * time.Millisecond) + + if n := atomic.LoadInt32(&received); n != 1 { + t.Errorf("received %d, want 1 (debug should be dropped)", n) } +} + +// TestClientCaptureError verifies error serialisation. +func TestClientCaptureError(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + json.NewEncoder(w).Encode(map[string]any{"received": len(req.Logs)}) + }) - // Close should flush remaining logs - err = client.Close() + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test-service", + BatchSize: 10, + FlushInterval: 100 * time.Millisecond, + }) if err != nil { - t.Errorf("Close() error = %v", err) + t.Fatalf("NewClient: %v", err) } + defer client.Close() - time.Sleep(100 * time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() - count := atomic.LoadInt32(&receivedCount) - if count != 10 { - t.Errorf("received %d logs, want 10", count) + id := client.CaptureError(ctx, context.DeadlineExceeded, nil) + if id == "" { + t.Fatal("CaptureError returned empty EventID") } - // Logging after close should fail - err = client.Info(ctx, "after close", nil) - if err != ErrClientClosed { - t.Errorf("Info() after close error = %v, want %v", err, ErrClientClosed) + client.Flush(ctx) + time.Sleep(50 * time.Millisecond) + + if len(captured) == 0 { + t.Fatal("no log entries received") + } + if captured[0].Level != logtide.LevelError { + t.Errorf("level = %v, want error", captured[0].Level) + } +} + +// TestClientScopeEnrichment verifies scope tags are merged into entries. +func TestClientScopeEnrichment(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + json.NewEncoder(w).Encode(map[string]any{"received": len(req.Logs)}) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test-service", + BatchSize: 10, + FlushInterval: 100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + scope := logtide.NewScope(10) + scope.SetTag("request_id", "abc-123") + ctx := logtide.WithScope(context.Background(), scope) + + client.Info(ctx, "with scope", nil) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + + if len(captured) == 0 { + t.Fatal("no entries received") + } + if captured[0].Tags["request_id"] != "abc-123" { + t.Errorf("tag request_id = %q, want %q", captured[0].Tags["request_id"], "abc-123") } } diff --git a/config.go b/config.go deleted file mode 100644 index 116716a..0000000 --- a/config.go +++ /dev/null @@ -1,129 +0,0 @@ -package logtide - -import "time" - -// Config holds the configuration for the LogTide client. -type Config struct { - // APIKey is the LogTide API key (required). - APIKey string - - // BaseURL is the LogTide API base URL. - // Default: "https://api.logtide.dev" - BaseURL string - - // Service is the default service name for all logs (required). - Service string - - // Timeout is the HTTP request timeout. - // Default: 30 seconds - Timeout time.Duration - - // BatchSize is the maximum number of logs per batch. - // Default: 100 - BatchSize int - - // FlushInterval is the maximum time to wait before flushing a batch. - // Default: 5 seconds - FlushInterval time.Duration - - // RetryConfig holds the retry configuration. - RetryConfig *RetryConfig - - // CircuitBreakerConfig holds the circuit breaker configuration. - CircuitBreakerConfig *CircuitBreakerConfig -} - -// Option is a functional option for configuring the Client. -type Option func(*Config) - -// DefaultConfig returns the default configuration. -func DefaultConfig() *Config { - return &Config{ - BaseURL: "https://api.logtide.dev", - Timeout: 30 * time.Second, - BatchSize: 100, - FlushInterval: 5 * time.Second, - RetryConfig: DefaultRetryConfig(), - CircuitBreakerConfig: DefaultCircuitBreakerConfig(), - } -} - -// WithAPIKey sets the API key. -func WithAPIKey(apiKey string) Option { - return func(c *Config) { - c.APIKey = apiKey - } -} - -// WithBaseURL sets the base URL. -func WithBaseURL(baseURL string) Option { - return func(c *Config) { - c.BaseURL = baseURL - } -} - -// WithService sets the default service name. -func WithService(service string) Option { - return func(c *Config) { - c.Service = service - } -} - -// WithTimeout sets the HTTP timeout. -func WithTimeout(timeout time.Duration) Option { - return func(c *Config) { - c.Timeout = timeout - } -} - -// WithBatchSize sets the maximum batch size. -func WithBatchSize(size int) Option { - return func(c *Config) { - c.BatchSize = size - } -} - -// WithFlushInterval sets the flush interval. -func WithFlushInterval(interval time.Duration) Option { - return func(c *Config) { - c.FlushInterval = interval - } -} - -// WithRetry sets the retry configuration. -func WithRetry(maxRetries int, minBackoff, maxBackoff time.Duration) Option { - return func(c *Config) { - c.RetryConfig = &RetryConfig{ - MaxRetries: maxRetries, - MinBackoff: minBackoff, - MaxBackoff: maxBackoff, - } - } -} - -// WithCircuitBreaker sets the circuit breaker configuration. -func WithCircuitBreaker(failureThreshold int, timeout time.Duration) Option { - return func(c *Config) { - c.CircuitBreakerConfig = &CircuitBreakerConfig{ - FailureThreshold: failureThreshold, - Timeout: timeout, - } - } -} - -// validate validates the configuration. -func (c *Config) validate() error { - if c.APIKey == "" { - return ErrInvalidAPIKey - } - if c.Service == "" { - return &ValidationError{Field: "service", Message: "service name is required"} - } - if len(c.Service) > 100 { - return &ValidationError{Field: "service", Message: "service name must be 100 characters or less"} - } - if c.BaseURL == "" { - return &ValidationError{Field: "baseURL", Message: "base URL is required"} - } - return nil -} diff --git a/context.go b/context.go deleted file mode 100644 index be29d6d..0000000 --- a/context.go +++ /dev/null @@ -1,38 +0,0 @@ -package logtide - -import ( - "context" - - "go.opentelemetry.io/otel/trace" -) - -// extractTraceID extracts the trace ID from the context if an OpenTelemetry span is present. -func extractTraceID(ctx context.Context) string { - span := trace.SpanFromContext(ctx) - if span == nil || !span.SpanContext().IsValid() { - return "" - } - - return span.SpanContext().TraceID().String() -} - -// extractSpanID extracts the span ID from the context if an OpenTelemetry span is present. -func extractSpanID(ctx context.Context) string { - span := trace.SpanFromContext(ctx) - if span == nil || !span.SpanContext().IsValid() { - return "" - } - - return span.SpanContext().SpanID().String() -} - -// enrichLogWithContext enriches a log entry with trace and span IDs from the context. -func enrichLogWithContext(ctx context.Context, log *Log) { - // Only extract if not already set - if log.TraceID == "" { - log.TraceID = extractTraceID(ctx) - } - if log.SpanID == "" { - log.SpanID = extractSpanID(ctx) - } -} diff --git a/context_test.go b/context_test.go deleted file mode 100644 index 99bf4ae..0000000 --- a/context_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package logtide - -import ( - "context" - "testing" - - "go.opentelemetry.io/otel/sdk/trace" - oteltrace "go.opentelemetry.io/otel/trace" -) - -func TestExtractTraceID(t *testing.T) { - // Create a tracer provider - provider := trace.NewTracerProvider() - tracer := provider.Tracer("test") - - t.Run("no span in context", func(t *testing.T) { - ctx := context.Background() - traceID := extractTraceID(ctx) - if traceID != "" { - t.Errorf("extractTraceID() = %q, want empty string", traceID) - } - }) - - t.Run("valid span in context", func(t *testing.T) { - ctx, span := tracer.Start(context.Background(), "test-span") - defer span.End() - - traceID := extractTraceID(ctx) - if traceID == "" { - t.Error("extractTraceID() = empty, want non-empty trace ID") - } - - // Verify it matches the span's trace ID - expectedTraceID := span.SpanContext().TraceID().String() - if traceID != expectedTraceID { - t.Errorf("extractTraceID() = %q, want %q", traceID, expectedTraceID) - } - }) - - t.Run("invalid span context", func(t *testing.T) { - // Create a context with an invalid span - ctx := oteltrace.ContextWithSpan(context.Background(), oteltrace.SpanFromContext(context.Background())) - traceID := extractTraceID(ctx) - if traceID != "" { - t.Errorf("extractTraceID() with invalid span = %q, want empty string", traceID) - } - }) -} - -func TestExtractSpanID(t *testing.T) { - // Create a tracer provider - provider := trace.NewTracerProvider() - tracer := provider.Tracer("test") - - t.Run("no span in context", func(t *testing.T) { - ctx := context.Background() - spanID := extractSpanID(ctx) - if spanID != "" { - t.Errorf("extractSpanID() = %q, want empty string", spanID) - } - }) - - t.Run("valid span in context", func(t *testing.T) { - ctx, span := tracer.Start(context.Background(), "test-span") - defer span.End() - - spanID := extractSpanID(ctx) - if spanID == "" { - t.Error("extractSpanID() = empty, want non-empty span ID") - } - - // Verify it matches the span's span ID - expectedSpanID := span.SpanContext().SpanID().String() - if spanID != expectedSpanID { - t.Errorf("extractSpanID() = %q, want %q", spanID, expectedSpanID) - } - - // Verify span ID is 16 hex characters - if len(spanID) != 16 { - t.Errorf("extractSpanID() length = %d, want 16", len(spanID)) - } - }) - - t.Run("invalid span context", func(t *testing.T) { - // Create a context with an invalid span - ctx := oteltrace.ContextWithSpan(context.Background(), oteltrace.SpanFromContext(context.Background())) - spanID := extractSpanID(ctx) - if spanID != "" { - t.Errorf("extractSpanID() with invalid span = %q, want empty string", spanID) - } - }) -} - -func TestEnrichLogWithContext(t *testing.T) { - // Create a tracer provider - provider := trace.NewTracerProvider() - tracer := provider.Tracer("test") - - t.Run("enriches log with trace and span IDs", func(t *testing.T) { - ctx, span := tracer.Start(context.Background(), "test-span") - defer span.End() - - log := &Log{ - Service: "test", - Level: LogLevelInfo, - Message: "test message", - } - - enrichLogWithContext(ctx, log) - - if log.TraceID == "" { - t.Error("TraceID not set") - } - if log.SpanID == "" { - t.Error("SpanID not set") - } - - // Verify IDs match the span - if log.TraceID != span.SpanContext().TraceID().String() { - t.Errorf("TraceID = %q, want %q", log.TraceID, span.SpanContext().TraceID().String()) - } - if log.SpanID != span.SpanContext().SpanID().String() { - t.Errorf("SpanID = %q, want %q", log.SpanID, span.SpanContext().SpanID().String()) - } - }) - - t.Run("does not overwrite existing IDs", func(t *testing.T) { - ctx, span := tracer.Start(context.Background(), "test-span") - defer span.End() - - existingTraceID := "existing-trace-id" - existingSpanID := "0123456789abcdef" - - log := &Log{ - Service: "test", - Level: LogLevelInfo, - Message: "test message", - TraceID: existingTraceID, - SpanID: existingSpanID, - } - - enrichLogWithContext(ctx, log) - - // Should not overwrite - if log.TraceID != existingTraceID { - t.Errorf("TraceID = %q, want %q (should not overwrite)", log.TraceID, existingTraceID) - } - if log.SpanID != existingSpanID { - t.Errorf("SpanID = %q, want %q (should not overwrite)", log.SpanID, existingSpanID) - } - }) - - t.Run("handles context without span", func(t *testing.T) { - ctx := context.Background() - - log := &Log{ - Service: "test", - Level: LogLevelInfo, - Message: "test message", - } - - enrichLogWithContext(ctx, log) - - // Should remain empty - if log.TraceID != "" { - t.Errorf("TraceID = %q, want empty", log.TraceID) - } - if log.SpanID != "" { - t.Errorf("SpanID = %q, want empty", log.SpanID) - } - }) -} diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md deleted file mode 100644 index 6b8cad5..0000000 --- a/docs/INSTALLATION.md +++ /dev/null @@ -1,149 +0,0 @@ -# Installation Guide - -This guide covers how to install and set up the LogTide Go SDK in your project. - -## Requirements - -- Go 1.21 or later -- A LogTide account with an API key - -## Installation - -### Using `go get` - -```bash -go get github.com/logtide-dev/logtide-sdk-go -``` - -### Using Go Modules - -Add to your `go.mod`: - -```go -require github.com/logtide-dev/logtide-sdk-go v0.1.0 -``` - -Then run: - -```bash -go mod download -``` - -### Verifying Installation - -Create a simple test file to verify the installation: - -```go -package main - -import ( - "context" - "fmt" - "github.com/logtide-dev/logtide-sdk-go" -) - -func main() { - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("test-service"), - ) - if err != nil { - panic(err) - } - defer client.Close() - - client.Info(context.Background(), "Installation successful!", nil) - fmt.Println("LogTide SDK installed successfully!") -} -``` - -Run it: - -```bash -go run main.go -``` - -## Getting Your API Key - -1. Sign up at [https://logtide.dev](https://logtide.dev) -2. Create a project -3. Navigate to **Project Settings** → **API Keys** -4. Generate a new API key (starts with `lp_`) -5. Copy your API key for use in your application - -## Environment Variables (Recommended) - -Instead of hardcoding your API key, use environment variables: - -```bash -export LOGTIDE_API_KEY="lp_your_api_key_here" -export LOGTIDE_SERVICE="my-service" -``` - -Then in your code: - -```go -import "os" - -client, err := logtide.New( - logtide.WithAPIKey(os.Getenv("LOGTIDE_API_KEY")), - logtide.WithService(os.Getenv("LOGTIDE_SERVICE")), -) -``` - -## Dependencies - -The SDK has minimal dependencies: - -- **Core SDK**: Only Go standard library -- **OpenTelemetry Support**: `go.opentelemetry.io/otel/trace` (for trace extraction) - -All dependencies are automatically managed by Go modules. - -## Updating - -To update to the latest version: - -```bash -go get -u github.com/logtide-dev/logtide-sdk-go -``` - -Or specify a version: - -```bash -go get github.com/logtide-dev/logtide-sdk-go@v0.2.0 -``` - -## Troubleshooting - -### Import Issues - -If you encounter import issues, try: - -```bash -go mod tidy -go clean -modcache -go mod download -``` - -### API Key Errors - -If you see "invalid or missing API key": -- Verify your API key starts with `lp_` -- Check that the API key is not expired -- Ensure you're using the correct project's API key - -### Connection Issues - -If logs aren't reaching LogTide: -- Check your network connectivity -- Verify the base URL (default: `https://api.logtide.dev`) -- Check firewall settings -- Look for error logs - -## Next Steps - -- Read the [Quick Start Guide](QUICKSTART.md) -- Explore [Framework Integrations](INTEGRATIONS.md) -- Check out the [Examples](../examples/) -- Review the [API Documentation](https://pkg.go.dev/github.com/logtide-dev/logtide-sdk-go) diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md deleted file mode 100644 index 64c5472..0000000 --- a/docs/INTEGRATIONS.md +++ /dev/null @@ -1,460 +0,0 @@ -# Framework Integrations - -This guide shows how to integrate the LogTide Go SDK with popular Go web frameworks. - -## Table of Contents - -- [Gin](#gin) -- [Echo](#echo) -- [Standard Library](#standard-library-nethttp) -- [Chi](#chi) -- [Fiber](#fiber) -- [OpenTelemetry](#opentelemetry) - ---- - -## Gin - -### Installation - -```bash -go get github.com/gin-gonic/gin -go get github.com/logtide-dev/logtide-sdk-go -``` - -### Middleware Implementation - -```go -package main - -import ( - "time" - "github.com/gin-gonic/gin" - "github.com/logtide-dev/logtide-sdk-go" -) - -func LogtideMiddleware(client *logtide.Client) gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - - // Process request - c.Next() - - // Log after request - duration := time.Since(start) - - client.Info(c.Request.Context(), "Request completed", map[string]interface{}{ - "method": c.Request.Method, - "path": c.Request.URL.Path, - "status": c.Writer.Status(), - "duration_ms": duration.Milliseconds(), - "ip": c.ClientIP(), - }) - } -} - -func main() { - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("gin-api"), - ) - defer client.Close() - - r := gin.Default() - r.Use(LogtideMiddleware(client)) - - r.GET("/", func(c *gin.Context) { - c.JSON(200, gin.H{"message": "Hello!"}) - }) - - r.Run(":8080") -} -``` - -**Complete Example:** [examples/gin/](../examples/gin/) - ---- - -## Echo - -### Installation - -```bash -go get github.com/labstack/echo/v4 -go get github.com/logtide-dev/logtide-sdk-go -``` - -### Middleware Implementation - -```go -package main - -import ( - "time" - "github.com/labstack/echo/v4" - "github.com/logtide-dev/logtide-sdk-go" -) - -func LogtideMiddleware(client *logtide.Client) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - start := time.Now() - - err := next(c) - - duration := time.Since(start) - - client.Info(c.Request().Context(), "Request completed", map[string]interface{}{ - "method": c.Request().Method, - "path": c.Request().URL.Path, - "status": c.Response().Status, - "duration_ms": duration.Milliseconds(), - "ip": c.RealIP(), - }) - - return err - } - } -} - -func main() { - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("echo-api"), - ) - defer client.Close() - - e := echo.New() - e.Use(LogtideMiddleware(client)) - - e.GET("/", func(c echo.Context) error { - return c.JSON(200, map[string]string{"message": "Hello!"}) - }) - - e.Start(":8080") -} -``` - -**Complete Example:** [examples/echo/](../examples/echo/) - ---- - -## Standard Library (net/http) - -### Basic Middleware - -```go -package main - -import ( - "net/http" - "time" - "github.com/logtide-dev/logtide-sdk-go" -) - -type responseWriter struct { - http.ResponseWriter - statusCode int -} - -func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) -} - -func LoggingMiddleware(client *logtide.Client, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - rw := &responseWriter{ResponseWriter: w, statusCode: 200} - next.ServeHTTP(rw, r) - - duration := time.Since(start) - - client.Info(r.Context(), "Request completed", map[string]interface{}{ - "method": r.Method, - "path": r.URL.Path, - "status": rw.statusCode, - "duration_ms": duration.Milliseconds(), - }) - }) -} - -func main() { - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("http-api"), - ) - defer client.Close() - - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello!")) - }) - - handler := LoggingMiddleware(client, mux) - http.ListenAndServe(":8080", handler) -} -``` - -**Complete Example:** [examples/stdlib/](../examples/stdlib/) - ---- - -## Chi - -### Installation - -```bash -go get github.com/go-chi/chi/v5 -go get github.com/logtide-dev/logtide-sdk-go -``` - -### Middleware Implementation - -```go -package main - -import ( - "net/http" - "time" - "github.com/go-chi/chi/v5" - "github.com/logtide-dev/logtide-sdk-go" -) - -func LogtideMiddleware(client *logtide.Client) func(next http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - ww := chi.NewWrapResponseWriter(w, r.ProtoMajor) - next.ServeHTTP(ww, r) - - duration := time.Since(start) - - client.Info(r.Context(), "Request completed", map[string]interface{}{ - "method": r.Method, - "path": r.URL.Path, - "status": ww.Status(), - "duration_ms": duration.Milliseconds(), - "bytes": ww.BytesWritten(), - }) - }) - } -} - -func main() { - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("chi-api"), - ) - defer client.Close() - - r := chi.NewRouter() - r.Use(LogtideMiddleware(client)) - - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello!")) - }) - - http.ListenAndServe(":8080", r) -} -``` - ---- - -## Fiber - -### Installation - -```bash -go get github.com/gofiber/fiber/v2 -go get github.com/logtide-dev/logtide-sdk-go -``` - -### Middleware Implementation - -```go -package main - -import ( - "time" - "github.com/gofiber/fiber/v2" - "github.com/logtide-dev/logtide-sdk-go" -) - -func LogtideMiddleware(client *logtide.Client) fiber.Handler { - return func(c *fiber.Ctx) error { - start := time.Now() - - err := c.Next() - - duration := time.Since(start) - - client.Info(c.UserContext(), "Request completed", map[string]interface{}{ - "method": c.Method(), - "path": c.Path(), - "status": c.Response().StatusCode(), - "duration_ms": duration.Milliseconds(), - "ip": c.IP(), - }) - - return err - } -} - -func main() { - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("fiber-api"), - ) - defer client.Close() - - app := fiber.New() - app.Use(LogtideMiddleware(client)) - - app.Get("/", func(c *fiber.Ctx) error { - return c.SendString("Hello!") - }) - - app.Listen(":8080") -} -``` - ---- - -## OpenTelemetry - -### Automatic Trace ID Extraction - -The SDK automatically extracts trace and span IDs from OpenTelemetry contexts: - -```go -package main - -import ( - "context" - "go.opentelemetry.io/otel" - "github.com/logtide-dev/logtide-sdk-go" -) - -func main() { - // Setup OpenTelemetry (tracer provider, etc.) - tracer := otel.Tracer("my-service") - - client, _ := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("traced-api"), - ) - defer client.Close() - - ctx, span := tracer.Start(context.Background(), "operation") - defer span.End() - - // Trace ID and Span ID automatically included! - client.Info(ctx, "Processing request", map[string]interface{}{ - "user_id": 123, - }) -} -``` - -### Manual Trace ID Injection - -If you're not using OpenTelemetry but have trace IDs: - -```go -client.Info(ctx, "Processing", map[string]interface{}{ - "trace_id": myTraceID, - "span_id": mySpanID, -}) -``` - -**Complete Example:** [examples/otel/](../examples/otel/) - ---- - -## Advanced Patterns - -### Request ID Middleware - -```go -func RequestIDMiddleware(client *logtide.Client) gin.HandlerFunc { - return func(c *gin.Context) { - requestID := c.GetHeader("X-Request-ID") - if requestID == "" { - requestID = generateRequestID() - } - - c.Set("request_id", requestID) - c.Next() - - client.Info(c.Request.Context(), "Request processed", map[string]interface{}{ - "request_id": requestID, - "path": c.Request.URL.Path, - }) - } -} -``` - -### Error Recovery Middleware - -```go -func RecoveryMiddleware(client *logtide.Client) gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if err := recover(); err != nil { - client.Critical(c.Request.Context(), "Panic recovered", map[string]interface{}{ - "error": err, - "path": c.Request.URL.Path, - }) - c.AbortWithStatus(500) - } - }() - c.Next() - } -} -``` - -### Authentication Logging - -```go -func AuthMiddleware(client *logtide.Client) gin.HandlerFunc { - return func(c *gin.Context) { - token := c.GetHeader("Authorization") - - user, err := authenticate(token) - if err != nil { - client.Warn(c.Request.Context(), "Authentication failed", map[string]interface{}{ - "error": err.Error(), - "ip": c.ClientIP(), - }) - c.AbortWithStatus(401) - return - } - - client.Info(c.Request.Context(), "User authenticated", map[string]interface{}{ - "user_id": user.ID, - }) - - c.Set("user", user) - c.Next() - } -} -``` - ---- - -## Best Practices - -1. **One Client Per Application**: Create a single client and reuse it -2. **Use Context**: Always pass request context for trace propagation -3. **Log After Processing**: Log after the request is processed to include duration -4. **Structured Metadata**: Use metadata for searchable fields -5. **Appropriate Log Levels**: Use Warn/Error for failed requests -6. **Performance**: The SDK is non-blocking and batches automatically - -## Next Steps - -- Check out the [Examples Directory](../examples/) for complete working code -- Read the [Quick Start Guide](QUICKSTART.md) -- Review the [API Reference](https://pkg.go.dev/github.com/logtide-dev/logtide-sdk-go) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md deleted file mode 100644 index 74e52e9..0000000 --- a/docs/QUICKSTART.md +++ /dev/null @@ -1,306 +0,0 @@ -# Quick Start Guide - -Get started with the LogTide Go SDK in minutes! - -## Basic Setup - -### 1. Install the SDK - -```bash -go get github.com/logtide-dev/logtide-sdk-go -``` - -### 2. Import and Initialize - -```go -package main - -import ( - "context" - "log" - - "github.com/logtide-dev/logtide-sdk-go" -) - -func main() { - // Create client - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("my-service"), - ) - if err != nil { - log.Fatal(err) - } - defer client.Close() // Important: flushes buffered logs - - ctx := context.Background() - - // Send logs - client.Info(ctx, "Application started", nil) -} -``` - -### 3. Run Your Application - -```bash -go run main.go -``` - -Check your [LogTide dashboard](https://app.logtide.dev) to see your logs! - -## Leveled Logging - -The SDK provides five log levels: - -```go -// Debug - Detailed debugging information -client.Debug(ctx, "Debug message", map[string]interface{}{ - "variable": value, -}) - -// Info - General informational messages -client.Info(ctx, "User logged in", map[string]interface{}{ - "user_id": 123, -}) - -// Warn - Warning messages -client.Warn(ctx, "High memory usage", map[string]interface{}{ - "usage_mb": 850, -}) - -// Error - Error events -client.Error(ctx, "Database connection failed", map[string]interface{}{ - "error": err.Error(), -}) - -// Critical - Critical system errors -client.Critical(ctx, "System shutdown", nil) -``` - -## Structured Logging - -Add metadata to provide context: - -```go -client.Info(ctx, "User action", map[string]interface{}{ - "user_id": 12345, - "action": "purchase", - "amount": 99.99, - "currency": "USD", - "timestamp": time.Now(), -}) -``` - -## Configuration Options - -Customize the client behavior: - -```go -client, err := logtide.New( - // Required - logtide.WithAPIKey("lp_your_api_key"), - logtide.WithService("my-service"), - - // Optional - logtide.WithBaseURL("https://api.logtide.dev"), - logtide.WithBatchSize(100), // Max logs per batch - logtide.WithFlushInterval(5*time.Second), // Flush interval - logtide.WithTimeout(30*time.Second), // HTTP timeout - logtide.WithRetry(3, 1*time.Second, 60*time.Second), // Retry config - logtide.WithCircuitBreaker(5, 30*time.Second), // Circuit breaker -) -``` - -## Common Patterns - -### Pattern 1: Application Logging - -```go -func main() { - client := setupLogtide() - defer client.Close() - - client.Info(ctx, "Application starting", map[string]interface{}{ - "version": "1.0.0", - "env": "production", - }) - - // Your application logic - - client.Info(ctx, "Application stopping", nil) -} -``` - -### Pattern 2: Error Handling - -```go -result, err := doSomething() -if err != nil { - client.Error(ctx, "Operation failed", map[string]interface{}{ - "operation": "doSomething", - "error": err.Error(), - }) - return err -} - -client.Info(ctx, "Operation succeeded", map[string]interface{}{ - "result": result, -}) -``` - -### Pattern 3: Request Logging - -```go -func handleRequest(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - // Process request - - client.Info(r.Context(), "Request completed", map[string]interface{}{ - "method": r.Method, - "path": r.URL.Path, - "duration_ms": time.Since(start).Milliseconds(), - "status": 200, - }) -} -``` - -### Pattern 4: Distributed Tracing - -```go -import "go.opentelemetry.io/otel" - -func processOrder(ctx context.Context, orderID string) { - // Create span - ctx, span := otel.Tracer("my-service").Start(ctx, "process-order") - defer span.End() - - // Trace ID automatically included! - client.Info(ctx, "Processing order", map[string]interface{}{ - "order_id": orderID, - }) -} -``` - -## Best Practices - -### 1. Always Close the Client - -```go -client, err := logtide.New(...) -if err != nil { - return err -} -defer client.Close() // Ensures buffered logs are flushed -``` - -### 2. Use Context - -```go -// Pass context for cancellation support -client.Info(ctx, "message", metadata) - -// Not recommended -client.Info(context.Background(), "message", metadata) -``` - -### 3. Structure Your Metadata - -```go -// Good: Structured data -client.Info(ctx, "User registered", map[string]interface{}{ - "user_id": 123, - "email": "user@example.com", - "source": "web", -}) - -// Bad: Everything in the message -client.Info(ctx, "User 123 (user@example.com) registered from web", nil) -``` - -### 4. Choose Appropriate Log Levels - -- **Debug**: Development debugging only -- **Info**: Normal application flow -- **Warn**: Potentially problematic situations -- **Error**: Error events that don't stop the application -- **Critical**: Severe errors requiring immediate attention - -### 5. Don't Log Sensitive Data - -```go -// Bad: Logging passwords -client.Info(ctx, "Login attempt", map[string]interface{}{ - "password": password, // ❌ Never log passwords -}) - -// Good: Log only safe information -client.Info(ctx, "Login attempt", map[string]interface{}{ - "username": username, - "success": true, -}) -``` - -## Performance Tips - -### 1. Automatic Batching - -Logs are automatically batched for performance. No need to batch manually. - -### 2. Non-Blocking - -All logging operations are non-blocking. The SDK uses background goroutines for flushing. - -### 3. Manual Flush (Optional) - -For critical logs, you can force an immediate flush: - -```go -client.Critical(ctx, "System error", metadata) -client.Flush(ctx) // Ensure it's sent immediately -``` - -## Error Handling - -```go -err := client.Info(ctx, "message", metadata) -if err != nil { - switch { - case errors.Is(err, logtide.ErrClientClosed): - // Client was closed - case errors.Is(err, logtide.ErrCircuitOpen): - // Circuit breaker is open - case errors.Is(err, logtide.ErrInvalidAPIKey): - // Invalid API key - default: - // Other errors - log.Printf("Logging error: %v", err) - } -} -``` - -## Testing - -### Mock for Testing - -```go -// In tests, you can skip logging or use a mock -func TestMyFunction(t *testing.T) { - // Option 1: Use a test client that doesn't send logs - client, _ := logtide.New( - logtide.WithAPIKey("lp_test_key"), - logtide.WithService("test"), - logtide.WithBaseURL("http://localhost:9999"), // Non-existent - ) - defer client.Close() - - // Run your tests -} -``` - -## Next Steps - -- Explore [Framework Integrations](INTEGRATIONS.md) for Gin, Echo, and stdlib -- Check out complete [Examples](../examples/) -- Review the [API Reference](https://pkg.go.dev/github.com/logtide-dev/logtide-sdk-go) -- Learn about [Advanced Configuration](../README.md#configuration) diff --git a/dsn.go b/dsn.go new file mode 100644 index 0000000..564317b --- /dev/null +++ b/dsn.go @@ -0,0 +1,55 @@ +package logtide + +import ( + "fmt" + "net/url" +) + +// DSN holds the parsed components of a LogTide Data Source Name. +type DSN struct { + APIKey string + Scheme string // "https" or "http" + Host string // host[:port] + Path string // optional sub-path prefix (without trailing slash) +} + +// ParseDSN parses a DSN string of the form: +// +// https://{api_key}@{host}[/{path}] +// +// Example: +// +// https://lp_abc123@api.logtide.dev +func ParseDSN(rawDSN string) (*DSN, error) { + u, err := url.Parse(rawDSN) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidDSN, err.Error()) + } + if u.Scheme != "https" && u.Scheme != "http" { + return nil, fmt.Errorf("%w: scheme must be http or https, got %q", ErrInvalidDSN, u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("%w: missing host", ErrInvalidDSN) + } + if u.User == nil || u.User.Username() == "" { + return nil, fmt.Errorf("%w: missing API key in userinfo (expected https://apikey@host)", ErrInvalidDSN) + } + + path := u.Path + if path == "/" { + path = "" + } + + return &DSN{ + APIKey: u.User.Username(), + Scheme: u.Scheme, + Host: u.Host, + Path: path, + }, nil +} + +// IngestURL returns the full URL for the log ingest endpoint. +func (d *DSN) IngestURL() string { + return fmt.Sprintf("%s://%s%s/api/v1/ingest", d.Scheme, d.Host, d.Path) +} + diff --git a/dsn_test.go b/dsn_test.go new file mode 100644 index 0000000..972d10a --- /dev/null +++ b/dsn_test.go @@ -0,0 +1,74 @@ +package logtide_test + +import ( + "testing" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +func TestParseDSN(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + apiKey string + host string + url string + }{ + { + name: "standard https", + input: "https://lp_abc123@api.logtide.dev", + apiKey: "lp_abc123", + host: "api.logtide.dev", + url: "https://api.logtide.dev/api/v1/ingest", + }, + { + name: "http with path", + input: "http://key@localhost:9000/v2", + apiKey: "key", + host: "localhost:9000", + url: "http://localhost:9000/v2/api/v1/ingest", + }, + { + name: "missing api key", + input: "https://api.logtide.dev", + wantErr: true, + }, + { + name: "missing host", + input: "https://key@", + wantErr: true, + }, + { + name: "wrong scheme", + input: "ftp://key@host", + wantErr: true, + }, + { + name: "not a url", + input: "not-a-dsn", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dsn, err := logtide.ParseDSN(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseDSN(%q) err = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if tt.wantErr { + return + } + if dsn.APIKey != tt.apiKey { + t.Errorf("APIKey = %q, want %q", dsn.APIKey, tt.apiKey) + } + if dsn.Host != tt.host { + t.Errorf("Host = %q, want %q", dsn.Host, tt.host) + } + if got := dsn.IngestURL(); got != tt.url { + t.Errorf("IngestURL() = %q, want %q", got, tt.url) + } + }) + } +} diff --git a/errors.go b/errors.go index c68f26d..44a500b 100644 --- a/errors.go +++ b/errors.go @@ -6,62 +6,42 @@ import ( ) var ( - // ErrInvalidAPIKey is returned when the API key is missing or invalid. - ErrInvalidAPIKey = errors.New("invalid or missing API key") + // ErrClientClosed indicates that a Client has been closed. After Close(), + // log-level methods silently drop entries and return an empty EventID. + // This sentinel is available for custom Transport or middleware implementations + // that need to surface the closed-client condition explicitly. + ErrClientClosed = errors.New("logtide: client is closed") - // ErrCircuitOpen is returned when the circuit breaker is in the open state. - ErrCircuitOpen = errors.New("circuit breaker is open") + // ErrInvalidDSN is returned when a DSN string cannot be parsed. + ErrInvalidDSN = errors.New("logtide: invalid DSN") - // ErrTimeout is returned when an operation times out. - ErrTimeout = errors.New("operation timed out") + // ErrCircuitOpen is returned when the circuit breaker is in the open state. + ErrCircuitOpen = errors.New("logtide: circuit breaker is open") - // ErrClientClosed is returned when attempting to use a closed client. - ErrClientClosed = errors.New("client is closed") + // ErrServiceRequired is returned when the service name is not set. + ErrServiceRequired = errors.New("logtide: service name is required") ) -// ValidationError represents a validation error for log data. +// ValidationError represents a field-level validation failure. type ValidationError struct { Field string Message string } -// Error implements the error interface. func (e *ValidationError) Error() string { - return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message) -} - -// Is allows errors.Is to match ValidationError types. -func (e *ValidationError) Is(target error) bool { - _, ok := target.(*ValidationError) - return ok + return fmt.Sprintf("logtide: validation error on field %q: %s", e.Field, e.Message) } -// HTTPError represents an HTTP error response from the LogTide API. +// HTTPError represents an unexpected HTTP response from the ingest endpoint. type HTTPError struct { StatusCode int Message string Body string } -// Error implements the error interface. func (e *HTTPError) Error() string { if e.Message != "" { - return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) + return fmt.Sprintf("logtide: HTTP %d: %s", e.StatusCode, e.Message) } - return fmt.Sprintf("HTTP %d", e.StatusCode) -} - -// Is allows errors.Is to match HTTPError types. -func (e *HTTPError) Is(target error) bool { - _, ok := target.(*HTTPError) - return ok -} - -// IsRetryable returns true if the HTTP error indicates a retryable condition. -func (e *HTTPError) IsRetryable() bool { - return e.StatusCode == 429 || // Too Many Requests - e.StatusCode == 500 || // Internal Server Error - e.StatusCode == 502 || // Bad Gateway - e.StatusCode == 503 || // Service Unavailable - e.StatusCode == 504 // Gateway Timeout + return fmt.Sprintf("logtide: HTTP %d", e.StatusCode) } diff --git a/errors_test.go b/errors_test.go index 4b25d99..121bf17 100644 --- a/errors_test.go +++ b/errors_test.go @@ -1,151 +1,62 @@ -package logtide +package logtide_test import ( "errors" "testing" + + logtide "github.com/logtide-dev/logtide-sdk-go" ) func TestValidationError(t *testing.T) { - err := &ValidationError{ - Field: "service", - Message: "service name is required", - } + err := &logtide.ValidationError{Field: "service", Message: "required"} - // Test Error() method - expected := "validation error on field 'service': service name is required" - if err.Error() != expected { - t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected) + if err.Error() == "" { + t.Fatal("ValidationError.Error() is empty") } - // Test errors.Is() compatibility - var target *ValidationError + var target *logtide.ValidationError if !errors.As(err, &target) { - t.Error("errors.As() should match ValidationError type") + t.Error("errors.As should match *ValidationError") } } func TestHTTPError(t *testing.T) { tests := []struct { - name string - err *HTTPError - expectedMsg string - isRetryable bool + name string + err *logtide.HTTPError + wantMsg string }{ - { - name: "429 Too Many Requests", - err: &HTTPError{ - StatusCode: 429, - Message: "Rate limit exceeded", - }, - expectedMsg: "HTTP 429: Rate limit exceeded", - isRetryable: true, - }, - { - name: "500 Internal Server Error", - err: &HTTPError{ - StatusCode: 500, - Message: "Internal server error", - }, - expectedMsg: "HTTP 500: Internal server error", - isRetryable: true, - }, - { - name: "502 Bad Gateway", - err: &HTTPError{ - StatusCode: 502, - }, - expectedMsg: "HTTP 502", - isRetryable: true, - }, - { - name: "503 Service Unavailable", - err: &HTTPError{ - StatusCode: 503, - }, - expectedMsg: "HTTP 503", - isRetryable: true, - }, - { - name: "504 Gateway Timeout", - err: &HTTPError{ - StatusCode: 504, - }, - expectedMsg: "HTTP 504", - isRetryable: true, - }, - { - name: "400 Bad Request", - err: &HTTPError{ - StatusCode: 400, - Message: "Invalid request", - }, - expectedMsg: "HTTP 400: Invalid request", - isRetryable: false, - }, - { - name: "401 Unauthorized", - err: &HTTPError{ - StatusCode: 401, - Message: "Unauthorized", - }, - expectedMsg: "HTTP 401: Unauthorized", - isRetryable: false, - }, - { - name: "403 Forbidden", - err: &HTTPError{ - StatusCode: 403, - Message: "Forbidden", - }, - expectedMsg: "HTTP 403: Forbidden", - isRetryable: false, - }, + {"with message", &logtide.HTTPError{StatusCode: 429, Message: "rate limit"}, "429"}, + {"without message", &logtide.HTTPError{StatusCode: 503}, "503"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Test Error() method - if tt.err.Error() != tt.expectedMsg { - t.Errorf("HTTPError.Error() = %q, want %q", tt.err.Error(), tt.expectedMsg) - } - - // Test IsRetryable() method - if tt.err.IsRetryable() != tt.isRetryable { - t.Errorf("HTTPError.IsRetryable() = %v, want %v", tt.err.IsRetryable(), tt.isRetryable) + if msg := tt.err.Error(); msg == "" { + t.Errorf("HTTPError.Error() is empty") } - // Test errors.Is() compatibility - var target *HTTPError + var target *logtide.HTTPError if !errors.As(tt.err, &target) { - t.Error("errors.As() should match HTTPError type") + t.Error("errors.As should match *HTTPError") } }) } } func TestSentinelErrors(t *testing.T) { - // Test that sentinel errors are defined - if ErrInvalidAPIKey == nil { - t.Error("ErrInvalidAPIKey should not be nil") - } - if ErrCircuitOpen == nil { - t.Error("ErrCircuitOpen should not be nil") - } - if ErrTimeout == nil { - t.Error("ErrTimeout should not be nil") - } - if ErrClientClosed == nil { - t.Error("ErrClientClosed should not be nil") - } - - // Test errors.Is() with sentinel errors - err1 := ErrInvalidAPIKey - if !errors.Is(err1, ErrInvalidAPIKey) { - t.Error("errors.Is() should match ErrInvalidAPIKey") - } - - err2 := ErrCircuitOpen - if !errors.Is(err2, ErrCircuitOpen) { - t.Error("errors.Is() should match ErrCircuitOpen") + sentinels := []error{ + logtide.ErrClientClosed, + logtide.ErrCircuitOpen, + logtide.ErrInvalidDSN, + logtide.ErrServiceRequired, + } + for _, err := range sentinels { + if err == nil { + t.Errorf("sentinel error is nil") + } + if !errors.Is(err, err) { + t.Errorf("errors.Is failed for %v", err) + } } } diff --git a/examples/basic/go.mod b/examples/basic/go.mod index 92eb7fb..bd52aa1 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -1,6 +1,6 @@ module example-basic -go 1.25.4 +go 1.23.0 require github.com/logtide-dev/logtide-sdk-go v0.1.0 diff --git a/examples/basic/main.go b/examples/basic/main.go index 1a25310..636c2d5 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -5,71 +5,61 @@ import ( "log" "time" - "github.com/logtide-dev/logtide-sdk-go" + logtide "github.com/logtide-dev/logtide-sdk-go" ) func main() { - // Create LogTide client - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key_here"), - logtide.WithService("example-service"), - // Optional: customize configuration - // logtide.WithBatchSize(50), - // logtide.WithFlushInterval(10*time.Second), - ) - if err != nil { - log.Fatalf("Failed to create LogTide client: %v", err) - } - - // Ensure logs are flushed on exit - defer client.Close() + // Initialize LogTide — returns a flush func for deferred cleanup. + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key_here@api.logtide.dev", + Service: "example-service", + // Optional overrides: + // BatchSize: 50, + // FlushInterval: 10 * time.Second, + }) + defer flush() ctx := context.Background() - // Debug level - detailed debugging information - client.Debug(ctx, "Application started", map[string]interface{}{ + // Debug level — detailed debugging information. + logtide.Debug(ctx, "Application started", map[string]any{ "version": "1.0.0", "environment": "production", }) - // Info level - general informational messages - client.Info(ctx, "User logged in", map[string]interface{}{ + // Info level — general informational messages. + logtide.Info(ctx, "User logged in", map[string]any{ "user_id": 12345, "username": "john.doe", "ip": "192.168.1.1", }) - // Warn level - warning messages - client.Warn(ctx, "High memory usage detected", map[string]interface{}{ + // Warn level — warning messages. + logtide.Warn(ctx, "High memory usage detected", map[string]any{ "memory_usage_percent": 85, "threshold": 80, }) - // Error level - error events - client.Error(ctx, "Failed to connect to database", map[string]interface{}{ + // Error level — error events. + logtide.Error(ctx, "Failed to connect to database", map[string]any{ "database": "postgres", "host": "db.example.com", "error": "connection timeout after 30s", "retries": 3, }) - // Critical level - critical system errors - client.Critical(ctx, "System shutdown initiated", map[string]interface{}{ + // Critical level — critical system errors. + logtide.Critical(ctx, "System shutdown initiated", map[string]any{ "reason": "critical error", "uptime": "72h", }) - // Logs with nil metadata - client.Info(ctx, "Simple log without metadata", nil) + // Log with nil metadata. + logtide.Info(ctx, "Simple log without metadata", nil) - // Simulate some work + // Simulate some work. log.Println("Doing some work...") time.Sleep(2 * time.Second) - // Manual flush (optional - Close() will also flush) - if err := client.Flush(ctx); err != nil { - log.Printf("Failed to flush logs: %v", err) - } - log.Println("Example completed successfully!") } diff --git a/examples/echo/go.mod b/examples/echo/go.mod index 27250a2..ac61fa2 100644 --- a/examples/echo/go.mod +++ b/examples/echo/go.mod @@ -1,10 +1,26 @@ module example-echo -go 1.21 +go 1.23.0 require ( github.com/labstack/echo/v4 v4.12.0 github.com/logtide-dev/logtide-sdk-go v0.1.0 ) +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect +) + replace github.com/logtide-dev/logtide-sdk-go => ../.. diff --git a/examples/echo/go.sum b/examples/echo/go.sum new file mode 100644 index 0000000..621f968 --- /dev/null +++ b/examples/echo/go.sum @@ -0,0 +1,41 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/echo/main.go b/examples/echo/main.go index 2bf7a19..4cf9321 100644 --- a/examples/echo/main.go +++ b/examples/echo/main.go @@ -7,28 +7,23 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "github.com/logtide-dev/logtide-sdk-go" + logtide "github.com/logtide-dev/logtide-sdk-go" ) func main() { - // Create LogTide client - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key_here"), - logtide.WithService("echo-example"), - ) - if err != nil { - log.Fatalf("Failed to create LogTide client: %v", err) - } - defer client.Close() + // Initialize LogTide. + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key_here@api.logtide.dev", + Service: "echo-example", + }) + defer flush() - // Create Echo instance - e := echo.New() + client := logtide.CurrentHub().Client() - // Middleware + e := echo.New() e.Use(middleware.Recover()) e.Use(LogtideMiddleware(client)) - // Routes e.GET("/", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{ "message": "Hello from Echo!", @@ -37,13 +32,10 @@ func main() { e.GET("/user/:id", func(c echo.Context) error { userID := c.Param("id") - - // Log within handler - client.Info(c.Request().Context(), "Fetching user details", map[string]interface{}{ + client.Info(c.Request().Context(), "Fetching user details", map[string]any{ "user_id": userID, }) - - return c.JSON(http.StatusOK, map[string]interface{}{ + return c.JSON(http.StatusOK, map[string]any{ "user_id": userID, "name": "Jane Doe", }) @@ -57,76 +49,56 @@ func main() { var req LoginRequest if err := c.Bind(&req); err != nil { - client.Error(c.Request().Context(), "Invalid login request", map[string]interface{}{ - "error": err.Error(), - }) - return c.JSON(http.StatusBadRequest, map[string]string{ + client.Error(c.Request().Context(), "Invalid login request", map[string]any{ "error": err.Error(), }) + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - // Simulate login - client.Info(c.Request().Context(), "User login attempt", map[string]interface{}{ + client.Info(c.Request().Context(), "User login attempt", map[string]any{ "username": req.Username, "success": true, }) - - return c.JSON(http.StatusOK, map[string]interface{}{ + return c.JSON(http.StatusOK, map[string]any{ "message": "Login successful", "token": "sample-jwt-token", }) }) e.GET("/error", func(c echo.Context) error { - // Simulate an error - client.Error(c.Request().Context(), "Simulated error endpoint", map[string]interface{}{ + client.Error(c.Request().Context(), "Simulated error endpoint", map[string]any{ "endpoint": "/error", "ip": c.RealIP(), }) - return c.JSON(http.StatusInternalServerError, map[string]string{ "error": "Internal server error", }) }) - // Start server log.Println("Starting Echo server on :8080") if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed { log.Fatalf("Failed to start server: %v", err) } } -// LogtideMiddleware creates an Echo middleware that logs all requests to LogTide +// LogtideMiddleware logs each HTTP request to LogTide. func LogtideMiddleware(client *logtide.Client) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - // Record start time start := time.Now() - - // Process request - err := next(c) - - // Calculate duration + handlerErr := next(c) duration := time.Since(start) - // Get response status statusCode := c.Response().Status - - // Handle error from handler - if err != nil { - // Echo's error handler will set the status code - if he, ok := err.(*echo.HTTPError); ok { + if handlerErr != nil { + if he, ok := handlerErr.(*echo.HTTPError); ok { statusCode = he.Code } else { statusCode = http.StatusInternalServerError } } - // Determine log level based on status code - logLevel := getLogLevel(statusCode) - - // Prepare metadata - metadata := map[string]interface{}{ + metadata := map[string]any{ "method": c.Request().Method, "path": c.Request().URL.Path, "status": statusCode, @@ -135,36 +107,21 @@ func LogtideMiddleware(client *logtide.Client) echo.MiddlewareFunc { "user_agent": c.Request().UserAgent(), "query_params": c.QueryParams().Encode(), } - - // Add error if present - if err != nil { - metadata["error"] = err.Error() + if handlerErr != nil { + metadata["error"] = handlerErr.Error() } - // Log the request - message := "HTTP request completed" - switch logLevel { - case logtide.LogLevelError: - client.Error(c.Request().Context(), message, metadata) - case logtide.LogLevelWarn: - client.Warn(c.Request().Context(), message, metadata) + msg := "HTTP request completed" + switch { + case statusCode >= 500: + client.Error(c.Request().Context(), msg, metadata) + case statusCode >= 400: + client.Warn(c.Request().Context(), msg, metadata) default: - client.Info(c.Request().Context(), message, metadata) + client.Info(c.Request().Context(), msg, metadata) } - return err + return handlerErr } } } - -// getLogLevel determines the log level based on HTTP status code -func getLogLevel(statusCode int) logtide.LogLevel { - switch { - case statusCode >= 500: - return logtide.LogLevelError - case statusCode >= 400: - return logtide.LogLevelWarn - default: - return logtide.LogLevelInfo - } -} diff --git a/examples/gin/go.mod b/examples/gin/go.mod index 1c465fe..9cdceef 100644 --- a/examples/gin/go.mod +++ b/examples/gin/go.mod @@ -1,10 +1,41 @@ module example-gin -go 1.21 +go 1.23.0 require ( github.com/gin-gonic/gin v1.10.0 github.com/logtide-dev/logtide-sdk-go v0.1.0 ) +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + replace github.com/logtide-dev/logtide-sdk-go => ../.. diff --git a/examples/gin/go.sum b/examples/gin/go.sum new file mode 100644 index 0000000..fa96106 --- /dev/null +++ b/examples/gin/go.sum @@ -0,0 +1,92 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/examples/gin/main.go b/examples/gin/main.go index e089946..c0c17c8 100644 --- a/examples/gin/main.go +++ b/examples/gin/main.go @@ -5,67 +5,52 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/logtide-dev/logtide-sdk-go" + logtide "github.com/logtide-dev/logtide-sdk-go" ) func main() { - // Create LogTide client - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key_here"), - logtide.WithService("gin-example"), - ) - if err != nil { - log.Fatalf("Failed to create LogTide client: %v", err) - } - defer client.Close() + // Initialize LogTide. + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key_here@api.logtide.dev", + Service: "gin-example", + }) + defer flush() - // Create Gin router - r := gin.Default() + client := logtide.CurrentHub().Client() - // Add LogTide middleware + r := gin.Default() r.Use(LogtideMiddleware(client)) - // Define routes r.GET("/", func(c *gin.Context) { - c.JSON(200, gin.H{ - "message": "Hello from Gin!", - }) + c.JSON(200, gin.H{"message": "Hello from Gin!"}) }) r.GET("/user/:id", func(c *gin.Context) { userID := c.Param("id") - - // Log within handler - client.Info(c.Request.Context(), "Fetching user details", map[string]interface{}{ - "user_id": userID, - }) - - c.JSON(200, gin.H{ + client.Info(c.Request.Context(), "Fetching user details", map[string]any{ "user_id": userID, - "name": "John Doe", }) + c.JSON(200, gin.H{"user_id": userID, "name": "John Doe"}) }) r.POST("/login", func(c *gin.Context) { - var json struct { + var body struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` } - if err := c.ShouldBindJSON(&json); err != nil { - client.Error(c.Request.Context(), "Invalid login request", map[string]interface{}{ + if err := c.ShouldBindJSON(&body); err != nil { + client.Error(c.Request.Context(), "Invalid login request", map[string]any{ "error": err.Error(), }) c.JSON(400, gin.H{"error": err.Error()}) return } - // Simulate login - client.Info(c.Request.Context(), "User login attempt", map[string]interface{}{ - "username": json.Username, + client.Info(c.Request.Context(), "User login attempt", map[string]any{ + "username": body.Username, "success": true, }) - c.JSON(200, gin.H{ "message": "Login successful", "token": "sample-jwt-token", @@ -73,42 +58,28 @@ func main() { }) r.GET("/error", func(c *gin.Context) { - // Simulate an error - client.Error(c.Request.Context(), "Simulated error endpoint", map[string]interface{}{ + client.Error(c.Request.Context(), "Simulated error endpoint", map[string]any{ "endpoint": "/error", "ip": c.ClientIP(), }) - - c.JSON(500, gin.H{ - "error": "Internal server error", - }) + c.JSON(500, gin.H{"error": "Internal server error"}) }) - // Start server log.Println("Starting Gin server on :8080") if err := r.Run(":8080"); err != nil { log.Fatalf("Failed to start server: %v", err) } } -// LogtideMiddleware creates a Gin middleware that logs all requests to LogTide +// LogtideMiddleware logs each HTTP request to LogTide. func LogtideMiddleware(client *logtide.Client) gin.HandlerFunc { return func(c *gin.Context) { - // Record start time start := time.Now() - - // Process request c.Next() - - // Calculate duration duration := time.Since(start) - // Determine log level based on status code statusCode := c.Writer.Status() - logLevel := getLogLevel(statusCode) - - // Prepare metadata - metadata := map[string]interface{}{ + metadata := map[string]any{ "method": c.Request.Method, "path": c.Request.URL.Path, "status": statusCode, @@ -117,33 +88,18 @@ func LogtideMiddleware(client *logtide.Client) gin.HandlerFunc { "user_agent": c.Request.UserAgent(), "query_params": c.Request.URL.RawQuery, } - - // Add error if present if len(c.Errors) > 0 { metadata["errors"] = c.Errors.String() } - // Log the request - message := "HTTP request completed" - switch logLevel { - case logtide.LogLevelError: - client.Error(c.Request.Context(), message, metadata) - case logtide.LogLevelWarn: - client.Warn(c.Request.Context(), message, metadata) + msg := "HTTP request completed" + switch { + case statusCode >= 500: + client.Error(c.Request.Context(), msg, metadata) + case statusCode >= 400: + client.Warn(c.Request.Context(), msg, metadata) default: - client.Info(c.Request.Context(), message, metadata) + client.Info(c.Request.Context(), msg, metadata) } } } - -// getLogLevel determines the log level based on HTTP status code -func getLogLevel(statusCode int) logtide.LogLevel { - switch { - case statusCode >= 500: - return logtide.LogLevelError - case statusCode >= 400: - return logtide.LogLevelWarn - default: - return logtide.LogLevelInfo - } -} diff --git a/examples/otel/go.mod b/examples/otel/go.mod index 31f1dc3..aa32e1e 100644 --- a/examples/otel/go.mod +++ b/examples/otel/go.mod @@ -1,6 +1,6 @@ module example-otel -go 1.25.4 +go 1.23.0 require ( github.com/logtide-dev/logtide-sdk-go v0.1.0 diff --git a/examples/otel/main.go b/examples/otel/main.go index 3bc9c4b..bc4e47d 100644 --- a/examples/otel/main.go +++ b/examples/otel/main.go @@ -10,11 +10,11 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" - "github.com/logtide-dev/logtide-sdk-go" + logtide "github.com/logtide-dev/logtide-sdk-go" ) func main() { - // Set up OpenTelemetry tracer + // Set up OpenTelemetry tracer. exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) if err != nil { log.Fatalf("Failed to create trace exporter: %v", err) @@ -32,27 +32,27 @@ func main() { otel.SetTracerProvider(tp) tracer := tp.Tracer("logtide-example") - // Create LogTide client - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key_here"), - logtide.WithService("otel-example"), - ) - if err != nil { - log.Fatalf("Failed to create LogTide client: %v", err) - } - defer client.Close() + // Initialize LogTide. + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key_here@api.logtide.dev", + Service: "otel-example", + }) + defer flush() + + // Create client reference for helpers below. + client := logtide.CurrentHub().Client() - // Example 1: Root span + // Example 1: Root span. ctx := context.Background() ctx, span := tracer.Start(ctx, "main-operation") defer span.End() - // This log will include the trace ID and span ID - client.Info(ctx, "Starting main operation", map[string]interface{}{ + // This log will automatically include trace_id and span_id from OTel context. + client.Info(ctx, "Starting main operation", map[string]any{ "operation": "main", }) - // Simulate some work with nested spans + // Simulate some work with nested spans. processOrder(ctx, tracer, client, "order-123") processPayment(ctx, tracer, client, "payment-456") @@ -62,71 +62,60 @@ func main() { log.Println("Check the console output to see trace IDs included in logs") } -// processOrder simulates order processing with a child span +// processOrder simulates order processing with a child span. func processOrder(ctx context.Context, tracer trace.Tracer, client *logtide.Client, orderID string) { ctx, span := tracer.Start(ctx, "process-order") defer span.End() - // Log with trace context - trace_id and span_id will be automatically extracted - client.Info(ctx, "Processing order", map[string]interface{}{ + client.Info(ctx, "Processing order", map[string]any{ "order_id": orderID, "status": "pending", }) - // Simulate work time.Sleep(100 * time.Millisecond) - - // Validate order validateOrder(ctx, tracer, client, orderID) - client.Info(ctx, "Order processed successfully", map[string]interface{}{ + client.Info(ctx, "Order processed successfully", map[string]any{ "order_id": orderID, "status": "completed", }) } -// validateOrder simulates order validation with another child span +// validateOrder simulates order validation with another child span. func validateOrder(ctx context.Context, tracer trace.Tracer, client *logtide.Client, orderID string) { ctx, span := tracer.Start(ctx, "validate-order") defer span.End() - client.Debug(ctx, "Validating order", map[string]interface{}{ - "order_id": orderID, - }) - - // Simulate validation + client.Debug(ctx, "Validating order", map[string]any{"order_id": orderID}) time.Sleep(50 * time.Millisecond) - - client.Debug(ctx, "Order validation completed", map[string]interface{}{ + client.Debug(ctx, "Order validation completed", map[string]any{ "order_id": orderID, "valid": true, }) } -// processPayment simulates payment processing +// processPayment simulates payment processing. func processPayment(ctx context.Context, tracer trace.Tracer, client *logtide.Client, paymentID string) { ctx, span := tracer.Start(ctx, "process-payment") defer span.End() - client.Info(ctx, "Processing payment", map[string]interface{}{ + client.Info(ctx, "Processing payment", map[string]any{ "payment_id": paymentID, "amount": 99.99, "currency": "USD", }) - // Simulate payment processing time.Sleep(150 * time.Millisecond) - // Simulate potential error if paymentID == "payment-error" { - client.Error(ctx, "Payment processing failed", map[string]interface{}{ + client.Error(ctx, "Payment processing failed", map[string]any{ "payment_id": paymentID, "error": "insufficient funds", }) return } - client.Info(ctx, "Payment processed successfully", map[string]interface{}{ + client.Info(ctx, "Payment processed successfully", map[string]any{ "payment_id": paymentID, "transaction_id": "txn-789", }) diff --git a/examples/stdlib/go.mod b/examples/stdlib/go.mod index cd1fbdb..1b448ba 100644 --- a/examples/stdlib/go.mod +++ b/examples/stdlib/go.mod @@ -1,7 +1,12 @@ module example-stdlib -go 1.21 +go 1.23.0 require github.com/logtide-dev/logtide-sdk-go v0.1.0 +require ( + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect +) + replace github.com/logtide-dev/logtide-sdk-go => ../.. diff --git a/examples/stdlib/go.sum b/examples/stdlib/go.sum new file mode 100644 index 0000000..2b82698 --- /dev/null +++ b/examples/stdlib/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/stdlib/main.go b/examples/stdlib/main.go index 126743a..920f173 100644 --- a/examples/stdlib/main.go +++ b/examples/stdlib/main.go @@ -6,24 +6,23 @@ import ( "net/http" "time" - "github.com/logtide-dev/logtide-sdk-go" + logtide "github.com/logtide-dev/logtide-sdk-go" + "github.com/logtide-dev/logtide-sdk-go/integrations/nethttp" ) func main() { - // Create LogTide client - client, err := logtide.New( - logtide.WithAPIKey("lp_your_api_key_here"), - logtide.WithService("stdlib-example"), - ) - if err != nil { - log.Fatalf("Failed to create LogTide client: %v", err) - } - defer client.Close() + // Initialize LogTide. + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key_here@api.logtide.dev", + Service: "stdlib-example", + }) + defer flush() - // Create router + client := logtide.CurrentHub().Client() + + // Create router. mux := http.NewServeMux() - // Define routes mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ @@ -34,13 +33,12 @@ func main() { mux.HandleFunc("/user/", func(w http.ResponseWriter, r *http.Request) { userID := r.URL.Path[len("/user/"):] - // Log within handler - client.Info(r.Context(), "Fetching user details", map[string]interface{}{ + client.Info(r.Context(), "Fetching user details", map[string]any{ "user_id": userID, }) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "user_id": userID, "name": "Alice Smith", }) @@ -59,40 +57,37 @@ func main() { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - client.Error(r.Context(), "Invalid login request", map[string]interface{}{ + client.Error(r.Context(), "Invalid login request", map[string]any{ "error": err.Error(), }) http.Error(w, err.Error(), http.StatusBadRequest) return } - // Simulate login - client.Info(r.Context(), "User login attempt", map[string]interface{}{ + client.Info(r.Context(), "User login attempt", map[string]any{ "username": req.Username, "success": true, }) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "message": "Login successful", "token": "sample-jwt-token", }) }) mux.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) { - // Simulate an error - client.Error(r.Context(), "Simulated error endpoint", map[string]interface{}{ + client.Error(r.Context(), "Simulated error endpoint", map[string]any{ "endpoint": "/error", "ip": r.RemoteAddr, }) - http.Error(w, "Internal server error", http.StatusInternalServerError) }) - // Wrap with logging middleware - handler := LoggingMiddleware(client, mux) + // Wrap with the LogTide net/http middleware — injects Hub into each request context + // and enriches it with HTTP metadata (method, path, IP, traceparent header). + handler := nethttp.Middleware(mux) - // Start server server := &http.Server{ Addr: ":8080", Handler: handler, @@ -106,82 +101,3 @@ func main() { log.Fatalf("Failed to start server: %v", err) } } - -// responseWriter wraps http.ResponseWriter to capture status code -type responseWriter struct { - http.ResponseWriter - statusCode int - written bool -} - -func (rw *responseWriter) WriteHeader(code int) { - if !rw.written { - rw.statusCode = code - rw.written = true - rw.ResponseWriter.WriteHeader(code) - } -} - -func (rw *responseWriter) Write(b []byte) (int, error) { - if !rw.written { - rw.WriteHeader(http.StatusOK) - } - return rw.ResponseWriter.Write(b) -} - -// LoggingMiddleware creates a middleware that logs all requests to LogTide -func LoggingMiddleware(client *logtide.Client, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Record start time - start := time.Now() - - // Wrap response writer to capture status code - rw := &responseWriter{ - ResponseWriter: w, - statusCode: http.StatusOK, - } - - // Process request - next.ServeHTTP(rw, r) - - // Calculate duration - duration := time.Since(start) - - // Determine log level based on status code - logLevel := getLogLevel(rw.statusCode) - - // Prepare metadata - metadata := map[string]interface{}{ - "method": r.Method, - "path": r.URL.Path, - "status": rw.statusCode, - "duration_ms": duration.Milliseconds(), - "ip": r.RemoteAddr, - "user_agent": r.UserAgent(), - "query_params": r.URL.RawQuery, - } - - // Log the request - message := "HTTP request completed" - switch logLevel { - case logtide.LogLevelError: - client.Error(r.Context(), message, metadata) - case logtide.LogLevelWarn: - client.Warn(r.Context(), message, metadata) - default: - client.Info(r.Context(), message, metadata) - } - }) -} - -// getLogLevel determines the log level based on HTTP status code -func getLogLevel(statusCode int) logtide.LogLevel { - switch { - case statusCode >= 500: - return logtide.LogLevelError - case statusCode >= 400: - return logtide.LogLevelWarn - default: - return logtide.LogLevelInfo - } -} diff --git a/go.mod b/go.mod index 4cea0b1..470ebad 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/logtide-dev/logtide-sdk-go -go 1.25.4 +go 1.23.0 require ( go.opentelemetry.io/otel/sdk v1.38.0 diff --git a/hub.go b/hub.go new file mode 100644 index 0000000..9a27130 --- /dev/null +++ b/hub.go @@ -0,0 +1,334 @@ +package logtide + +import ( + "context" + "sync" + "time" +) + +// layer is one entry in the Hub's scope stack. +type layer struct { + client *Client + scope *Scope +} + +// Hub pairs a Client with a stack of Scopes. +// +// The global Hub singleton is accessed via CurrentHub(). Per-request isolation +// is achieved by cloning the Hub with Clone() at request boundaries: +// +// func middleware(next http.Handler) http.Handler { +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// hub := logtide.CurrentHub().Clone() +// hub.ConfigureScope(func(s *logtide.Scope) { +// s.SetTag("request_id", r.Header.Get("X-Request-ID")) +// }) +// ctx := logtide.SetHubOnContext(r.Context(), hub) +// next.ServeHTTP(w, r.WithContext(ctx)) +// }) +// } +type Hub struct { + mu sync.RWMutex + stack []*layer + lastEventID EventID +} + +// NewHub creates a Hub with the given client and initial scope. +// If scope is nil, an empty Scope is created using client.Options().MaxBreadcrumbs. +func NewHub(client *Client, scope *Scope) *Hub { + if scope == nil { + if client != nil { + scope = NewScope(client.opts.MaxBreadcrumbs) + } else { + scope = NewScope(100) + } + } + return &Hub{ + stack: []*layer{{client: client, scope: scope}}, + } +} + +// Client returns the Client at the top of the stack. +func (h *Hub) Client() *Client { + h.mu.RLock() + defer h.mu.RUnlock() + return h.top().client +} + +// Scope returns the Scope at the top of the stack. +func (h *Hub) Scope() *Scope { + h.mu.RLock() + defer h.mu.RUnlock() + return h.top().scope +} + +// PushScope clones the current top Scope and pushes a new layer reusing +// the same Client. Returns the new child Scope for configuration. +// Callers must pair with PopScope. +func (h *Hub) PushScope() *Scope { + h.mu.Lock() + defer h.mu.Unlock() + + top := h.top() + child := top.scope.Clone() + h.stack = append(h.stack, &layer{client: top.client, scope: child}) + return child +} + +// PopScope removes the top layer. It is a no-op if the stack has only one layer. +func (h *Hub) PopScope() { + h.mu.Lock() + defer h.mu.Unlock() + + if len(h.stack) > 1 { + h.stack = h.stack[:len(h.stack)-1] + } +} + +// WithScope executes fn in a temporary child scope, then restores the previous one. +// It is equivalent to PushScope / defer PopScope. +func (h *Hub) WithScope(fn func(scope *Scope)) { + scope := h.PushScope() + defer h.PopScope() + fn(scope) +} + +// ConfigureScope calls fn with the current top Scope for in-place mutation. +func (h *Hub) ConfigureScope(fn func(scope *Scope)) { + h.mu.RLock() + scope := h.top().scope + h.mu.RUnlock() + fn(scope) +} + +// BindClient replaces the Client on the current top layer. +func (h *Hub) BindClient(client *Client) { + h.mu.Lock() + h.top().client = client + h.mu.Unlock() +} + +// Clone returns a new Hub with a deep copy of the current top layer. +// Use this at request boundaries to get per-request scope isolation. +func (h *Hub) Clone() *Hub { + h.mu.RLock() + top := h.top() + client := top.client + scope := top.scope.Clone() + h.mu.RUnlock() + + return &Hub{ + stack: []*layer{{client: client, scope: scope}}, + } +} + +// AddBreadcrumb adds a breadcrumb to the current top Scope. +func (h *Hub) AddBreadcrumb(bc *Breadcrumb, hint BreadcrumbHint) { + h.mu.RLock() + scope := h.top().scope + var beforeFn func(*Breadcrumb, BreadcrumbHint) *Breadcrumb + if c := h.top().client; c != nil { + beforeFn = c.opts.BeforeBreadcrumb + } + h.mu.RUnlock() + + scope.AddBreadcrumb(bc, beforeFn) +} + +// LastEventID returns the EventID of the most recently captured entry. +func (h *Hub) LastEventID() EventID { + h.mu.RLock() + defer h.mu.RUnlock() + return h.lastEventID +} + +// --- Log-level methods --- + +// Debug captures a debug-level log entry via the Hub's Client and Scope. +func (h *Hub) Debug(ctx context.Context, message string, metadata map[string]any) EventID { + return h.capture(ctx, LevelDebug, message, metadata) +} + +// Info captures an info-level log entry. +func (h *Hub) Info(ctx context.Context, message string, metadata map[string]any) EventID { + return h.capture(ctx, LevelInfo, message, metadata) +} + +// Warn captures a warn-level log entry. +func (h *Hub) Warn(ctx context.Context, message string, metadata map[string]any) EventID { + return h.capture(ctx, LevelWarn, message, metadata) +} + +// Error captures an error-level log entry. +func (h *Hub) Error(ctx context.Context, message string, metadata map[string]any) EventID { + return h.capture(ctx, LevelError, message, metadata) +} + +// Critical captures a critical-level log entry. +func (h *Hub) Critical(ctx context.Context, message string, metadata map[string]any) EventID { + return h.capture(ctx, LevelCritical, message, metadata) +} + +// CaptureError captures err as an error-level entry with a stack trace. +// Returns the EventID of the entry, or "" if it was dropped. +func (h *Hub) CaptureError(ctx context.Context, err error, metadata map[string]any) EventID { + client := h.Client() + if client == nil { + return "" + } + ctx = h.injectScopeIfMissing(ctx) + id := client.CaptureError(ctx, err, metadata) + h.recordEventID(id) + return id +} + +// Flush flushes all buffered entries using timeout as the deadline. +// Returns true if all entries were delivered before the deadline. +func (h *Hub) Flush(timeout time.Duration) bool { + client := h.Client() + if client == nil { + return true + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return client.Flush(ctx) +} + +// --- internal --- + +func (h *Hub) top() *layer { + return h.stack[len(h.stack)-1] +} + +func (h *Hub) capture(ctx context.Context, level Level, message string, metadata map[string]any) EventID { + client := h.Client() + if client == nil { + return "" + } + ctx = h.injectScopeIfMissing(ctx) + + entry := &LogEntry{Level: level, Message: message} + if metadata != nil { + entry.Metadata = metadata + } + id := client.captureEntry(ctx, entry, nil) + h.recordEventID(id) + return id +} + +// injectScopeIfMissing ensures the hub's scope is available in ctx. +func (h *Hub) injectScopeIfMissing(ctx context.Context) context.Context { + if ScopeFromContext(ctx) != nil { + return ctx + } + return WithScope(ctx, h.Scope()) +} + +func (h *Hub) recordEventID(id EventID) { + if id == "" { + return + } + h.mu.Lock() + h.lastEventID = id + h.mu.Unlock() +} + +// --- Global singleton --- + +var ( + globalHub *Hub + globalHubMu sync.RWMutex +) + +func init() { + // Start with a no-op hub (nil client) so package-level calls before Init + // are silently dropped rather than panicking. + globalHub = NewHub(nil, NewScope(100)) +} + +// CurrentHub returns the global Hub singleton. +func CurrentHub() *Hub { + globalHubMu.RLock() + defer globalHubMu.RUnlock() + return globalHub +} + +// Init initialises the global Hub with a new Client built from opts. +// +// It returns a flush function that should be deferred at program startup to +// ensure all buffered entries are delivered on shutdown: +// +// flush := logtide.Init(logtide.ClientOptions{ +// DSN: "https://lp_abc@api.logtide.dev", +// Service: "my-service", +// }) +// defer flush() +// +// Init may be called multiple times; each call replaces the global Client. +func Init(opts ClientOptions) func() { + client, err := NewClient(opts) + if err != nil { + if opts.Debug && opts.DebugWriter != nil { + logDebug(opts.DebugWriter, "Init failed: %v", err) + } + return func() {} + } + + hub := NewHub(client, NewScope(opts.MaxBreadcrumbs)) + + globalHubMu.Lock() + globalHub = hub + globalHubMu.Unlock() + + return func() { hub.Flush(client.Options().FlushTimeout) } +} + +// --- Package-level API (delegates to hub from ctx or global hub) --- + +func hubFrom(ctx context.Context) *Hub { + if h := GetHubFromContext(ctx); h != nil { + return h + } + return CurrentHub() +} + +// Debug captures a debug-level log entry via the current Hub. +func Debug(ctx context.Context, message string, metadata map[string]any) EventID { + return hubFrom(ctx).Debug(ctx, message, metadata) +} + +// Info captures an info-level log entry via the current Hub. +func Info(ctx context.Context, message string, metadata map[string]any) EventID { + return hubFrom(ctx).Info(ctx, message, metadata) +} + +// Warn captures a warn-level log entry via the current Hub. +func Warn(ctx context.Context, message string, metadata map[string]any) EventID { + return hubFrom(ctx).Warn(ctx, message, metadata) +} + +// Error captures an error-level log entry via the current Hub. +func Error(ctx context.Context, message string, metadata map[string]any) EventID { + return hubFrom(ctx).Error(ctx, message, metadata) +} + +// Critical captures a critical-level log entry via the current Hub. +func Critical(ctx context.Context, message string, metadata map[string]any) EventID { + return hubFrom(ctx).Critical(ctx, message, metadata) +} + +// CaptureError captures err as an error-level entry with a stack trace. +func CaptureError(ctx context.Context, err error, metadata map[string]any) EventID { + return hubFrom(ctx).CaptureError(ctx, err, metadata) +} + +// AddBreadcrumb adds a breadcrumb to the current Hub's top Scope. +func AddBreadcrumb(ctx context.Context, bc *Breadcrumb, hint BreadcrumbHint) { + hubFrom(ctx).AddBreadcrumb(bc, hint) +} + +// Flush flushes all buffered entries using the global Hub's flush timeout. +// Returns true if all entries were delivered. +func Flush(timeout time.Duration) bool { + return CurrentHub().Flush(timeout) +} diff --git a/hub_test.go b/hub_test.go new file mode 100644 index 0000000..624a0dc --- /dev/null +++ b/hub_test.go @@ -0,0 +1,310 @@ +package logtide_test + +import ( + "context" + "encoding/json" + "net/http" + "sync/atomic" + "testing" + "time" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +// newNoopClient returns a Client backed by NoopTransport for hub tests. +func newNoopClient(t *testing.T) *logtide.Client { + t.Helper() + c, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(c.Close) + return c +} + +func TestHubClientAndScope(t *testing.T) { + client := newNoopClient(t) + scope := logtide.NewScope(10) + hub := logtide.NewHub(client, scope) + + if hub.Client() != client { + t.Error("Client() should return the bound client") + } + if hub.Scope() != scope { + t.Error("Scope() should return the bound scope") + } +} + +func TestHubNilScopeCreatedAutomatically(t *testing.T) { + client := newNoopClient(t) + hub := logtide.NewHub(client, nil) + if hub.Scope() == nil { + t.Error("Scope() should not be nil when nil is passed to NewHub") + } +} + +func TestHubPushPopScope(t *testing.T) { + hub := logtide.NewHub(newNoopClient(t), nil) + original := hub.Scope() + original.SetTag("base", "yes") + + pushed := hub.PushScope() + pushed.SetTag("pushed", "yes") + + // The pushed scope has the base tag (it's a clone). + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := pushed.ApplyToEntry(entry) + if enriched.Tags["base"] != "yes" { + t.Error("pushed scope should inherit base tags") + } + if enriched.Tags["pushed"] != "yes" { + t.Error("pushed scope should have its own tags") + } + + // After pop, top scope is the original again. + hub.PopScope() + if hub.Scope() != original { + t.Error("after PopScope, should return original scope") + } + // Original should not have the "pushed" tag. + entry2 := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + e2 := original.ApplyToEntry(entry2) + if _, ok := e2.Tags["pushed"]; ok { + t.Error("original scope should not have tag set on pushed scope") + } +} + +func TestHubPopScopeNoop(t *testing.T) { + // PopScope when stack has only one layer is a no-op. + hub := logtide.NewHub(newNoopClient(t), nil) + hub.PopScope() + hub.PopScope() + if hub.Scope() == nil { + t.Error("scope should not be nil after excess PopScope calls") + } +} + +func TestHubWithScope(t *testing.T) { + hub := logtide.NewHub(newNoopClient(t), nil) + original := hub.Scope() + + var innerScope *logtide.Scope + hub.WithScope(func(s *logtide.Scope) { + innerScope = s + s.SetTag("inner", "yes") + // Inside WithScope, top should be the child. + if hub.Scope() == original { + t.Error("inside WithScope, scope should be child, not original") + } + }) + + // After WithScope, top is restored. + if hub.Scope() != original { + t.Error("after WithScope, original scope should be restored") + } + _ = innerScope +} + +func TestHubConfigureScope(t *testing.T) { + hub := logtide.NewHub(newNoopClient(t), nil) + hub.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("configured", "true") + }) + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + if v := hub.Scope().ApplyToEntry(entry).Tags["configured"]; v != "true" { + t.Errorf("tag configured = %q, want true", v) + } +} + +func TestHubBindClient(t *testing.T) { + hub := logtide.NewHub(nil, nil) + if hub.Client() != nil { + t.Error("initial client should be nil") + } + client := newNoopClient(t) + hub.BindClient(client) + if hub.Client() != client { + t.Error("BindClient should update the client") + } +} + +func TestHubCloneIsIndependent(t *testing.T) { + hub := logtide.NewHub(newNoopClient(t), nil) + hub.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("original", "yes") + }) + + clone := hub.Clone() + clone.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("clone-only", "yes") + }) + + // Original hub should NOT have the clone-only tag. + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + orig := hub.Scope().ApplyToEntry(entry) + if _, ok := orig.Tags["clone-only"]; ok { + t.Error("original hub scope should not have tag set on clone") + } + // Clone should have both tags. + cloneEntry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + ce := clone.Scope().ApplyToEntry(cloneEntry) + if ce.Tags["original"] != "yes" { + t.Error("clone should inherit original tags") + } + if ce.Tags["clone-only"] != "yes" { + t.Error("clone should have its own tags") + } +} + +func TestHubNilClientSilentDrop(t *testing.T) { + hub := logtide.NewHub(nil, nil) + ctx := context.Background() + + // All capture methods should return "" without panicking. + if id := hub.Debug(ctx, "msg", nil); id != "" { + t.Errorf("Debug with nil client = %q, want \"\"", id) + } + if id := hub.Info(ctx, "msg", nil); id != "" { + t.Errorf("Info with nil client = %q, want \"\"", id) + } + if id := hub.Warn(ctx, "msg", nil); id != "" { + t.Errorf("Warn with nil client = %q, want \"\"", id) + } + if id := hub.Error(ctx, "msg", nil); id != "" { + t.Errorf("Error with nil client = %q, want \"\"", id) + } + if id := hub.Critical(ctx, "msg", nil); id != "" { + t.Errorf("Critical with nil client = %q, want \"\"", id) + } + if id := hub.CaptureError(ctx, context.DeadlineExceeded, nil); id != "" { + t.Errorf("CaptureError with nil client = %q, want \"\"", id) + } +} + +func TestHubFlushNilClientReturnsTrue(t *testing.T) { + hub := logtide.NewHub(nil, nil) + if !hub.Flush(time.Second) { + t.Error("Flush with nil client should return true") + } +} + +func TestHubLastEventID(t *testing.T) { + var received int32 + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&received, 1) + w.WriteHeader(http.StatusOK) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + hub := logtide.NewHub(client, nil) + if hub.LastEventID() != "" { + t.Error("LastEventID should be empty initially") + } + + id := hub.Info(context.Background(), "hello", nil) + if id == "" { + t.Fatal("Info should return a non-empty EventID") + } + if hub.LastEventID() != id { + t.Errorf("LastEventID = %q, want %q", hub.LastEventID(), id) + } +} + +func TestHubAddBreadcrumb(t *testing.T) { + hub := logtide.NewHub(newNoopClient(t), nil) + hub.AddBreadcrumb(&logtide.Breadcrumb{Message: "crumb", Timestamp: time.Now()}, nil) + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := hub.Scope().ApplyToEntry(entry) + if len(enriched.Breadcrumbs) != 1 { + t.Errorf("breadcrumbs = %d, want 1", len(enriched.Breadcrumbs)) + } +} + +func TestHubScopeEnrichesEntries(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + w.WriteHeader(http.StatusOK) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + hub := logtide.NewHub(client, nil) + hub.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("hub-tag", "present") + }) + + hub.Info(context.Background(), "msg", nil) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + client.Close() + + if len(captured) == 0 { + t.Fatal("no entries received") + } + if captured[0].Tags["hub-tag"] != "present" { + t.Errorf("hub-tag = %q, want present", captured[0].Tags["hub-tag"]) + } +} + +func TestPackageLevelInit(t *testing.T) { + var received int32 + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + atomic.AddInt32(&received, int32(len(req.Logs))) + w.WriteHeader(http.StatusOK) + }) + + flush := logtide.Init(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "pkg-test", + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + + ctx := context.Background() + logtide.Debug(ctx, "d", nil) + logtide.Info(ctx, "i", nil) + logtide.Warn(ctx, "w", nil) + logtide.Error(ctx, "e", nil) + logtide.Critical(ctx, "c", nil) + + flush() + time.Sleep(50 * time.Millisecond) + + if n := atomic.LoadInt32(&received); n != 5 { + t.Errorf("received %d, want 5", n) + } +} diff --git a/integration.go b/integration.go new file mode 100644 index 0000000..fcc451e --- /dev/null +++ b/integration.go @@ -0,0 +1,147 @@ +package logtide + +import ( + "fmt" + "os" + "runtime" +) + +// Integration augments or filters events within the Client pipeline. +// Integrations are installed once at Client construction time. +// All methods must be safe for concurrent use. +type Integration interface { + // Name returns a stable unique identifier for this integration. + Name() string + + // Setup is called once by NewClient after construction. + // Register processors via client.AddEventProcessor inside Setup. + Setup(client *Client) +} + +// --- Built-in integrations --- + +// EnvironmentIntegration attaches runtime context (Go version, OS, architecture) +// to every log entry as metadata. +type EnvironmentIntegration struct{} + +func (e *EnvironmentIntegration) Name() string { return "Environment" } + +func (e *EnvironmentIntegration) Setup(client *Client) { + goVersion := runtime.Version() + goos := runtime.GOOS + goarch := runtime.GOARCH + + client.AddEventProcessor(func(entry *LogEntry, _ *EventHint) *LogEntry { + if _, ok := entry.Metadata["runtime"]; ok { + return entry + } + // Copy before mutating to avoid a data race when the caller's metadata + // map is shared across concurrent log calls. + meta := make(map[string]any, len(entry.Metadata)+1) + for k, v := range entry.Metadata { + meta[k] = v + } + meta["runtime"] = map[string]any{ + "go": goVersion, + "os": goos, + "arch": goarch, + } + entry.Metadata = meta + return entry + }) +} + +// GlobalTagsIntegration applies ClientOptions.Tags to every log entry. +type GlobalTagsIntegration struct{} + +func (g *GlobalTagsIntegration) Name() string { return "GlobalTags" } + +func (g *GlobalTagsIntegration) Setup(client *Client) { + tags := client.Options().Tags + if len(tags) == 0 { + return + } + client.AddEventProcessor(func(entry *LogEntry, _ *EventHint) *LogEntry { + entry.Tags = mergeTags(tags, entry.Tags) + return entry + }) +} + +// defaultIntegrations returns the integration list installed by default. +func defaultIntegrations() []Integration { + return []Integration{ + &EnvironmentIntegration{}, + &GlobalTagsIntegration{}, + } +} + +// setupIntegrations processes the Integrations option, deduplicates by name, +// and calls Setup on each. +func setupIntegrations(client *Client, opts ClientOptions) { + list := defaultIntegrations() + if opts.Integrations != nil { + list = opts.Integrations(list) + } + + seen := make(map[string]struct{}, len(list)) + for _, i := range list { + name := i.Name() + if _, dup := seen[name]; dup { + continue + } + seen[name] = struct{}{} + i.Setup(client) + client.integrations = append(client.integrations, i) + } +} + +// --- Helpers --- + +// mergeTags merges base and override tags into a new map. +// Keys in override win on collision. +func mergeTags(base, override map[string]string) map[string]string { + merged := make(map[string]string, len(base)+len(override)) + for k, v := range base { + merged[k] = v + } + for k, v := range override { + merged[k] = v + } + return merged +} + +// --- Server name helper --- + +func resolveServerName(override string) string { + if override != "" { + return override + } + host, err := os.Hostname() + if err != nil { + return "" + } + return host +} + +// --- Validation --- + +func validateEntry(entry *LogEntry) error { + if entry.Service == "" { + return ErrServiceRequired + } + if len(entry.Service) > 100 { + return &ValidationError{Field: "service", Message: "service name must be 100 characters or less"} + } + if entry.Message == "" { + return &ValidationError{Field: "message", Message: "message is required"} + } + switch entry.Level { + case LevelDebug, LevelInfo, LevelWarn, LevelError, LevelCritical: + default: + return &ValidationError{ + Field: "level", + Message: fmt.Sprintf("invalid level %q (must be debug, info, warn, error, or critical)", entry.Level), + } + } + return nil +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..e71ee3c --- /dev/null +++ b/integration_test.go @@ -0,0 +1,249 @@ +package logtide_test + +import ( + "context" + "encoding/json" + "net/http" + "runtime" + "sync/atomic" + "testing" + "time" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +func TestEnvironmentIntegrationAddsRuntime(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + w.WriteHeader(http.StatusOK) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + client.Info(context.Background(), "hello", nil) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + client.Close() + + if len(captured) == 0 { + t.Fatal("no entries captured") + } + entry := captured[0] + if entry.Metadata == nil { + t.Fatal("metadata is nil; EnvironmentIntegration should have set it") + } + rtMeta, ok := entry.Metadata["runtime"].(map[string]any) + if !ok { + t.Fatalf("metadata[runtime] type = %T, want map[string]any", entry.Metadata["runtime"]) + } + if rtMeta["go"] != runtime.Version() { + t.Errorf("runtime.go = %v, want %v", rtMeta["go"], runtime.Version()) + } + if rtMeta["os"] != runtime.GOOS { + t.Errorf("runtime.os = %v, want %v", rtMeta["os"], runtime.GOOS) + } + if rtMeta["arch"] != runtime.GOARCH { + t.Errorf("runtime.arch = %v, want %v", rtMeta["arch"], runtime.GOARCH) + } +} + +func TestEnvironmentIntegrationDoesNotOverrideExisting(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + w.WriteHeader(http.StatusOK) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + // Entry with pre-set runtime metadata. + client.Info(context.Background(), "hello", map[string]any{"runtime": "custom"}) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + client.Close() + + if len(captured) == 0 { + t.Fatal("no entries captured") + } + // The integration should not overwrite existing "runtime" key. + if v, _ := captured[0].Metadata["runtime"].(string); v != "custom" { + t.Errorf("metadata[runtime] = %v, want custom (should not be overwritten)", captured[0].Metadata["runtime"]) + } +} + +func TestGlobalTagsIntegrationAppliesTags(t *testing.T) { + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + w.WriteHeader(http.StatusOK) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + Tags: map[string]string{ + "region": "eu-west-1", + "env": "staging", + }, + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + client.Info(context.Background(), "hello", nil) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + client.Close() + + if len(captured) == 0 { + t.Fatal("no entries captured") + } + if captured[0].Tags["region"] != "eu-west-1" { + t.Errorf("tag region = %q, want eu-west-1", captured[0].Tags["region"]) + } + if captured[0].Tags["env"] != "staging" { + t.Errorf("tag env = %q, want staging", captured[0].Tags["env"]) + } +} + +func TestGlobalTagsScopeTagsWinOnCollision(t *testing.T) { + // Scope tags are merged into the entry before GlobalTagsIntegration runs. + // GlobalTagsIntegration uses mergeTags(global, entry.Tags), so entry/scope tags win. + var captured []logtide.LogEntry + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + captured = append(captured, req.Logs...) + w.WriteHeader(http.StatusOK) + }) + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + Tags: map[string]string{"env": "global"}, + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + // Scope sets "env" → it should override the global tag. + scope := logtide.NewScope(10) + scope.SetTag("env", "request-scoped") + ctx := logtide.WithScope(context.Background(), scope) + + client.Info(ctx, "hello", nil) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + client.Close() + + if len(captured) == 0 { + t.Fatal("no entries captured") + } + // Scope tag should win over global tag. + if captured[0].Tags["env"] != "request-scoped" { + t.Errorf("tag env = %q, want request-scoped (scope tags should override global)", captured[0].Tags["env"]) + } +} + +func TestIntegrationDeduplication(t *testing.T) { + var received int32 + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Logs []logtide.LogEntry `json:"logs"` + } + json.NewDecoder(r.Body).Decode(&req) + atomic.AddInt32(&received, int32(len(req.Logs))) + w.WriteHeader(http.StatusOK) + }) + + // Register the same integration twice; only one processor should be added. + var processorCalls int32 + dupIntegration := &duplicateIntegration{calls: &processorCalls} + + client, err := logtide.NewClient(logtide.ClientOptions{ + DSN: newTestDSN(srv.URL), + Service: "test", + Integrations: func(defaults []logtide.Integration) []logtide.Integration { + return append(defaults, dupIntegration, dupIntegration) + }, + BatchSize: 10, + FlushInterval: 50 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + client.Info(context.Background(), "msg", nil) + + flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + client.Flush(flushCtx) + time.Sleep(50 * time.Millisecond) + client.Close() + + // Processor should have been called exactly once per entry (not twice). + if n := atomic.LoadInt32(&processorCalls); n != 1 { + t.Errorf("processor called %d times, want 1 (dedup should prevent double registration)", n) + } +} + +// duplicateIntegration is a test integration that counts processor invocations. +type duplicateIntegration struct { + calls *int32 +} + +func (d *duplicateIntegration) Name() string { return "DuplicateTest" } + +func (d *duplicateIntegration) Setup(client *logtide.Client) { + client.AddEventProcessor(func(e *logtide.LogEntry, _ *logtide.EventHint) *logtide.LogEntry { + atomic.AddInt32(d.calls, 1) + return e + }) +} diff --git a/integrations/nethttp/nethttp.go b/integrations/nethttp/nethttp.go new file mode 100644 index 0000000..ecce4ff --- /dev/null +++ b/integrations/nethttp/nethttp.go @@ -0,0 +1,140 @@ +// Package nethttp provides a LogTide middleware for the standard net/http package. +package nethttp + +import ( + "net" + "net/http" + "strings" + "time" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +// Middleware wraps next with per-request LogTide scope isolation. +// +// For each request it: +// 1. Clones the Hub from ctx (or the global Hub if absent). +// 2. Pushes a new Scope and configures it with HTTP request metadata. +// 3. Parses the incoming traceparent header and stores it on the Scope. +// 4. Injects the Hub into the request context. +// 5. After the handler returns, adds a response breadcrumb. +// +// Usage: +// +// http.Handle("/", nethttp.Middleware(myHandler)) +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hub := hubFromRequest(r) + hub.ConfigureScope(func(s *logtide.Scope) { + s.SetTag("http.method", r.Method) + s.SetTag("http.url", r.URL.String()) + s.SetTag("http.host", r.Host) + if ip := clientIP(r); ip != "" { + s.SetTag("http.client_ip", ip) + } + + // Parse and store W3C traceparent if present. + if tp := r.Header.Get("Traceparent"); tp != "" { + traceID, spanID, _, err := logtide.ParseTraceparent(tp) + if err == nil { + s.SetTraceContext(traceID, spanID) + } + } + }) + + hub.AddBreadcrumb(&logtide.Breadcrumb{ + Type: "http", + Category: "request", + Message: r.Method + " " + r.URL.Path, + Level: logtide.LevelInfo, + Timestamp: time.Now(), + Data: map[string]any{ + "method": r.Method, + "url": r.URL.String(), + }, + }, nil) + + ctx := logtide.SetHubOnContext(r.Context(), hub) + rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r.WithContext(ctx)) + + hub.AddBreadcrumb(&logtide.Breadcrumb{ + Type: "http", + Category: "response", + Level: levelForStatus(rw.status), + Timestamp: time.Now(), + Data: map[string]any{ + "status_code": rw.status, + }, + }, nil) + }) +} + +// Integration implements logtide.Integration. +// Register it in ClientOptions.Integrations to have the net/http middleware +// listed in the SDK integration metadata. +type Integration struct{} + +func (Integration) Name() string { return "net/http" } + +// Setup implements logtide.Integration. The middleware is a standalone function; +// no client-level event processor registration is required. +func (Integration) Setup(_ *logtide.Client) {} + +// --- helpers --- + +func hubFromRequest(r *http.Request) *logtide.Hub { + if h := logtide.GetHubFromContext(r.Context()); h != nil { + return h.Clone() + } + return logtide.CurrentHub().Clone() +} + +func clientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + // X-Forwarded-For may be a comma-separated list; take only the first + // (leftmost) IP, which is the original client address. + if idx := strings.IndexByte(fwd, ','); idx >= 0 { + return strings.TrimSpace(fwd[:idx]) + } + return fwd + } + if real := r.Header.Get("X-Real-IP"); real != "" { + return real + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func levelForStatus(status int) logtide.Level { + switch { + case status >= 500: + return logtide.LevelError + case status >= 400: + return logtide.LevelWarn + default: + return logtide.LevelInfo + } +} + +// responseWriter wraps http.ResponseWriter to capture the status code. +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +// Flush implements http.Flusher so that streaming handlers (SSE, etc.) work +// correctly through the middleware wrapper. +func (rw *responseWriter) Flush() { + if f, ok := rw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/integrations/nethttp/nethttp_test.go b/integrations/nethttp/nethttp_test.go new file mode 100644 index 0000000..76408a9 --- /dev/null +++ b/integrations/nethttp/nethttp_test.go @@ -0,0 +1,207 @@ +package nethttp_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + logtide "github.com/logtide-dev/logtide-sdk-go" + lnethttp "github.com/logtide-dev/logtide-sdk-go/integrations/nethttp" +) + +func newTestClient(t *testing.T) *logtide.Client { + t.Helper() + c, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(c.Close) + return c +} + +func TestMiddlewareInjectsHubIntoContext(t *testing.T) { + client := newTestClient(t) + hub := logtide.NewHub(client, nil) + + var gotHub *logtide.Hub + handler := lnethttp.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHub = logtide.GetHubFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + ctx := logtide.SetHubOnContext(req.Context(), hub) + req = req.WithContext(ctx) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + if gotHub == nil { + t.Error("hub should be injected into request context by middleware") + } +} + +func TestMiddlewareSetsHTTPTags(t *testing.T) { + client := newTestClient(t) + hub := logtide.NewHub(client, nil) + + var scope *logtide.Scope + handler := lnethttp.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h := logtide.GetHubFromContext(r.Context()); h != nil { + scope = h.Scope() + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/things?x=1", nil) + req.Host = "example.com" + ctx := logtide.SetHubOnContext(req.Context(), hub) + req = req.WithContext(ctx) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + if scope == nil { + t.Fatal("expected scope from hub in context") + } + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := scope.ApplyToEntry(entry) + + if enriched.Tags["http.method"] != "POST" { + t.Errorf("http.method = %q, want POST", enriched.Tags["http.method"]) + } + if enriched.Tags["http.host"] != "example.com" { + t.Errorf("http.host = %q, want example.com", enriched.Tags["http.host"]) + } +} + +func TestMiddlewareParsesTraceparent(t *testing.T) { + client := newTestClient(t) + hub := logtide.NewHub(client, nil) + + var scope *logtide.Scope + handler := lnethttp.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h := logtide.GetHubFromContext(r.Context()); h != nil { + scope = h.Scope() + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + ctx := logtide.SetHubOnContext(req.Context(), hub) + req = req.WithContext(ctx) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + if scope == nil { + t.Fatal("expected scope in context") + } + traceID, spanID := scope.TraceContext() + if traceID != "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Errorf("traceID = %q, want 4bf92f3577b34da6a3ce929d0e0e4736", traceID) + } + if spanID != "00f067aa0ba902b7" { + t.Errorf("spanID = %q, want 00f067aa0ba902b7", spanID) + } +} + +func TestMiddlewareAddsResponseBreadcrumb(t *testing.T) { + client := newTestClient(t) + hub := logtide.NewHub(client, nil) + + var afterScope *logtide.Scope + var innerHub *logtide.Hub + handler := lnethttp.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + innerHub = logtide.GetHubFromContext(r.Context()) + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + ctx := logtide.SetHubOnContext(req.Context(), hub) + req = req.WithContext(ctx) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + if innerHub == nil { + t.Fatal("innerHub should not be nil") + } + afterScope = innerHub.Scope() + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := afterScope.ApplyToEntry(entry) + // Should have at least the request breadcrumb + the response breadcrumb. + if len(enriched.Breadcrumbs) < 2 { + t.Errorf("breadcrumbs = %d, want >= 2 (request + response)", len(enriched.Breadcrumbs)) + } +} + +func TestResponseWriterCapturesStatusCode(t *testing.T) { + // Verify that the middleware's responseWriter wrapper correctly captures the + // status code and passes it to the response breadcrumb added after the handler. + client := newTestClient(t) + hub := logtide.NewHub(client, nil) + + var innerHub *logtide.Hub + handler := lnethttp.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + innerHub = logtide.GetHubFromContext(r.Context()) + w.WriteHeader(http.StatusTeapot) + })) + + req := httptest.NewRequest(http.MethodGet, "/teapot", nil) + ctx := logtide.SetHubOnContext(req.Context(), hub) + req = req.WithContext(ctx) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + if rw.Code != http.StatusTeapot { + t.Errorf("response recorder code = %d, want %d", rw.Code, http.StatusTeapot) + } + if innerHub == nil { + t.Fatal("innerHub should not be nil") + } + + // Verify the response breadcrumb captured the correct status from the wrapper. + scope := innerHub.Scope() + entry := scope.ApplyToEntry(&logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "m"}) + if entry == nil { + t.Fatal("ApplyToEntry returned nil") + } + var responseBreadcrumb *logtide.Breadcrumb + for _, bc := range entry.Breadcrumbs { + if bc.Category == "response" { + responseBreadcrumb = bc + break + } + } + if responseBreadcrumb == nil { + t.Fatal("no response breadcrumb found") + } + if got, ok := responseBreadcrumb.Data["status_code"]; !ok || got != http.StatusTeapot { + t.Errorf("response breadcrumb status_code = %v, want %d", got, http.StatusTeapot) + } +} + +func TestMiddlewareDefaultStatus200(t *testing.T) { + // When the handler doesn't call WriteHeader, status defaults to 200. + handler := lnethttp.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Errorf("default status = %d, want 200", rw.Code) + } +} + +func TestIntegrationName(t *testing.T) { + i := lnethttp.Integration{} + if i.Name() != "net/http" { + t.Errorf("Name() = %q, want net/http", i.Name()) + } +} diff --git a/integrations/otelexport/otelexport.go b/integrations/otelexport/otelexport.go new file mode 100644 index 0000000..882236e --- /dev/null +++ b/integrations/otelexport/otelexport.go @@ -0,0 +1,145 @@ +// Package otelexport provides a LogTide integration that bridges OpenTelemetry +// spans into the LogTide pipeline. +// +// Usage: +// +// integration := otelexport.New() +// flush := logtide.Init(logtide.ClientOptions{ +// DSN: "https://lp_abc@api.logtide.dev", +// Service: "my-service", +// Integrations: func(defaults []logtide.Integration) []logtide.Integration { +// return append(defaults, integration) +// }, +// }) +// defer flush() +// +// // Register the span exporter with your TracerProvider: +// tp := sdktrace.NewTracerProvider( +// sdktrace.WithBatcher(integration.Exporter()), +// ) +package otelexport + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +// Exporter implements sdktrace.SpanExporter. +// Each completed span is converted to a logtide.LogEntry and sent via the Client. +type Exporter struct { + client *logtide.Client +} + +// ExportSpans implements sdktrace.SpanExporter. +func (e *Exporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error { + if e.client == nil { + return nil + } + for _, s := range spans { + entry := spanToEntry(s) + + // Inject the span's trace context via a scope. + // Use context.Background() as the base so no ambient active span + // from the exporter's ctx can override the completed span's IDs: + // captureEntry extracts OTel span IDs in step 2 (before the scope + // is applied in step 3), so an active span in ctx would win. + scope := logtide.NewScope(0) + scope.SetTraceContext(entry.TraceID, entry.SpanID) + spanCtx := logtide.WithScope(context.Background(), scope) + + if entry.Level == logtide.LevelError { + e.client.Error(spanCtx, entry.Message, entry.Metadata) + } else { + e.client.Info(spanCtx, entry.Message, entry.Metadata) + } + } + return nil +} + +// Shutdown implements sdktrace.SpanExporter. +func (e *Exporter) Shutdown(ctx context.Context) error { + if e.client != nil { + e.client.Flush(ctx) + } + return nil +} + +// Integration implements logtide.Integration and holds the Exporter reference. +// Call Exporter() after Setup has been called to obtain the configured exporter. +type Integration struct { + exporter *Exporter +} + +// New creates an Integration. Register it in ClientOptions.Integrations, then +// call Exporter() after Init to obtain the configured sdktrace.SpanExporter. +func New() *Integration { + return &Integration{exporter: &Exporter{}} +} + +// Name implements logtide.Integration. +func (i *Integration) Name() string { return "OTelSpanExport" } + +// Setup implements logtide.Integration. Stores the client reference in the Exporter. +func (i *Integration) Setup(client *logtide.Client) { + i.exporter.client = client +} + +// Exporter returns the sdktrace.SpanExporter backed by this integration's Client. +// Register it with sdktrace.NewTracerProvider(sdktrace.WithBatcher(integration.Exporter())). +func (i *Integration) Exporter() *Exporter { + return i.exporter +} + +// --- span → LogEntry conversion --- + +func spanToEntry(s sdktrace.ReadOnlySpan) *logtide.LogEntry { + level := logtide.LevelInfo + if s.Status().Code == codes.Error { + level = logtide.LevelError + } + + sc := s.SpanContext() + meta := map[string]any{ + "span": map[string]any{ + "name": s.Name(), + "kind": s.SpanKind().String(), + "start_time": s.StartTime().Format(time.RFC3339Nano), + "end_time": s.EndTime().Format(time.RFC3339Nano), + "duration_ms": s.EndTime().Sub(s.StartTime()).Milliseconds(), + "status": s.Status().Code.String(), + }, + } + + if attrs := s.Attributes(); len(attrs) > 0 { + attrMap := make(map[string]any, len(attrs)) + for _, a := range attrs { + attrMap[string(a.Key)] = a.Value.AsInterface() + } + meta["attributes"] = attrMap + } + + if events := s.Events(); len(events) > 0 { + evList := make([]map[string]any, 0, len(events)) + for _, ev := range events { + evList = append(evList, map[string]any{ + "name": ev.Name, + "time": ev.Time.Format(time.RFC3339Nano), + }) + } + meta["events"] = evList + } + + return &logtide.LogEntry{ + Level: level, + Message: fmt.Sprintf("%s [%s]", s.Name(), s.SpanKind()), + Metadata: meta, + TraceID: sc.TraceID().String(), + SpanID: sc.SpanID().String(), + } +} diff --git a/integrations/otelexport/otelexport_test.go b/integrations/otelexport/otelexport_test.go new file mode 100644 index 0000000..1d25f93 --- /dev/null +++ b/integrations/otelexport/otelexport_test.go @@ -0,0 +1,76 @@ +package otelexport_test + +import ( + "context" + "testing" + + logtide "github.com/logtide-dev/logtide-sdk-go" + "github.com/logtide-dev/logtide-sdk-go/integrations/otelexport" +) + +func TestNewCreatesIntegration(t *testing.T) { + i := otelexport.New() + if i == nil { + t.Fatal("New() returned nil") + } + if i.Name() != "OTelSpanExport" { + t.Errorf("Name() = %q, want OTelSpanExport", i.Name()) + } +} + +func TestExporterBeforeSetupHasNilClient(t *testing.T) { + i := otelexport.New() + // Before Setup is called, exporter has a nil client. + // ExportSpans with a nil client should return nil (no panic). + if err := i.Exporter().ExportSpans(context.Background(), nil); err != nil { + t.Errorf("ExportSpans with nil client = %v, want nil", err) + } +} + +func TestExporterShutdownWithNilClient(t *testing.T) { + i := otelexport.New() + // Shutdown before Setup should not panic. + if err := i.Exporter().Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown with nil client = %v, want nil", err) + } +} + +func TestSetupWiresClientToExporter(t *testing.T) { + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + i := otelexport.New() + i.Setup(client) + + // After Setup, Shutdown should flush the client without panicking. + if err := i.Exporter().Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown after Setup = %v, want nil", err) + } +} + +func TestIntegrationRegisteredViaClientOptions(t *testing.T) { + otelInt := otelexport.New() + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + Integrations: func(defaults []logtide.Integration) []logtide.Integration { + return append(defaults, otelInt) + }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + // After NewClient, the integration's Setup has been called. + // ExportSpans with empty span list should succeed. + if err := otelInt.Exporter().ExportSpans(context.Background(), nil); err != nil { + t.Errorf("ExportSpans after init = %v, want nil", err) + } +} diff --git a/internal/batch/batch.go b/internal/batch/batch.go new file mode 100644 index 0000000..895d665 --- /dev/null +++ b/internal/batch/batch.go @@ -0,0 +1,164 @@ +// Package batch provides a goroutine-safe generic batch accumulator +// with size-triggered and time-triggered flushing. +package batch + +import ( + "context" + "errors" + "sync" + "time" +) + +// ErrStopped is returned by Add when the batch has been stopped. +var ErrStopped = errors.New("batch: stopped") + +// FlushFunc is called by the batch engine to deliver accumulated items. +type FlushFunc[T any] func(ctx context.Context, items []T) error + +// Options configures a Batch. +type Options struct { + // MaxSize is the number of items that triggers an immediate flush. + // Default: 100. + MaxSize int + + // FlushInterval is the maximum time between automatic flushes. + // Default: 5s. + FlushInterval time.Duration + + // FlushTimeout is the deadline used for the final flush on Stop. + // Default: 10s. + FlushTimeout time.Duration +} + +// Batch accumulates items of type T and dispatches them in groups via FlushFunc. +// It is safe for concurrent use. +type Batch[T any] struct { + mu sync.Mutex + items []T + maxSize int + flushInterval time.Duration + flushTimeout time.Duration + flushFn FlushFunc[T] + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + flushChan chan struct{} + stopped bool +} + +// New creates and starts a Batch with the given options and flush function. +// Panics if fn is nil. +func New[T any](opts Options, fn FlushFunc[T]) *Batch[T] { + if fn == nil { + panic("batch: flush function cannot be nil") + } + if opts.MaxSize <= 0 { + opts.MaxSize = 100 + } + if opts.FlushInterval <= 0 { + opts.FlushInterval = 5 * time.Second + } + if opts.FlushTimeout <= 0 { + opts.FlushTimeout = 10 * time.Second + } + + ctx, cancel := context.WithCancel(context.Background()) + b := &Batch[T]{ + items: make([]T, 0, opts.MaxSize), + maxSize: opts.MaxSize, + flushInterval: opts.FlushInterval, + flushTimeout: opts.FlushTimeout, + flushFn: fn, + ctx: ctx, + cancel: cancel, + flushChan: make(chan struct{}, 1), + } + + b.wg.Add(1) + go b.backgroundFlusher() + return b +} + +// Add appends item to the batch. If the batch reaches MaxSize, a flush is +// triggered asynchronously. Returns ErrStopped if the batch has been stopped. +func (b *Batch[T]) Add(item T) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.stopped { + return ErrStopped + } + + b.items = append(b.items, item) + if len(b.items) >= b.maxSize { + select { + case b.flushChan <- struct{}{}: + default: + // flush already pending + } + } + return nil +} + +// Flush immediately drains all buffered items by calling FlushFunc. +// It is a no-op if the buffer is empty. +func (b *Batch[T]) Flush(ctx context.Context) error { + b.mu.Lock() + if len(b.items) == 0 { + b.mu.Unlock() + return nil + } + items := make([]T, len(b.items)) + copy(items, b.items) + b.items = b.items[:0] + b.mu.Unlock() + + return b.flushFn(ctx, items) +} + +// Stop signals the background goroutine to exit, waits for it, then performs +// a final flush of any remaining items using FlushTimeout as the deadline. +func (b *Batch[T]) Stop() error { + b.mu.Lock() + if b.stopped { + b.mu.Unlock() + return nil + } + b.stopped = true + b.mu.Unlock() + + b.cancel() + b.wg.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), b.flushTimeout) + defer cancel() + return b.Flush(ctx) +} + +func (b *Batch[T]) backgroundFlusher() { + defer b.wg.Done() + + ticker := time.NewTicker(b.flushInterval) + defer ticker.Stop() + + for { + select { + case <-b.ctx.Done(): + return + case <-ticker.C: + b.flushWithTimeout() + case <-b.flushChan: + b.flushWithTimeout() + } + } +} + +// flushWithTimeout performs a single flush with its own context, independent +// of the batch lifecycle context. This prevents items from being lost when +// Stop cancels the lifecycle context while a flush is in progress. +func (b *Batch[T]) flushWithTimeout() { + ctx, cancel := context.WithTimeout(context.Background(), b.flushTimeout) + defer cancel() + _ = b.Flush(ctx) +} diff --git a/internal/batch/batch_test.go b/internal/batch/batch_test.go new file mode 100644 index 0000000..d23cdc1 --- /dev/null +++ b/internal/batch/batch_test.go @@ -0,0 +1,133 @@ +package batch_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/logtide-dev/logtide-sdk-go/internal/batch" +) + +func TestSizeBasedFlushing(t *testing.T) { + var total int32 + b := batch.New(batch.Options{MaxSize: 3, FlushInterval: time.Minute}, func(_ context.Context, items []string) error { + atomic.AddInt32(&total, int32(len(items))) + return nil + }) + defer b.Stop() + + for i := 0; i < 10; i++ { + b.Add("msg") + } + time.Sleep(100 * time.Millisecond) + b.Stop() + + if atomic.LoadInt32(&total) != 10 { + t.Errorf("total = %d, want 10", total) + } +} + +func TestTimeBasedFlushing(t *testing.T) { + var flushes int32 + b := batch.New(batch.Options{MaxSize: 100, FlushInterval: 50 * time.Millisecond}, func(_ context.Context, _ []string) error { + atomic.AddInt32(&flushes, 1) + return nil + }) + defer b.Stop() + + b.Add("msg1") + b.Add("msg2") + + time.Sleep(120 * time.Millisecond) + if atomic.LoadInt32(&flushes) < 1 { + t.Error("expected at least one time-based flush") + } +} + +func TestManualFlush(t *testing.T) { + var got []string + var mu sync.Mutex + b := batch.New(batch.Options{MaxSize: 100, FlushInterval: time.Minute}, func(_ context.Context, items []string) error { + mu.Lock() + got = append(got, items...) + mu.Unlock() + return nil + }) + defer b.Stop() + + for i := 0; i < 5; i++ { + b.Add("x") + } + if err := b.Flush(context.Background()); err != nil { + t.Fatalf("Flush: %v", err) + } + + mu.Lock() + n := len(got) + mu.Unlock() + if n != 5 { + t.Errorf("flushed %d, want 5", n) + } +} + +func TestStopFlushesRemaining(t *testing.T) { + var total int32 + b := batch.New(batch.Options{MaxSize: 100, FlushInterval: time.Minute}, func(_ context.Context, items []string) error { + atomic.AddInt32(&total, int32(len(items))) + return nil + }) + + for i := 0; i < 7; i++ { + b.Add("msg") + } + b.Stop() + + if atomic.LoadInt32(&total) != 7 { + t.Errorf("total after stop = %d, want 7", total) + } + + if err := b.Add("after-stop"); err != batch.ErrStopped { + t.Errorf("Add after stop: got %v, want ErrStopped", err) + } +} + +func TestEmptyFlushIsNoop(t *testing.T) { + called := false + b := batch.New(batch.Options{MaxSize: 100, FlushInterval: time.Minute}, func(_ context.Context, _ []string) error { + called = true + return nil + }) + defer b.Stop() + + b.Flush(context.Background()) + if called { + t.Error("flush function should not be called for empty batch") + } +} + +func TestConcurrentAdds(t *testing.T) { + var total int32 + b := batch.New(batch.Options{MaxSize: 50, FlushInterval: 50 * time.Millisecond}, func(_ context.Context, items []string) error { + atomic.AddInt32(&total, int32(len(items))) + return nil + }) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 20; j++ { + b.Add("msg") + } + }() + } + wg.Wait() + b.Stop() + + if atomic.LoadInt32(&total) != 200 { + t.Errorf("total = %d, want 200", total) + } +} diff --git a/internal/circuitbreaker/circuitbreaker.go b/internal/circuitbreaker/circuitbreaker.go new file mode 100644 index 0000000..3d3b419 --- /dev/null +++ b/internal/circuitbreaker/circuitbreaker.go @@ -0,0 +1,138 @@ +// Package circuitbreaker implements the circuit-breaker pattern to prevent +// cascading failures when a downstream service is unavailable. +package circuitbreaker + +import ( + "errors" + "sync" + "time" +) + +// ErrOpen is returned by Allow when the circuit is in the open state. +var ErrOpen = errors.New("circuit breaker is open") + +// State represents the state of the circuit breaker. +type State int + +const ( + StateClosed State = iota // requests allowed + StateOpen // requests blocked + StateHalfOpen // one probe request allowed +) + +func (s State) String() string { + switch s { + case StateClosed: + return "closed" + case StateOpen: + return "open" + case StateHalfOpen: + return "half-open" + default: + return "unknown" + } +} + +// CircuitBreaker is a three-state (closed/open/half-open) circuit breaker. +// It is safe for concurrent use. +type CircuitBreaker struct { + mu sync.Mutex + failureThreshold int + timeout time.Duration + state State + failures int + lastStateChange time.Time +} + +// New creates a CircuitBreaker with the given failure threshold and open timeout. +// Passing threshold=0 or a negative value disables the circuit breaker (always allows). +func New(failureThreshold int, timeout time.Duration) *CircuitBreaker { + if failureThreshold < 0 { + failureThreshold = 0 + } + if timeout <= 0 { + timeout = 30 * time.Second + } + return &CircuitBreaker{ + failureThreshold: failureThreshold, + timeout: timeout, + state: StateClosed, + lastStateChange: time.Now(), + } +} + +// Allow checks whether a request is permitted. +// Returns ErrOpen when the circuit is open and the recovery timeout has not elapsed. +// When failureThreshold is 0 (disabled), Allow always returns nil. +func (cb *CircuitBreaker) Allow() error { + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.failureThreshold == 0 { + return nil + } + if cb.state == StateOpen { + if time.Since(cb.lastStateChange) >= cb.timeout { + cb.state = StateHalfOpen + cb.lastStateChange = time.Now() + } else { + return ErrOpen + } + } + return nil +} + +// RecordSuccess records a successful request. +// Transitions from HalfOpen → Closed and resets the failure counter. +func (cb *CircuitBreaker) RecordSuccess() { + cb.mu.Lock() + defer cb.mu.Unlock() + + cb.failures = 0 + if cb.state == StateHalfOpen { + cb.state = StateClosed + cb.lastStateChange = time.Now() + } +} + +// RecordFailure records a failed request. +// A single failure in HalfOpen reopens the circuit immediately. +// In Closed state, the circuit opens once failures reaches the threshold. +// When failureThreshold is 0 (disabled), RecordFailure is a no-op. +func (cb *CircuitBreaker) RecordFailure() { + cb.mu.Lock() + defer cb.mu.Unlock() + + if cb.failureThreshold == 0 { + return + } + if cb.state == StateHalfOpen { + // A single failure in HalfOpen reopens the circuit. + // Reset the counter so it starts clean for the next Closed phase. + cb.failures = 0 + cb.state = StateOpen + cb.lastStateChange = time.Now() + return + } + cb.failures++ + if cb.failures >= cb.failureThreshold { + cb.state = StateOpen + cb.lastStateChange = time.Now() + } +} + +// State returns the current state. +func (cb *CircuitBreaker) State() State { + cb.mu.Lock() + defer cb.mu.Unlock() + return cb.state +} + +// Reset forces the circuit breaker back to the Closed state. +func (cb *CircuitBreaker) Reset() { + cb.mu.Lock() + defer cb.mu.Unlock() + cb.state = StateClosed + cb.failures = 0 + cb.lastStateChange = time.Now() +} diff --git a/internal/circuitbreaker/circuitbreaker_test.go b/internal/circuitbreaker/circuitbreaker_test.go new file mode 100644 index 0000000..82f12e5 --- /dev/null +++ b/internal/circuitbreaker/circuitbreaker_test.go @@ -0,0 +1,184 @@ +package circuitbreaker_test + +import ( + "errors" + "testing" + "time" + + "github.com/logtide-dev/logtide-sdk-go/internal/circuitbreaker" +) + +func TestInitiallyClosed(t *testing.T) { + cb := circuitbreaker.New(3, 100*time.Millisecond) + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("initial state = %v, want Closed", cb.State()) + } + if err := cb.Allow(); err != nil { + t.Errorf("Allow() = %v, want nil", err) + } +} + +func TestOpensAfterThreshold(t *testing.T) { + cb := circuitbreaker.New(3, 100*time.Millisecond) + cb.RecordFailure() + cb.RecordFailure() + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("state after 2 failures = %v, want Closed", cb.State()) + } + cb.RecordFailure() + if cb.State() != circuitbreaker.StateOpen { + t.Errorf("state after 3 failures = %v, want Open", cb.State()) + } + if err := cb.Allow(); !errors.Is(err, circuitbreaker.ErrOpen) { + t.Errorf("Allow() = %v, want ErrOpen", err) + } +} + +func TestTransitionsToHalfOpen(t *testing.T) { + cb := circuitbreaker.New(2, 50*time.Millisecond) + cb.RecordFailure() + cb.RecordFailure() + + time.Sleep(60 * time.Millisecond) + + if err := cb.Allow(); err != nil { + t.Errorf("Allow() after timeout = %v, want nil", err) + } + if cb.State() != circuitbreaker.StateHalfOpen { + t.Errorf("state = %v, want HalfOpen", cb.State()) + } +} + +func TestHalfOpenSuccess(t *testing.T) { + cb := circuitbreaker.New(2, 50*time.Millisecond) + cb.RecordFailure() + cb.RecordFailure() + time.Sleep(60 * time.Millisecond) + cb.Allow() + cb.RecordSuccess() + + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("state after success = %v, want Closed", cb.State()) + } +} + +func TestHalfOpenFailureReopens(t *testing.T) { + cb := circuitbreaker.New(2, 50*time.Millisecond) + cb.RecordFailure() + cb.RecordFailure() + time.Sleep(60 * time.Millisecond) + cb.Allow() + cb.RecordFailure() + + if cb.State() != circuitbreaker.StateOpen { + t.Errorf("state after half-open failure = %v, want Open", cb.State()) + } +} + +func TestReset(t *testing.T) { + cb := circuitbreaker.New(2, 100*time.Millisecond) + cb.RecordFailure() + cb.RecordFailure() + cb.Reset() + + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("state after reset = %v, want Closed", cb.State()) + } + if err := cb.Allow(); err != nil { + t.Errorf("Allow() after reset = %v, want nil", err) + } +} + +func TestDisabledCircuitBreaker(t *testing.T) { + // threshold=0 means disabled: Allow always returns nil, RecordFailure is no-op. + cb := circuitbreaker.New(0, 100*time.Millisecond) + for i := 0; i < 100; i++ { + cb.RecordFailure() + } + if err := cb.Allow(); err != nil { + t.Errorf("Allow() on disabled CB = %v, want nil", err) + } + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("state = %v, want Closed (disabled CB should never open)", cb.State()) + } +} + +func TestHalfOpenFailureResetsCounter(t *testing.T) { + // After HalfOpen→Open, the failure counter should be 0. + // Then if the CB closes again and receives failures, it should need + // threshold failures to reopen (not fewer because of leftover state). + cb := circuitbreaker.New(3, 50*time.Millisecond) + // Open the circuit. + cb.RecordFailure() + cb.RecordFailure() + cb.RecordFailure() + if cb.State() != circuitbreaker.StateOpen { + t.Fatalf("expected Open, got %v", cb.State()) + } + // Wait for timeout → HalfOpen. + time.Sleep(60 * time.Millisecond) + cb.Allow() + if cb.State() != circuitbreaker.StateHalfOpen { + t.Fatalf("expected HalfOpen, got %v", cb.State()) + } + // One failure in HalfOpen reopens immediately. + cb.RecordFailure() + if cb.State() != circuitbreaker.StateOpen { + t.Errorf("expected Open after HalfOpen failure, got %v", cb.State()) + } + // Now recover again → HalfOpen → Closed. + time.Sleep(60 * time.Millisecond) + cb.Allow() + cb.RecordSuccess() + if cb.State() != circuitbreaker.StateClosed { + t.Fatalf("expected Closed, got %v", cb.State()) + } + // Counter was reset on HalfOpen→Open, so we need full threshold again. + cb.RecordFailure() + cb.RecordFailure() + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("should still be closed after 2 of 3 failures, got %v", cb.State()) + } + cb.RecordFailure() + if cb.State() != circuitbreaker.StateOpen { + t.Errorf("expected Open after 3 failures, got %v", cb.State()) + } +} + +func TestSuccessResetsFailureCount(t *testing.T) { + cb := circuitbreaker.New(3, 50*time.Millisecond) + cb.RecordFailure() + cb.RecordFailure() + // 2 failures — not yet open. RecordSuccess should reset them. + cb.RecordSuccess() + if cb.State() != circuitbreaker.StateClosed { + t.Fatalf("expected Closed, got %v", cb.State()) + } + // Now it takes another full threshold to open. + cb.RecordFailure() + cb.RecordFailure() + if cb.State() != circuitbreaker.StateClosed { + t.Errorf("should still be closed after 2 failures (counter reset), got %v", cb.State()) + } + cb.RecordFailure() + if cb.State() != circuitbreaker.StateOpen { + t.Errorf("expected Open after 3 failures, got %v", cb.State()) + } +} + +func TestStateString(t *testing.T) { + tests := []struct { + s circuitbreaker.State + want string + }{ + {circuitbreaker.StateClosed, "closed"}, + {circuitbreaker.StateOpen, "open"}, + {circuitbreaker.StateHalfOpen, "half-open"}, + {circuitbreaker.State(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.s.String(); got != tt.want { + t.Errorf("State(%d).String() = %q, want %q", tt.s, got, tt.want) + } + } +} diff --git a/internal/http/client.go b/internal/http/client.go deleted file mode 100644 index 42c19ca..0000000 --- a/internal/http/client.go +++ /dev/null @@ -1,129 +0,0 @@ -package http - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "time" -) - -// Client wraps an HTTP client with LogTide-specific configuration. -type Client struct { - httpClient *http.Client - baseURL string - apiKey string - timeout time.Duration -} - -// Config holds the configuration for the HTTP client. -type Config struct { - BaseURL string - APIKey string - Timeout time.Duration - MaxIdleConns int - IdleConnTimeout time.Duration - TLSMinVersion uint16 -} - -// NewClient creates a new HTTP client with the specified configuration. -func NewClient(cfg *Config) *Client { - // Set defaults - if cfg.Timeout == 0 { - cfg.Timeout = 30 * time.Second - } - if cfg.MaxIdleConns == 0 { - cfg.MaxIdleConns = 10 - } - if cfg.IdleConnTimeout == 0 { - cfg.IdleConnTimeout = 90 * time.Second - } - if cfg.TLSMinVersion == 0 { - cfg.TLSMinVersion = tls.VersionTLS12 - } - - // Create transport with custom settings - transport := &http.Transport{ - MaxIdleConns: cfg.MaxIdleConns, - MaxIdleConnsPerHost: cfg.MaxIdleConns, - IdleConnTimeout: cfg.IdleConnTimeout, - TLSClientConfig: &tls.Config{ - MinVersion: cfg.TLSMinVersion, - }, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - } - - return &Client{ - httpClient: &http.Client{ - Transport: transport, - Timeout: cfg.Timeout, - }, - baseURL: cfg.BaseURL, - apiKey: cfg.APIKey, - timeout: cfg.Timeout, - } -} - -// Post sends a POST request to the specified path with the given payload. -func (c *Client) Post(ctx context.Context, path string, payload interface{}) (*http.Response, error) { - // Marshal payload to JSON - body, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) - } - - // Create request - url := c.baseURL + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", c.apiKey) - req.Header.Set("User-Agent", "logtide-sdk-go/0.1.0") - - // Send request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - - return resp, nil -} - -// DecodeResponse decodes the JSON response body into the provided target. -func DecodeResponse(resp *http.Response, target interface{}) error { - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if err := json.Unmarshal(body, target); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - return nil -} - -// ReadResponseBody reads the entire response body and returns it as a string. -func ReadResponseBody(resp *http.Response) (string, error) { - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - return string(body), nil -} diff --git a/internal/httpclient/httpclient.go b/internal/httpclient/httpclient.go new file mode 100644 index 0000000..b28025c --- /dev/null +++ b/internal/httpclient/httpclient.go @@ -0,0 +1,119 @@ +// Package httpclient provides a thin HTTP client with LogTide-specific +// authentication and connection-pool configuration. +package httpclient + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" +) + +const defaultTimeout = 30 * time.Second + +// Client wraps net/http.Client with LogTide authentication headers. +type Client struct { + inner *http.Client + apiKey string + version string +} + +// Options configures the HTTP client. +type Options struct { + Timeout time.Duration + MaxIdleConns int + IdleConnTimeout time.Duration + TLSMinVersion uint16 + // Version is included in the User-Agent header (e.g. "1.0.0"). + Version string + // Inner, if non-nil, is used as the underlying *http.Client instead of + // constructing a new one. Transport and Timeout of the supplied client + // take precedence over the other options. + Inner *http.Client +} + +// New creates a Client with the given API key and options. +// If opts.Inner is non-nil it is used as-is and the other transport options are ignored. +func New(apiKey string, opts Options) *Client { + if opts.Version == "" { + opts.Version = "unknown" + } + + var inner *http.Client + if opts.Inner != nil { + inner = opts.Inner + } else { + if opts.Timeout <= 0 { + opts.Timeout = defaultTimeout + } + if opts.MaxIdleConns <= 0 { + opts.MaxIdleConns = 10 + } + if opts.IdleConnTimeout <= 0 { + opts.IdleConnTimeout = 90 * time.Second + } + if opts.TLSMinVersion == 0 { + opts.TLSMinVersion = tls.VersionTLS12 + } + + transport := &http.Transport{ + MaxIdleConns: opts.MaxIdleConns, + MaxIdleConnsPerHost: opts.MaxIdleConns, + IdleConnTimeout: opts.IdleConnTimeout, + TLSClientConfig: &tls.Config{MinVersion: opts.TLSMinVersion}, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + } + inner = &http.Client{ + Transport: transport, + Timeout: opts.Timeout, + } + } + + return &Client{ + inner: inner, + apiKey: apiKey, + version: opts.Version, + } +} + +// Post sends a JSON-encoded POST request to url and returns the raw response. +// The caller is responsible for closing resp.Body. +func (c *Client) Post(ctx context.Context, url string, payload any) (*http.Response, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("httpclient: marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("httpclient: create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("User-Agent", "logtide-sdk-go/"+c.version) + + resp, err := c.inner.Do(req) + if err != nil { + return nil, fmt.Errorf("httpclient: send request: %w", err) + } + return resp, nil +} + +// ReadBody reads and closes the response body, returning it as a string. +func ReadBody(resp *http.Response) (string, error) { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("httpclient: read body: %w", err) + } + return string(body), nil +} diff --git a/internal/retry/retry.go b/internal/retry/retry.go new file mode 100644 index 0000000..6ad0bb6 --- /dev/null +++ b/internal/retry/retry.go @@ -0,0 +1,94 @@ +// Package retry provides exponential-backoff retry logic for HTTP calls. +package retry + +import ( + "context" + "fmt" + "io" + "math" + "math/rand" + "net/http" + "time" +) + +// Config holds parameters for the retry strategy. +type Config struct { + MaxRetries int + MinBackoff time.Duration + MaxBackoff time.Duration +} + +// fn is a function that makes an HTTP call and can be retried. +type fn func(ctx context.Context) (*http.Response, error) + +// Do executes f with exponential-backoff retry according to cfg. +// It retries on network errors and on HTTP 429, 500, 502, 503, 504. +func Do(ctx context.Context, cfg Config, f fn) (*http.Response, error) { + var ( + resp *http.Response + err error + ) + + for attempt := 0; attempt <= cfg.MaxRetries; attempt++ { + resp, err = f(ctx) + + if !shouldRetry(resp, err) { + return resp, err + } + + if attempt == cfg.MaxRetries { + if err != nil { + return nil, fmt.Errorf("max retries exceeded: %w", err) + } + // Retryable HTTP status but out of attempts: drain and close body, + // then return an error so callers need not inspect StatusCode. + if resp != nil && resp.Body != nil { + io.Copy(io.Discard, resp.Body) //nolint:errcheck + resp.Body.Close() //nolint:errcheck + } + return nil, fmt.Errorf("max retries exceeded: server returned status %d", resp.StatusCode) + } + + // Drain and close the body before sleeping so the underlying TCP + // connection is returned to the pool rather than discarded. + if resp != nil && resp.Body != nil { + io.Copy(io.Discard, resp.Body) //nolint:errcheck + resp.Body.Close() //nolint:errcheck + resp = nil + } + + timer := time.NewTimer(calculateBackoff(attempt, cfg)) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + } + } + + return resp, err +} + +func shouldRetry(resp *http.Response, err error) bool { + if err != nil { + return true + } + if resp == nil { + return false + } + switch resp.StatusCode { + case 429, 500, 502, 503, 504: + return true + default: + return false + } +} + +func calculateBackoff(attempt int, cfg Config) time.Duration { + backoff := float64(cfg.MinBackoff) * math.Pow(2, float64(attempt)) + if backoff > float64(cfg.MaxBackoff) { + backoff = float64(cfg.MaxBackoff) + } + jitter := rand.Float64() * 0.25 * backoff + return time.Duration(backoff + jitter) +} diff --git a/internal/retry/retry_test.go b/internal/retry/retry_test.go new file mode 100644 index 0000000..fe92340 --- /dev/null +++ b/internal/retry/retry_test.go @@ -0,0 +1,142 @@ +package retry_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + "time" + + "github.com/logtide-dev/logtide-sdk-go/internal/retry" +) + +func TestSuccessOnFirstAttempt(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 3, MinBackoff: 5 * time.Millisecond, MaxBackoff: 50 * time.Millisecond} + resp, err := retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: 200}, nil + }) + if err != nil || resp.StatusCode != 200 || attempts != 1 { + t.Errorf("got err=%v status=%d attempts=%d, want nil/200/1", err, resp.StatusCode, attempts) + } +} + +func TestRetriesOnServerError(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 2, MinBackoff: 5 * time.Millisecond, MaxBackoff: 50 * time.Millisecond} + resp, err := retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + if attempts <= 2 { + return &http.Response{StatusCode: 500}, nil + } + return &http.Response{StatusCode: 200}, nil + }) + if err != nil || resp.StatusCode != 200 || attempts != 3 { + t.Errorf("got err=%v status=%d attempts=%d, want nil/200/3", err, resp.StatusCode, attempts) + } +} + +func TestExhaustsRetries(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 2, MinBackoff: 5 * time.Millisecond, MaxBackoff: 50 * time.Millisecond} + resp, err := retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: 500, Body: http.NoBody}, nil + }) + if err == nil { + t.Error("expected error after exhausting retries on 5xx, got nil") + } + if resp != nil { + t.Errorf("expected nil resp after exhausting retries, got status=%d", resp.StatusCode) + } + if attempts != 3 { + t.Errorf("attempts = %d, want 3", attempts) + } +} + +func TestRetriesOnNetworkError(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 2, MinBackoff: 5 * time.Millisecond, MaxBackoff: 50 * time.Millisecond} + _, err := retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + return nil, errors.New("network error") + }) + if err == nil { + t.Error("expected error, got nil") + } + if attempts != 3 { + t.Errorf("attempts = %d, want 3", attempts) + } +} + +func TestContextCancellation(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 5, MinBackoff: 100 * time.Millisecond, MaxBackoff: time.Second} + ctx, cancel := context.WithCancel(context.Background()) + + _, err := retry.Do(ctx, cfg, func(ctx context.Context) (*http.Response, error) { + attempts++ + if attempts == 2 { + cancel() + } + return &http.Response{StatusCode: 500}, nil + }) + + if err == nil { + t.Error("expected error after cancellation") + } + if attempts > 2 { + t.Errorf("attempts = %d after cancel, want <= 2", attempts) + } +} + +func TestNoRetryOn400(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 3, MinBackoff: 5 * time.Millisecond, MaxBackoff: 50 * time.Millisecond} + resp, err := retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: 400}, nil + }) + if err != nil || resp.StatusCode != 400 || attempts != 1 { + t.Errorf("got err=%v status=%d attempts=%d, want nil/400/1", err, resp.StatusCode, attempts) + } +} + +func TestAllRetryableStatusCodes(t *testing.T) { + retryable := []int{429, 500, 502, 503, 504} + for _, code := range retryable { + code := code + t.Run(fmt.Sprintf("status_%d", code), func(t *testing.T) { + attempts := 0 + cfg := retry.Config{MaxRetries: 1, MinBackoff: 5 * time.Millisecond, MaxBackoff: 10 * time.Millisecond} + retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: code, Body: http.NoBody}, nil + }) + if attempts != 2 { + t.Errorf("status %d: attempts = %d, want 2 (initial + 1 retry)", code, attempts) + } + }) + } +} + +func TestNilResponseNotRetried(t *testing.T) { + // A nil response with no error should NOT be retried (shouldRetry returns false). + attempts := 0 + cfg := retry.Config{MaxRetries: 3, MinBackoff: 5 * time.Millisecond, MaxBackoff: 10 * time.Millisecond} + resp, err := retry.Do(context.Background(), cfg, func(_ context.Context) (*http.Response, error) { + attempts++ + return nil, nil + }) + if resp != nil { + t.Errorf("resp = %v, want nil", resp) + } + if err != nil { + t.Errorf("err = %v, want nil", err) + } + if attempts != 1 { + t.Errorf("attempts = %d, want 1 (nil response with no error should not retry)", attempts) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..8561e83 --- /dev/null +++ b/options.go @@ -0,0 +1,135 @@ +package logtide + +import ( + "io" + "net/http" + "time" +) + +// ClientOptions configures a Client. +// +// The recommended way to initialise options is to call NewClientOptions and +// override only the fields you need: +// +// opts := logtide.NewClientOptions() +// opts.DSN = "https://lp_abc@api.logtide.dev" +// opts.Service = "my-service" +// client, err := logtide.NewClient(opts) +type ClientOptions struct { + // DSN is the LogTide Data Source Name: + // https://{api_key}@{host} + // Required unless Transport is provided directly. + DSN string + + // Service is the logical name of the service (required, max 100 chars). + Service string + + // Release is the application version / release identifier. + Release string + + // Environment is the deployment environment ("production", "staging", …). + Environment string + + // ServerName overrides the hostname attached to every log entry. + // Defaults to os.Hostname(). + ServerName string + + // Debug enables verbose SDK-internal logging to DebugWriter. + Debug bool + + // DebugWriter is the target for debug output. Defaults to os.Stderr. + DebugWriter io.Writer + + // MaxBreadcrumbs is the maximum number of breadcrumbs retained per Scope. + // Default: 100. + MaxBreadcrumbs int + + // AttachStacktrace causes a stack trace to be attached to every CaptureError call. + // Default: true. Use Bool(false) to explicitly disable. + AttachStacktrace *bool + + // SampleRate is a fraction in (0.0, 1.0] controlling the proportion of + // log entries that are delivered. 1.0 sends everything (default). + // Values in (0, 1) enable random sampling. To suppress all output in tests + // or dry-run mode, use NoopTransport instead of setting SampleRate to 0. + // Default: 1.0. + SampleRate float64 + + // BeforeSend is called before every log entry is dispatched to the Transport. + // Return nil to drop the entry. The function must be safe for concurrent use. + BeforeSend func(entry *LogEntry, hint *EventHint) *LogEntry + + // BeforeBreadcrumb is called before a breadcrumb is added to a Scope. + // Return nil to drop it. + BeforeBreadcrumb func(bc *Breadcrumb, hint BreadcrumbHint) *Breadcrumb + + // Integrations is applied to the default integration list before installation. + // Receives the default list; return the list you want active. + // If nil, all defaults are installed unchanged. + Integrations func([]Integration) []Integration + + // Transport overrides the default HTTPTransport. + // Useful for testing (e.g. NoopTransport{}) or custom delivery. + Transport Transport + + // HTTPClient overrides the net/http.Client used by the default HTTPTransport. + HTTPClient *http.Client + + // BatchSize is the maximum number of entries per HTTP batch. + // Default: 100. + BatchSize int + + // FlushInterval is the maximum time between automatic batch flushes. + // Default: 5s. + FlushInterval time.Duration + + // FlushTimeout is used for the final Flush on Close and as the Init + // return value deadline. + // Default: 10s. + FlushTimeout time.Duration + + // MaxRetries for the default HTTPTransport. + // Default: 3. + MaxRetries int + + // RetryMinBackoff for the default HTTPTransport. + // Default: 1s. + RetryMinBackoff time.Duration + + // RetryMaxBackoff for the default HTTPTransport. + // Default: 60s. + RetryMaxBackoff time.Duration + + // CircuitBreakerThreshold is the consecutive-failure count before the + // circuit opens. Set to 0 to disable. + // Default: 5. + CircuitBreakerThreshold int + + // CircuitBreakerTimeout is the recovery probe interval. + // Default: 30s. + CircuitBreakerTimeout time.Duration + + // Tags are key-value pairs applied to every log entry. + Tags map[string]string +} + +// Bool returns a pointer to v. Use with pointer-bool fields in ClientOptions +// such as AttachStacktrace to distinguish an explicit false from the zero value. +func Bool(v bool) *bool { return &v } + +// NewClientOptions returns ClientOptions pre-filled with safe defaults. +func NewClientOptions() ClientOptions { + return ClientOptions{ + MaxBreadcrumbs: 100, + AttachStacktrace: Bool(true), + SampleRate: 1.0, + BatchSize: 100, + FlushInterval: 5 * time.Second, + FlushTimeout: 10 * time.Second, + MaxRetries: 3, + RetryMinBackoff: 1 * time.Second, + RetryMaxBackoff: 60 * time.Second, + CircuitBreakerThreshold: 5, + CircuitBreakerTimeout: 30 * time.Second, + } +} diff --git a/retry.go b/retry.go deleted file mode 100644 index d5d574a..0000000 --- a/retry.go +++ /dev/null @@ -1,114 +0,0 @@ -package logtide - -import ( - "context" - "fmt" - "math" - "math/rand" - "net/http" - "time" -) - -// RetryConfig holds the configuration for retry logic. -type RetryConfig struct { - MaxRetries int - MinBackoff time.Duration - MaxBackoff time.Duration -} - -// DefaultRetryConfig returns the default retry configuration. -func DefaultRetryConfig() *RetryConfig { - return &RetryConfig{ - MaxRetries: 3, - MinBackoff: 1 * time.Second, - MaxBackoff: 60 * time.Second, - } -} - -// shouldRetry determines if a request should be retried based on the response. -func shouldRetry(resp *http.Response, err error) bool { - // Retry on network errors - if err != nil { - return true - } - - // Retry on specific HTTP status codes - switch resp.StatusCode { - case 429: // Too Many Requests - return true - case 500: // Internal Server Error - return true - case 502: // Bad Gateway - return true - case 503: // Service Unavailable - return true - case 504: // Gateway Timeout - return true - default: - return false - } -} - -// calculateBackoff calculates the backoff duration for a retry attempt with exponential backoff and jitter. -func calculateBackoff(attempt int, config *RetryConfig) time.Duration { - // Calculate exponential backoff: min_backoff * 2^attempt - backoff := float64(config.MinBackoff) * math.Pow(2, float64(attempt)) - - // Cap at max backoff - if backoff > float64(config.MaxBackoff) { - backoff = float64(config.MaxBackoff) - } - - // Add jitter (random value between 0 and 25% of backoff) - jitter := rand.Float64() * 0.25 * backoff - backoff += jitter - - return time.Duration(backoff) -} - -// retryableFunc is a function that can be retried. -type retryableFunc func(ctx context.Context) (*http.Response, error) - -// withRetry executes a function with retry logic. -func withRetry(ctx context.Context, config *RetryConfig, fn retryableFunc) (*http.Response, error) { - var resp *http.Response - var err error - - for attempt := 0; attempt <= config.MaxRetries; attempt++ { - // Execute the function - resp, err = fn(ctx) - - // Check if we should retry - if !shouldRetry(resp, err) { - // Success or non-retryable error - return resp, err - } - - // Check if we've exhausted retries - if attempt == config.MaxRetries { - // Last attempt failed - if err != nil { - return nil, fmt.Errorf("max retries exceeded: %w", err) - } - return resp, nil - } - - // Calculate backoff - backoff := calculateBackoff(attempt, config) - - // Wait before retrying, respecting context cancellation - select { - case <-time.After(backoff): - // Continue to next attempt - case <-ctx.Done(): - return nil, ctx.Err() - } - - // Close response body if it exists before retrying - if resp != nil && resp.Body != nil { - resp.Body.Close() - } - } - - return resp, err -} diff --git a/retry_test.go b/retry_test.go deleted file mode 100644 index 46bf0c1..0000000 --- a/retry_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package logtide - -import ( - "context" - "errors" - "net/http" - "testing" - "time" -) - -func TestShouldRetry(t *testing.T) { - tests := []struct { - name string - statusCode int - err error - want bool - }{ - { - name: "network error", - statusCode: 0, - err: errors.New("network error"), - want: true, - }, - { - name: "429 Too Many Requests", - statusCode: 429, - err: nil, - want: true, - }, - { - name: "500 Internal Server Error", - statusCode: 500, - err: nil, - want: true, - }, - { - name: "502 Bad Gateway", - statusCode: 502, - err: nil, - want: true, - }, - { - name: "503 Service Unavailable", - statusCode: 503, - err: nil, - want: true, - }, - { - name: "504 Gateway Timeout", - statusCode: 504, - err: nil, - want: true, - }, - { - name: "200 OK", - statusCode: 200, - err: nil, - want: false, - }, - { - name: "400 Bad Request", - statusCode: 400, - err: nil, - want: false, - }, - { - name: "401 Unauthorized", - statusCode: 401, - err: nil, - want: false, - }, - { - name: "403 Forbidden", - statusCode: 403, - err: nil, - want: false, - }, - { - name: "404 Not Found", - statusCode: 404, - err: nil, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var resp *http.Response - if tt.statusCode > 0 { - resp = &http.Response{StatusCode: tt.statusCode} - } - got := shouldRetry(resp, tt.err) - if got != tt.want { - t.Errorf("shouldRetry() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestCalculateBackoff(t *testing.T) { - config := &RetryConfig{ - MinBackoff: 1 * time.Second, - MaxBackoff: 10 * time.Second, - } - - tests := []struct { - name string - attempt int - wantMin time.Duration - wantMax time.Duration - }{ - { - name: "first retry", - attempt: 0, - wantMin: 1 * time.Second, - wantMax: 1500 * time.Millisecond, // 1s + 25% jitter - }, - { - name: "second retry", - attempt: 1, - wantMin: 2 * time.Second, - wantMax: 2500 * time.Millisecond, // 2s + 25% jitter - }, - { - name: "third retry", - attempt: 2, - wantMin: 4 * time.Second, - wantMax: 5 * time.Second, // 4s + 25% jitter - }, - { - name: "capped at max", - attempt: 10, - wantMin: 10 * time.Second, - wantMax: 12500 * time.Millisecond, // 10s + 25% jitter - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - backoff := calculateBackoff(tt.attempt, config) - if backoff < tt.wantMin || backoff > tt.wantMax { - t.Errorf("calculateBackoff(%d) = %v, want between %v and %v", tt.attempt, backoff, tt.wantMin, tt.wantMax) - } - }) - } -} - -func TestWithRetry(t *testing.T) { - t.Run("success on first attempt", func(t *testing.T) { - attempts := 0 - config := DefaultRetryConfig() - - fn := func(ctx context.Context) (*http.Response, error) { - attempts++ - return &http.Response{StatusCode: 200}, nil - } - - resp, err := withRetry(context.Background(), config, fn) - if err != nil { - t.Errorf("withRetry() error = %v, want nil", err) - } - if resp.StatusCode != 200 { - t.Errorf("withRetry() status = %d, want 200", resp.StatusCode) - } - if attempts != 1 { - t.Errorf("withRetry() attempts = %d, want 1", attempts) - } - }) - - t.Run("retries on 500 error", func(t *testing.T) { - attempts := 0 - config := &RetryConfig{ - MaxRetries: 2, - MinBackoff: 10 * time.Millisecond, - MaxBackoff: 100 * time.Millisecond, - } - - fn := func(ctx context.Context) (*http.Response, error) { - attempts++ - if attempts <= 2 { - return &http.Response{StatusCode: 500}, nil - } - return &http.Response{StatusCode: 200}, nil - } - - resp, err := withRetry(context.Background(), config, fn) - if err != nil { - t.Errorf("withRetry() error = %v, want nil", err) - } - if resp.StatusCode != 200 { - t.Errorf("withRetry() status = %d, want 200", resp.StatusCode) - } - if attempts != 3 { - t.Errorf("withRetry() attempts = %d, want 3", attempts) - } - }) - - t.Run("exhausts retries", func(t *testing.T) { - attempts := 0 - config := &RetryConfig{ - MaxRetries: 2, - MinBackoff: 10 * time.Millisecond, - MaxBackoff: 100 * time.Millisecond, - } - - fn := func(ctx context.Context) (*http.Response, error) { - attempts++ - return &http.Response{StatusCode: 500}, nil - } - - resp, err := withRetry(context.Background(), config, fn) - if err != nil { - t.Errorf("withRetry() error = %v, want nil", err) - } - if resp.StatusCode != 500 { - t.Errorf("withRetry() status = %d, want 500", resp.StatusCode) - } - if attempts != 3 { - t.Errorf("withRetry() attempts = %d, want 3 (initial + 2 retries)", attempts) - } - }) - - t.Run("respects context cancellation", func(t *testing.T) { - attempts := 0 - config := &RetryConfig{ - MaxRetries: 5, - MinBackoff: 100 * time.Millisecond, - MaxBackoff: 1 * time.Second, - } - - ctx, cancel := context.WithCancel(context.Background()) - - fn := func(ctx context.Context) (*http.Response, error) { - attempts++ - if attempts == 2 { - cancel() // Cancel after second attempt - } - return &http.Response{StatusCode: 500}, nil - } - - _, err := withRetry(ctx, config, fn) - if err == nil { - t.Error("withRetry() error = nil, want context.Canceled") - } - if attempts > 2 { - t.Errorf("withRetry() attempts = %d, want <= 2 (should stop after context cancellation)", attempts) - } - }) -} diff --git a/scope.go b/scope.go new file mode 100644 index 0000000..0700b42 --- /dev/null +++ b/scope.go @@ -0,0 +1,251 @@ +package logtide + +import ( + "context" + "sync" + "time" +) + +type contextKey int + +const ( + scopeContextKey contextKey = iota + 1 + hubContextKey +) + +// Scope carries per-request contextual data that is merged into every log +// entry produced while the scope is active. +// +// Scope is safe for concurrent use. Use Clone to produce an independent copy +// for per-request isolation. +type Scope struct { + mu sync.RWMutex + tags map[string]string + metadata map[string]any + user User + breadcrumbs []*Breadcrumb + maxBreadcrumbs int + traceID string + spanID string + eventProcessors []EventProcessor +} + +// EventProcessor is a function that may inspect or mutate a LogEntry before +// it is dispatched. Returning nil drops the entry. +type EventProcessor func(entry *LogEntry, hint *EventHint) *LogEntry + +// NewScope creates an empty Scope with the given breadcrumb capacity. +func NewScope(maxBreadcrumbs int) *Scope { + if maxBreadcrumbs <= 0 { + maxBreadcrumbs = 100 + } + return &Scope{ + tags: make(map[string]string), + metadata: make(map[string]any), + maxBreadcrumbs: maxBreadcrumbs, + } +} + +// Clone returns a deep copy of this Scope, safe for independent mutation. +func (s *Scope) Clone() *Scope { + s.mu.RLock() + defer s.mu.RUnlock() + + clone := &Scope{ + maxBreadcrumbs: s.maxBreadcrumbs, + user: s.user, + traceID: s.traceID, + spanID: s.spanID, + tags: make(map[string]string, len(s.tags)), + metadata: make(map[string]any, len(s.metadata)), + breadcrumbs: make([]*Breadcrumb, len(s.breadcrumbs)), + eventProcessors: make([]EventProcessor, len(s.eventProcessors)), + } + for k, v := range s.tags { + clone.tags[k] = v + } + for k, v := range s.metadata { + clone.metadata[k] = v + } + copy(clone.breadcrumbs, s.breadcrumbs) + copy(clone.eventProcessors, s.eventProcessors) + return clone +} + +// SetTag sets a single key-value tag. +func (s *Scope) SetTag(key, value string) { + s.mu.Lock() + s.tags[key] = value + s.mu.Unlock() +} + +// RemoveTag removes a single tag. +func (s *Scope) RemoveTag(key string) { + s.mu.Lock() + delete(s.tags, key) + s.mu.Unlock() +} + +// SetUser sets the user context. +func (s *Scope) SetUser(u User) { + s.mu.Lock() + s.user = u + s.mu.Unlock() +} + +// SetTraceContext pins a trace and span ID on this scope, overriding automatic +// OTel extraction. +func (s *Scope) SetTraceContext(traceID, spanID string) { + s.mu.Lock() + s.traceID = traceID + s.spanID = spanID + s.mu.Unlock() +} + +// AddBreadcrumb appends a breadcrumb, evicting the oldest entry when at capacity. +// The breadcrumb is deep-copied so the caller may safely reuse or mutate the +// original value after this call returns. +func (s *Scope) AddBreadcrumb(bc *Breadcrumb, beforeFunc func(*Breadcrumb, BreadcrumbHint) *Breadcrumb) { + if bc == nil { + return + } + // Deep-copy the breadcrumb to isolate it from caller mutations. + cp := *bc + if len(bc.Data) > 0 { + cp.Data = make(map[string]any, len(bc.Data)) + for k, v := range bc.Data { + cp.Data[k] = v + } + } + if cp.Timestamp.IsZero() { + cp.Timestamp = time.Now() + } + if beforeFunc != nil { + result := beforeFunc(&cp, nil) + if result == nil { + return + } + cp = *result + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.breadcrumbs = append(s.breadcrumbs, &cp) + if len(s.breadcrumbs) > s.maxBreadcrumbs { + s.breadcrumbs = s.breadcrumbs[len(s.breadcrumbs)-s.maxBreadcrumbs:] + } +} + +// ClearBreadcrumbs removes all breadcrumbs. +func (s *Scope) ClearBreadcrumbs() { + s.mu.Lock() + s.breadcrumbs = nil + s.mu.Unlock() +} + +// TraceContext returns the trace and span IDs pinned on this scope. +func (s *Scope) TraceContext() (traceID, spanID string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.traceID, s.spanID +} + +// AddEventProcessor appends a per-scope processor. +func (s *Scope) AddEventProcessor(p EventProcessor) { + s.mu.Lock() + s.eventProcessors = append(s.eventProcessors, p) + s.mu.Unlock() +} + +// ApplyToEntry merges the scope's state into a copy of entry and returns it. +// Scope tags override entry-level tags. Scope metadata is merged first; +// entry-level metadata wins on collision. +// Returns nil if entry is nil. +func (s *Scope) ApplyToEntry(entry *LogEntry) *LogEntry { + if entry == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + // shallow copy + e := *entry + + // tags: scope overrides base (entry tags are the base, scope wins) + if len(s.tags) > 0 { + e.Tags = mergeTags(entry.Tags, s.tags) + } + + // metadata: scope base, entry overrides + if len(s.metadata) > 0 { + merged := make(map[string]any, len(s.metadata)+len(entry.Metadata)) + for k, v := range s.metadata { + merged[k] = v + } + for k, v := range entry.Metadata { + merged[k] = v + } + e.Metadata = merged + } + + // breadcrumbs + if len(s.breadcrumbs) > 0 { + crumbs := make([]*Breadcrumb, len(s.breadcrumbs)) + copy(crumbs, s.breadcrumbs) + e.Breadcrumbs = crumbs + } + + // user → stored in metadata under "user" key for wire format compatibility + if s.user != (User{}) { + if _, exists := e.Metadata["user"]; !exists { + // e.Metadata may still alias entry.Metadata (no deep-copy happened + // above when len(s.metadata) == 0). Copy before writing. + if e.Metadata == nil { + e.Metadata = make(map[string]any, 1) + } else if len(s.metadata) == 0 { + copied := make(map[string]any, len(e.Metadata)+1) + for k, v := range e.Metadata { + copied[k] = v + } + e.Metadata = copied + } + e.Metadata["user"] = s.user + } + } + + // trace context from scope (only if not already set by OTel) + if e.TraceID == "" && s.traceID != "" { + e.TraceID = s.traceID + } + if e.SpanID == "" && s.spanID != "" { + e.SpanID = s.spanID + } + + return &e +} + +// --- Context helpers --- + +// WithScope returns a child context that carries s. +func WithScope(ctx context.Context, s *Scope) context.Context { + return context.WithValue(ctx, scopeContextKey, s) +} + +// ScopeFromContext retrieves the Scope from ctx, returning nil if absent. +func ScopeFromContext(ctx context.Context) *Scope { + s, _ := ctx.Value(scopeContextKey).(*Scope) + return s +} + +// SetHubOnContext returns a child context carrying hub. +func SetHubOnContext(ctx context.Context, hub *Hub) context.Context { + return context.WithValue(ctx, hubContextKey, hub) +} + +// GetHubFromContext retrieves the Hub from ctx, returning nil if absent. +func GetHubFromContext(ctx context.Context) *Hub { + h, _ := ctx.Value(hubContextKey).(*Hub) + return h +} + diff --git a/scope_test.go b/scope_test.go new file mode 100644 index 0000000..5dcab78 --- /dev/null +++ b/scope_test.go @@ -0,0 +1,260 @@ +package logtide_test + +import ( + "context" + "testing" + "time" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +func TestScopeSetTag(t *testing.T) { + s := logtide.NewScope(10) + s.SetTag("env", "production") + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + + if enriched.Tags["env"] != "production" { + t.Errorf("tag env = %q, want %q", enriched.Tags["env"], "production") + } +} + +func TestScopeTagPrecedence(t *testing.T) { + // Scope tags should be overridden by entry-level tags on collision. + // Per the design: scope tags win over base, entry metadata wins over scope metadata. + // For Tags: scope wins over entry base, but entry-provided tags override scope. + s := logtide.NewScope(10) + s.SetTag("key", "from-scope") + + entry := &logtide.LogEntry{ + Service: "svc", + Level: logtide.LevelInfo, + Message: "msg", + Tags: map[string]string{"key": "from-entry"}, + } + // After ApplyToEntry: scope wins for tags (scope overrides entry base) + enriched := s.ApplyToEntry(entry) + // scope overrides entry base tags + if enriched.Tags["key"] != "from-scope" { + t.Errorf("tag key = %q, want scope to win (from-scope)", enriched.Tags["key"]) + } +} + +func TestScopeClone(t *testing.T) { + s := logtide.NewScope(10) + s.SetTag("x", "original") + + clone := s.Clone() + clone.SetTag("x", "modified") + + // Original should be unchanged. + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + original := s.ApplyToEntry(entry) + if original.Tags["x"] != "original" { + t.Errorf("original tag x = %q, want original (clone should not affect original)", original.Tags["x"]) + } +} + +func TestScopeBreadcrumbs(t *testing.T) { + s := logtide.NewScope(3) + for i := 0; i < 5; i++ { + s.AddBreadcrumb(&logtide.Breadcrumb{ + Message: "step", + Timestamp: time.Now(), + }, nil) + } + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + + // MaxBreadcrumbs=3 so only last 3 should be kept. + if len(enriched.Breadcrumbs) != 3 { + t.Errorf("breadcrumbs = %d, want 3", len(enriched.Breadcrumbs)) + } +} + +func TestScopeSetTraceContext(t *testing.T) { + s := logtide.NewScope(10) + s.SetTraceContext("aaaaaa11223344556677889900aabbcc", "0011223344556677") + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + + if enriched.TraceID != "aaaaaa11223344556677889900aabbcc" { + t.Errorf("TraceID = %q", enriched.TraceID) + } + if enriched.SpanID != "0011223344556677" { + t.Errorf("SpanID = %q", enriched.SpanID) + } +} + +func TestScopeDoesNotOverwriteExistingTraceContext(t *testing.T) { + s := logtide.NewScope(10) + s.SetTraceContext("scope-trace", "scope-span") + + entry := &logtide.LogEntry{ + Service: "svc", Level: logtide.LevelInfo, Message: "msg", + TraceID: "entry-trace", SpanID: "entry-span", + } + enriched := s.ApplyToEntry(entry) + + if enriched.TraceID != "entry-trace" { + t.Errorf("TraceID = %q, want entry to win", enriched.TraceID) + } +} + +func TestApplyToEntryNilReturnsNil(t *testing.T) { + s := logtide.NewScope(10) + if got := s.ApplyToEntry(nil); got != nil { + t.Errorf("ApplyToEntry(nil) = %v, want nil", got) + } +} + +func TestAddBreadcrumbDataIsolation(t *testing.T) { + s := logtide.NewScope(10) + data := map[string]any{"key": "original"} + s.AddBreadcrumb(&logtide.Breadcrumb{Message: "b", Data: data}, nil) + + // Mutate the original data map after adding. + data["key"] = "mutated" + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + + if len(enriched.Breadcrumbs) == 0 { + t.Fatal("expected breadcrumb") + } + if v, ok := enriched.Breadcrumbs[0].Data["key"]; !ok || v != "original" { + t.Errorf("breadcrumb data[key] = %v, want original (should be isolated copy)", v) + } +} + +func TestScopeTraceContextAccessor(t *testing.T) { + s := logtide.NewScope(10) + s.SetTraceContext("trace-aaa", "span-bbb") + + traceID, spanID := s.TraceContext() + if traceID != "trace-aaa" { + t.Errorf("traceID = %q, want trace-aaa", traceID) + } + if spanID != "span-bbb" { + t.Errorf("spanID = %q, want span-bbb", spanID) + } +} + +func TestScopeRemoveTag(t *testing.T) { + s := logtide.NewScope(10) + s.SetTag("to-remove", "value") + s.RemoveTag("to-remove") + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + if _, ok := enriched.Tags["to-remove"]; ok { + t.Error("removed tag should not appear in entry") + } +} + +func TestScopeClearBreadcrumbs(t *testing.T) { + s := logtide.NewScope(10) + s.AddBreadcrumb(&logtide.Breadcrumb{Message: "one", Timestamp: time.Now()}, nil) + s.ClearBreadcrumbs() + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + if len(enriched.Breadcrumbs) != 0 { + t.Errorf("expected no breadcrumbs after clear, got %d", len(enriched.Breadcrumbs)) + } +} + +func TestScopeUserInMetadata(t *testing.T) { + s := logtide.NewScope(10) + s.SetUser(logtide.User{ID: "u1", Email: "test@example.com"}) + + entry := &logtide.LogEntry{Service: "svc", Level: logtide.LevelInfo, Message: "msg"} + enriched := s.ApplyToEntry(entry) + + if enriched.Metadata == nil { + t.Fatal("expected metadata to be set") + } + if _, ok := enriched.Metadata["user"]; !ok { + t.Error("expected user key in metadata") + } +} + +func TestScopeUserDoesNotOverwriteExistingMetadataUser(t *testing.T) { + s := logtide.NewScope(10) + s.SetUser(logtide.User{ID: "scope-user"}) + + entry := &logtide.LogEntry{ + Service: "svc", + Level: logtide.LevelInfo, + Message: "msg", + Metadata: map[string]any{"user": "entry-user"}, + } + enriched := s.ApplyToEntry(entry) + + // Entry-level "user" should win over scope user. + if v, _ := enriched.Metadata["user"].(string); v != "entry-user" { + t.Errorf("metadata[user] = %v, want entry-user", enriched.Metadata["user"]) + } +} + +func TestScopeUserDoesNotMutateEntryMetadata(t *testing.T) { + // Regression test: ApplyToEntry must not mutate the caller's Metadata map + // when the scope has a user but no scope-level metadata. + s := logtide.NewScope(10) + s.SetUser(logtide.User{ID: "scope-user"}) + + original := map[string]any{"request_id": "abc-123"} + entry := &logtide.LogEntry{ + Service: "svc", + Level: logtide.LevelInfo, + Message: "msg", + Metadata: original, + } + s.ApplyToEntry(entry) + + // original map must not have been mutated. + if _, found := original["user"]; found { + t.Error("ApplyToEntry mutated the caller's Metadata map by adding a 'user' key") + } +} + +func TestScopeAddEventProcessor(t *testing.T) { + // Processors are run by captureEntry (not ApplyToEntry directly). + // Verify via the full client pipeline with a scope injected into context. + c, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer c.Close() + + s := logtide.NewScope(10) + called := false + s.AddEventProcessor(func(entry *logtide.LogEntry, _ *logtide.EventHint) *logtide.LogEntry { + called = true + return entry + }) + + ctx := logtide.WithScope(context.Background(), s) + c.Info(ctx, "test message", nil) + if !called { + t.Error("event processor was not called") + } + + // Clone should carry the processor too. + called = false + clone := s.Clone() + if clone == nil { + t.Fatal("clone should not be nil") + } + ctx2 := logtide.WithScope(context.Background(), clone) + c.Info(ctx2, "test message", nil) + if !called { + t.Error("cloned scope should carry the event processor") + } +} diff --git a/stacktrace.go b/stacktrace.go new file mode 100644 index 0000000..3f6b51e --- /dev/null +++ b/stacktrace.go @@ -0,0 +1,141 @@ +package logtide + +import ( + "errors" + "path/filepath" + "reflect" + "runtime" + "strings" +) + +const sdkPkgPrefix = "github.com/logtide-dev/logtide-sdk-go" + +// NewStacktrace captures the current goroutine's call stack, stripping +// SDK-internal and Go runtime frames. +// +// skip controls how many additional frames above the SDK boundary are skipped. +// Pass 0 to start from the immediate caller of NewStacktrace. +func NewStacktrace(skip int) *Stacktrace { + pcs := make([]uintptr, 64) + n := runtime.Callers(skip+2, pcs) // +2: runtime.Callers + NewStacktrace + return buildStacktrace(pcs[:n]) +} + +// ExtractStacktrace attempts to extract a pre-existing stack trace from err. +// It supports errors wrapped with github.com/pkg/errors or any error that +// implements a StackTrace() method returning a slice of uintptr (or a named +// type with uintptr as its underlying element type). Falls back to capturing +// the current call stack. +func ExtractStacktrace(err error) *Stacktrace { + for err != nil { + if pcs := tryExtractPkgErrorsStack(err); pcs != nil { + return buildStacktrace(pcs) + } + err = errors.Unwrap(err) + } + return NewStacktrace(1) +} + +// tryExtractPkgErrorsStack uses reflection to call StackTrace() on err without +// importing github.com/pkg/errors. It handles both []uintptr and named slice +// types whose elements have an underlying uintptr kind (e.g. pkg/errors.Frame). +func tryExtractPkgErrorsStack(err error) []uintptr { + rv := reflect.ValueOf(err) + // Guard against a non-nil interface holding a nil concrete pointer: + // calling a method on a nil receiver would panic inside StackTrace(). + if rv.Kind() == reflect.Ptr && rv.IsNil() { + return nil + } + m := rv.MethodByName("StackTrace") + if !m.IsValid() { + return nil + } + res := m.Call(nil) + if len(res) != 1 || res[0].Kind() != reflect.Slice { + return nil + } + val := res[0] + pcs := make([]uintptr, val.Len()) + for i := 0; i < val.Len(); i++ { + elem := val.Index(i) + if elem.Kind() != reflect.Uintptr { + return nil + } + pcs[i] = uintptr(elem.Uint()) + } + return pcs +} + +func buildStacktrace(pcs []uintptr) *Stacktrace { + if len(pcs) == 0 { + return &Stacktrace{} + } + + frames := runtime.CallersFrames(pcs) + var result []Frame + for { + f, more := frames.Next() + if f.Function == "" { + break + } + if !isSDKInternal(f.Function) && !isGoRuntime(f.Function) { + result = append(result, Frame{ + Function: f.Function, + Module: moduleFromFunc(f.Function), + Filename: filepath.Base(f.File), + AbsPath: f.File, + Lineno: f.Line, + InApp: isInApp(f.Function, f.File), + }) + } + if !more { + break + } + } + + // Reverse so innermost frame is last (standard convention). + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return &Stacktrace{Frames: result} +} + +func isSDKInternal(fn string) bool { + if !strings.HasPrefix(fn, sdkPkgPrefix) { + return false + } + // Allow external test packages ("…_test.TestXxx") to appear in stack traces. + // Internal SDK frames end with '.' (same package) or '/' (sub-package); + // the external test package path has '_test' immediately after the prefix. + if len(fn) > len(sdkPkgPrefix) { + next := fn[len(sdkPkgPrefix)] + return next == '.' || next == '/' + } + return true +} + +func isGoRuntime(fn string) bool { + return strings.HasPrefix(fn, "runtime.") || + strings.HasPrefix(fn, "testing.") || + strings.HasPrefix(fn, "reflect.") +} + +func isInApp(fn, file string) bool { + return !isGoRuntime(fn) && + !strings.Contains(file, "/vendor/") && + !strings.Contains(file, "go/pkg/mod/") +} + +func moduleFromFunc(fn string) string { + // "github.com/org/pkg.FuncName" → "github.com/org/pkg" + if idx := strings.LastIndex(fn, "."); idx > 0 { + pkg := fn[:idx] + // strip method receiver: "(*Foo)" prefix + if paren := strings.LastIndex(pkg, "("); paren > 0 { + pkg = pkg[:paren-1] + } + return pkg + } + return fn +} diff --git a/stacktrace_test.go b/stacktrace_test.go new file mode 100644 index 0000000..6abc8d5 --- /dev/null +++ b/stacktrace_test.go @@ -0,0 +1,91 @@ +package logtide_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +func TestNewStacktraceHasFrames(t *testing.T) { + st := logtide.NewStacktrace(0) + if st == nil { + t.Fatal("NewStacktrace returned nil") + } + if len(st.Frames) == 0 { + t.Error("expected at least one frame") + } +} + +func TestNewStacktraceStripsSDKFrames(t *testing.T) { + st := logtide.NewStacktrace(0) + for _, f := range st.Frames { + if strings.HasPrefix(f.Module, "github.com/logtide-dev/logtide-sdk-go") && + !strings.HasPrefix(f.Module, "github.com/logtide-dev/logtide-sdk-go_test") { + t.Errorf("SDK-internal frame leaked: %s %s", f.Module, f.Function) + } + } +} + +func TestNewStacktraceFrameFields(t *testing.T) { + st := logtide.NewStacktrace(0) + if len(st.Frames) == 0 { + t.Skip("no frames captured") + } + // Innermost frame (last in slice) should be in this test. + inner := st.Frames[len(st.Frames)-1] + if inner.Function == "" { + t.Error("frame Function should not be empty") + } + if inner.Lineno <= 0 { + t.Errorf("frame Lineno = %d, want > 0", inner.Lineno) + } +} + +func TestExtractStacktraceFallback(t *testing.T) { + // A plain error has no StackTrace() method — ExtractStacktrace should fall + // back to capturing the current call stack. + err := errors.New("plain error") + st := logtide.ExtractStacktrace(err) + if st == nil { + t.Fatal("ExtractStacktrace returned nil") + } + if len(st.Frames) == 0 { + t.Error("expected frames from fallback capture") + } +} + +type pkgErrorsLike struct { + msg string + pcs []uintptr +} + +func (e *pkgErrorsLike) Error() string { return e.msg } +func (e *pkgErrorsLike) StackTrace() []uintptr { return e.pcs } + +func TestExtractStacktraceFromErrorWithStack(t *testing.T) { + // An error that implements StackTrace() []uintptr (pkg/errors-compatible). + // We pass an empty slice so the stacktrace will have no frames, + // but it should not fall back to the current stack. + wrapped := &pkgErrorsLike{msg: "wrapped", pcs: []uintptr{}} + st := logtide.ExtractStacktrace(wrapped) + if st == nil { + t.Fatal("ExtractStacktrace returned nil") + } + // Since pcs is empty, Frames should be empty (not fallback frames). + if len(st.Frames) != 0 { + t.Errorf("expected 0 frames for empty StackTrace(), got %d", len(st.Frames)) + } +} + +func TestExtractStacktraceUnwrapsChain(t *testing.T) { + inner := &pkgErrorsLike{msg: "inner", pcs: []uintptr{}} + outer := fmt.Errorf("outer: %w", inner) // stdlib wrapped error + // ExtractStacktrace should unwrap and find inner's StackTrace. + st := logtide.ExtractStacktrace(outer) + if st == nil { + t.Fatal("ExtractStacktrace returned nil") + } +} diff --git a/tracing.go b/tracing.go new file mode 100644 index 0000000..713587b --- /dev/null +++ b/tracing.go @@ -0,0 +1,46 @@ +package logtide + +import ( + "context" + "fmt" + "strings" + + "go.opentelemetry.io/otel/trace" +) + +// ParseTraceparent parses a W3C traceparent header value: +// +// 00-{32 hex}-{16 hex}-{2 hex} +// +// Returns the extracted trace ID, span ID, and sampled flag. +// Returns an error if the header is malformed. +func ParseTraceparent(header string) (traceID, spanID string, sampled bool, err error) { + parts := strings.Split(header, "-") + if len(parts) != 4 || parts[0] != "00" { + return "", "", false, fmt.Errorf("logtide: invalid traceparent %q", header) + } + if len(parts[1]) != 32 || len(parts[2]) != 16 || len(parts[3]) != 2 { + return "", "", false, fmt.Errorf("logtide: invalid traceparent %q", header) + } + return parts[1], parts[2], parts[3] == "01", nil +} + +// FormatTraceparent formats trace and span IDs into a W3C traceparent header. +func FormatTraceparent(traceID, spanID string, sampled bool) string { + flags := "00" + if sampled { + flags = "01" + } + return fmt.Sprintf("00-%s-%s-%s", traceID, spanID, flags) +} + +// traceContextFromContext extracts trace correlation from an active OTel span in ctx. +// Scope-based trace context is applied separately via Scope.ApplyToEntry. +func traceContextFromContext(ctx context.Context) (traceID, spanID string) { + span := trace.SpanFromContext(ctx) + if span != nil && span.SpanContext().IsValid() { + sc := span.SpanContext() + return sc.TraceID().String(), sc.SpanID().String() + } + return "", "" +} diff --git a/tracing_test.go b/tracing_test.go new file mode 100644 index 0000000..9ec3778 --- /dev/null +++ b/tracing_test.go @@ -0,0 +1,77 @@ +package logtide_test + +import ( + "testing" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +func TestParseTraceparent(t *testing.T) { + tests := []struct { + name string + header string + traceID string + spanID string + sampled bool + wantErr bool + }{ + { + name: "sampled", + header: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + traceID: "4bf92f3577b34da6a3ce929d0e0e4736", + spanID: "00f067aa0ba902b7", + sampled: true, + }, + { + name: "not sampled", + header: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00", + traceID: "4bf92f3577b34da6a3ce929d0e0e4736", + spanID: "00f067aa0ba902b7", + sampled: false, + }, + { + name: "malformed", + header: "invalid-header", + wantErr: true, + }, + { + name: "wrong version", + header: "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + traceID, spanID, sampled, err := logtide.ParseTraceparent(tt.header) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseTraceparent(%q) err = %v, wantErr %v", tt.header, err, tt.wantErr) + } + if tt.wantErr { + return + } + if traceID != tt.traceID { + t.Errorf("traceID = %q, want %q", traceID, tt.traceID) + } + if spanID != tt.spanID { + t.Errorf("spanID = %q, want %q", spanID, tt.spanID) + } + if sampled != tt.sampled { + t.Errorf("sampled = %v, want %v", sampled, tt.sampled) + } + }) + } +} + +func TestFormatTraceparent(t *testing.T) { + got := logtide.FormatTraceparent("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7", true) + want := "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + if got != want { + t.Errorf("FormatTraceparent() = %q, want %q", got, want) + } + + got2 := logtide.FormatTraceparent("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7", false) + if got2[len(got2)-2:] != "00" { + t.Errorf("unsampled flags = %q, want 00", got2[len(got2)-2:]) + } +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..738ac63 --- /dev/null +++ b/transport.go @@ -0,0 +1,144 @@ +package logtide + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/logtide-dev/logtide-sdk-go/internal/batch" + "github.com/logtide-dev/logtide-sdk-go/internal/circuitbreaker" + "github.com/logtide-dev/logtide-sdk-go/internal/httpclient" + "github.com/logtide-dev/logtide-sdk-go/internal/retry" +) + +// drainAndClose drains and closes a response body so the underlying TCP +// connection is returned to the pool and can be reused. +func drainAndClose(body io.ReadCloser) { + io.Copy(io.Discard, body) //nolint:errcheck + body.Close() //nolint:errcheck +} + +// Transport dispatches log entries to a backend. +// Implementations must be safe for concurrent use. +type Transport interface { + // Send enqueues entry for delivery. It must not block the caller. + Send(entry *LogEntry) + + // Flush blocks until all buffered entries are delivered or ctx is cancelled. + // Returns true if all entries were flushed before ctx expired. + Flush(ctx context.Context) bool + + // Close flushes and releases any resources. Safe to call multiple times. + Close() +} + +// NoopTransport discards all log entries. Useful in tests. +type NoopTransport struct{} + +func (NoopTransport) Send(*LogEntry) {} +func (NoopTransport) Flush(context.Context) bool { return true } +func (NoopTransport) Close() {} + +// HTTPTransport is the default Transport. It buffers entries via an internal +// batch engine and delivers them in batches to the LogTide ingest endpoint +// using exponential-backoff retry and a circuit breaker. +type HTTPTransport struct { + batch *batch.Batch[LogEntry] + debug io.Writer +} + +// newHTTPTransport constructs an HTTPTransport from the resolved DSN and options. +// It is called by NewClient. +func newHTTPTransport(dsn *DSN, opts ClientOptions) *HTTPTransport { + retryConfig := retry.Config{ + MaxRetries: opts.MaxRetries, + MinBackoff: opts.RetryMinBackoff, + MaxBackoff: opts.RetryMaxBackoff, + } + + cb := circuitbreaker.New(opts.CircuitBreakerThreshold, opts.CircuitBreakerTimeout) + + hcOpts := httpclient.Options{ + Timeout: opts.FlushTimeout, + Version: sdkVersion, + Inner: opts.HTTPClient, // nil means build a default client + } + hc := httpclient.New(dsn.APIKey, hcOpts) + + ingestURL := dsn.IngestURL() + debugWriter := opts.DebugWriter + + flushFn := func(ctx context.Context, entries []LogEntry) error { + if err := cb.Allow(); err != nil { + logDebug(debugWriter, "circuit breaker open, dropping %d entries", len(entries)) + return ErrCircuitOpen + } + + req := ingestRequest{Logs: entries} + resp, err := retry.Do(ctx, retryConfig, func(ctx context.Context) (*http.Response, error) { + return hc.Post(ctx, ingestURL, req) + }) + + if err != nil { + cb.RecordFailure() + logDebug(debugWriter, "send failed: %v", err) + return fmt.Errorf("logtide: send batch: %w", err) + } + if resp == nil { + cb.RecordFailure() + return fmt.Errorf("logtide: send batch: nil response") + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + cb.RecordFailure() + body, _ := httpclient.ReadBody(resp) + httpErr := &HTTPError{ + StatusCode: resp.StatusCode, + Message: fmt.Sprintf("unexpected status %d", resp.StatusCode), + Body: body, + } + logDebug(debugWriter, "ingest error: %v", httpErr) + return httpErr + } + + cb.RecordSuccess() + drainAndClose(resp.Body) + return nil + } + + b := batch.New(batch.Options{ + MaxSize: opts.BatchSize, + FlushInterval: opts.FlushInterval, + FlushTimeout: opts.FlushTimeout, + }, flushFn) + + return &HTTPTransport{ + batch: b, + debug: debugWriter, + } +} + +// Send implements Transport. It is non-blocking. +func (t *HTTPTransport) Send(entry *LogEntry) { + if err := t.batch.Add(*entry); err != nil { + logDebug(t.debug, "dropped entry (batch stopped): %v", err) + } +} + +// Flush implements Transport. +func (t *HTTPTransport) Flush(ctx context.Context) bool { + return t.batch.Flush(ctx) == nil +} + +// Close implements Transport. +func (t *HTTPTransport) Close() { + _ = t.batch.Stop() +} + +func logDebug(w io.Writer, format string, args ...any) { + if w == nil { + return + } + _, _ = fmt.Fprintf(w, "[logtide] "+format+"\n", args...) +} diff --git a/types.go b/types.go index 908ff25..99de42c 100644 --- a/types.go +++ b/types.go @@ -1,62 +1,107 @@ package logtide -import "time" +import ( + "crypto/rand" + "encoding/hex" + "time" +) -// LogLevel represents the severity level of a log entry. -type LogLevel string +// Level represents the severity of a log entry. +type Level string const ( - // LogLevelDebug represents debug-level logs for detailed debugging information. - LogLevelDebug LogLevel = "debug" - - // LogLevelInfo represents informational messages that highlight the progress of the application. - LogLevelInfo LogLevel = "info" - - // LogLevelWarn represents potentially harmful situations. - LogLevelWarn LogLevel = "warn" - - // LogLevelError represents error events that might still allow the application to continue running. - LogLevelError LogLevel = "error" - - // LogLevelCritical represents very severe error events that will presumably lead the application to abort. - LogLevelCritical LogLevel = "critical" + LevelDebug Level = "debug" + LevelInfo Level = "info" + LevelWarn Level = "warn" + LevelError Level = "error" + LevelCritical Level = "critical" ) -// Log represents a single log entry to be sent to LogTide. -type Log struct { - // Time is the timestamp of the log entry. If not set, the current time will be used. - Time time.Time `json:"time"` +// EventID is a unique identifier for a log entry (32 lowercase hex chars, no dashes). +type EventID string - // Service is the name of the service generating the log (1-100 characters, required). - Service string `json:"service"` +// newEventID generates a random EventID using crypto/rand. +func newEventID() EventID { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "" + } + return EventID(hex.EncodeToString(b)) +} - // Level is the severity level of the log entry (required). - Level LogLevel `json:"level"` +// LogEntry is the primary unit sent to the LogTide ingest endpoint. +// It is built by the Client and enriched with data from the active Scope. +type LogEntry struct { + EventID EventID `json:"event_id"` + Timestamp time.Time `json:"timestamp"` + Level Level `json:"level"` + Message string `json:"message"` + Service string `json:"service"` + Release string `json:"release,omitempty"` + Environment string `json:"environment,omitempty"` + ServerName string `json:"server_name,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Breadcrumbs []*Breadcrumb `json:"breadcrumbs,omitempty"` + Errors []Exception `json:"errors,omitempty"` + TraceID string `json:"trace_id,omitempty"` + SpanID string `json:"span_id,omitempty"` +} - // Message is the log message (minimum 1 character, required). - Message string `json:"message"` +// EventHint carries supplemental data about the event source. +// Available in BeforeSend hooks. +type EventHint struct { + // OriginalError is the raw error passed to CaptureError, if applicable. + OriginalError error +} + +// Breadcrumb is a discrete event recorded before a log entry, +// used to reconstruct the execution path leading to an error. +type Breadcrumb struct { + Type string `json:"type,omitempty"` + Category string `json:"category,omitempty"` + Message string `json:"message,omitempty"` + Data map[string]any `json:"data,omitempty"` + Level Level `json:"level,omitempty"` + Timestamp time.Time `json:"timestamp"` +} - // Metadata contains additional structured data associated with the log entry (optional). - Metadata map[string]interface{} `json:"metadata,omitempty"` +// BreadcrumbHint carries supplemental data for the BeforeBreadcrumb hook. +type BreadcrumbHint map[string]any - // TraceID is the W3C trace ID for distributed tracing (optional). - TraceID string `json:"trace_id,omitempty"` +// Exception is a serialised Go error with its extracted call stack. +type Exception struct { + Type string `json:"type"` + Value string `json:"value"` + Stacktrace *Stacktrace `json:"stacktrace,omitempty"` +} - // SpanID is the W3C span ID, must be exactly 16 hex characters if provided (optional). - SpanID string `json:"span_id,omitempty"` +// Stacktrace holds the ordered list of frames for an Exception. +// Frames are ordered outermost-first (deepest caller last). +type Stacktrace struct { + Frames []Frame `json:"frames"` } -// IngestRequest represents the request payload for batch log ingestion. -type IngestRequest struct { - // Logs is the array of log entries to ingest (1-1000 logs per request). - Logs []Log `json:"logs"` +// Frame is a single Go stack frame. +type Frame struct { + Function string `json:"function,omitempty"` + Module string `json:"module,omitempty"` + Filename string `json:"filename,omitempty"` + AbsPath string `json:"abs_path,omitempty"` + Lineno int `json:"lineno,omitempty"` + InApp bool `json:"in_app"` } -// IngestResponse represents the response from the log ingestion API. -type IngestResponse struct { - // Received is the number of logs successfully received. - Received int `json:"received"` +// User identifies an actor associated with a Scope. +type User struct { + ID string `json:"id,omitempty"` + Email string `json:"email,omitempty"` + Username string `json:"username,omitempty"` + IP string `json:"ip,omitempty"` +} - // Timestamp is the server timestamp when the logs were processed. - Timestamp string `json:"timestamp"` +// ingestRequest is the wire format sent to the LogTide ingest endpoint. +type ingestRequest struct { + Logs []LogEntry `json:"logs"` } + diff --git a/validation.go b/validation.go deleted file mode 100644 index e0b4a7b..0000000 --- a/validation.go +++ /dev/null @@ -1,73 +0,0 @@ -package logtide - -import ( - "fmt" - "regexp" -) - -var ( - // spanIDRegex validates that span IDs are exactly 16 hexadecimal characters. - spanIDRegex = regexp.MustCompile(`^[a-fA-F0-9]{16}$`) - - // validLogLevels contains the set of acceptable log levels. - validLogLevels = map[LogLevel]bool{ - LogLevelDebug: true, - LogLevelInfo: true, - LogLevelWarn: true, - LogLevelError: true, - LogLevelCritical: true, - } -) - -// validateLog validates a single log entry according to LogTide's requirements. -func validateLog(log *Log) error { - // Validate service name - if len(log.Service) == 0 { - return &ValidationError{Field: "service", Message: "service name is required"} - } - if len(log.Service) > 100 { - return &ValidationError{Field: "service", Message: "service name must be 100 characters or less"} - } - - // Validate message - if len(log.Message) == 0 { - return &ValidationError{Field: "message", Message: "message is required"} - } - - // Validate log level - if !validLogLevels[log.Level] { - return &ValidationError{ - Field: "level", - Message: fmt.Sprintf("invalid log level: %s (must be one of: debug, info, warn, error, critical)", log.Level), - } - } - - // Validate span ID format if provided - if log.SpanID != "" && !spanIDRegex.MatchString(log.SpanID) { - return &ValidationError{ - Field: "span_id", - Message: "span_id must be exactly 16 hexadecimal characters", - } - } - - return nil -} - -// validateBatch validates a batch of logs according to LogTide's requirements. -func validateBatch(logs []Log) error { - if len(logs) == 0 { - return &ValidationError{Field: "logs", Message: "at least one log is required"} - } - if len(logs) > 1000 { - return &ValidationError{Field: "logs", Message: "batch size must be 1000 logs or less"} - } - - // Validate each log in the batch - for i, log := range logs { - if err := validateLog(&log); err != nil { - return fmt.Errorf("log at index %d: %w", i, err) - } - } - - return nil -} diff --git a/validation_test.go b/validation_test.go deleted file mode 100644 index 0441047..0000000 --- a/validation_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package logtide - -import ( - "strings" - "testing" - "time" -) - -func TestValidateLog(t *testing.T) { - tests := []struct { - name string - log *Log - wantErr bool - errMsg string - }{ - { - name: "valid log", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelInfo, - Message: "test message", - }, - wantErr: false, - }, - { - name: "valid log with metadata", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelError, - Message: "error occurred", - Metadata: map[string]interface{}{ - "user_id": 123, - "action": "login", - }, - }, - wantErr: false, - }, - { - name: "valid log with trace and span IDs", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelDebug, - Message: "debug message", - TraceID: "trace-123", - SpanID: "0123456789abcdef", - }, - wantErr: false, - }, - { - name: "missing service", - log: &Log{ - Time: time.Now(), - Level: LogLevelInfo, - Message: "test message", - }, - wantErr: true, - errMsg: "service name is required", - }, - { - name: "service too long", - log: &Log{ - Time: time.Now(), - Service: strings.Repeat("a", 101), - Level: LogLevelInfo, - Message: "test message", - }, - wantErr: true, - errMsg: "service name must be 100 characters or less", - }, - { - name: "missing message", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelInfo, - }, - wantErr: true, - errMsg: "message is required", - }, - { - name: "invalid log level", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevel("invalid"), - Message: "test message", - }, - wantErr: true, - errMsg: "invalid log level", - }, - { - name: "invalid span ID - too short", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelInfo, - Message: "test message", - SpanID: "abc123", - }, - wantErr: true, - errMsg: "span_id must be exactly 16 hexadecimal characters", - }, - { - name: "invalid span ID - invalid characters", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelInfo, - Message: "test message", - SpanID: "0123456789abcdez", - }, - wantErr: true, - errMsg: "span_id must be exactly 16 hexadecimal characters", - }, - { - name: "valid span ID - uppercase", - log: &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelInfo, - Message: "test message", - SpanID: "0123456789ABCDEF", - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateLog(tt.log) - if (err != nil) != tt.wantErr { - t.Errorf("validateLog() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && err != nil && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateLog() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -} - -func TestValidateBatch(t *testing.T) { - validLog := &Log{ - Time: time.Now(), - Service: "test-service", - Level: LogLevelInfo, - Message: "test message", - } - - tests := []struct { - name string - logs []Log - wantErr bool - errMsg string - }{ - { - name: "valid batch with one log", - logs: []Log{*validLog}, - wantErr: false, - }, - { - name: "valid batch with multiple logs", - logs: []Log{*validLog, *validLog, *validLog}, - wantErr: false, - }, - { - name: "empty batch", - logs: []Log{}, - wantErr: true, - errMsg: "at least one log is required", - }, - { - name: "batch too large", - logs: make([]Log, 1001), - wantErr: true, - errMsg: "batch size must be 1000 logs or less", - }, - { - name: "batch with invalid log", - logs: []Log{ - *validLog, - { - Time: time.Now(), - Service: "", - Level: LogLevelInfo, - Message: "test", - }, - }, - wantErr: true, - errMsg: "log at index 1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateBatch(tt.logs) - if (err != nil) != tt.wantErr { - t.Errorf("validateBatch() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && err != nil && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateBatch() error = %v, want error containing %q", err, tt.errMsg) - } - }) - } -}