From dcf31c913056bbc1302375b5fe32b7e59ccf06e0 Mon Sep 17 00:00:00 2001
From: Polliog
Date: Sun, 22 Mar 2026 14:13:23 +0100
Subject: [PATCH 1/3] refactor: complete SDK v0.8.4 rewrite with
Hub/Scope/Transport architecture
- New Hub/Scope/Client/Transport layered architecture (sentry-go inspired)
- DSN-based configuration with structured ingest URL construction
- Leveled logging: Debug, Info, Warn, Error, Critical, CaptureError
- Breadcrumb trail, user context, trace context per scope
- OpenTelemetry span exporter integration (integrations/otelexport)
- net/http middleware with per-request scope isolation (integrations/nethttp)
- Internal batch engine with size- and interval-based flushing
- Exponential-backoff retry with circuit breaker fault tolerance
- Global singleton pattern (Init/flush) and explicit NewClient pattern
- Full test suite with race detector coverage (86 tests)
Bug fixes included in this release:
- Scope.ApplyToEntry no longer mutates caller Metadata map
- otelexport: completed span IDs preserved against ambient OTel context
- retry.Do returns error (not nil) when retries exhausted on 5xx
- tryExtractPkgErrorsStack nil-pointer panic guard added
- Client processor slice copied under lock before iteration
- EnvironmentIntegration copies metadata map before mutation
---
.claude/settings.local.json | 10 -
.github/workflows/lint.yml | 4 +-
.github/workflows/test.yml | 4 +-
CHANGELOG.md | 25 +
batch.go | 180 ------
batch_test.go | 313 -----------
circuit_breaker.go | 156 ------
circuit_breaker_test.go | 236 --------
client.go | 397 +++++++++-----
client_test.go | 515 ++++++++++--------
config.go | 129 -----
context.go | 38 --
context_test.go | 172 ------
dsn.go | 55 ++
dsn_test.go | 74 +++
errors.go | 52 +-
errors_test.go | 149 +----
examples/basic/go.mod | 2 +-
examples/basic/main.go | 56 +-
examples/echo/go.mod | 18 +-
examples/echo/go.sum | 41 ++
examples/echo/main.go | 105 ++--
examples/gin/go.mod | 33 +-
examples/gin/go.sum | 92 ++++
examples/gin/main.go | 100 +---
examples/otel/go.mod | 2 +-
examples/otel/main.go | 61 +--
examples/stdlib/go.mod | 7 +-
examples/stdlib/go.sum | 14 +
examples/stdlib/main.go | 124 +----
go.mod | 2 +-
hub.go | 334 ++++++++++++
hub_test.go | 310 +++++++++++
integration.go | 147 +++++
integration_test.go | 249 +++++++++
integrations/nethttp/nethttp.go | 140 +++++
integrations/nethttp/nethttp_test.go | 207 +++++++
integrations/otelexport/otelexport.go | 145 +++++
integrations/otelexport/otelexport_test.go | 76 +++
internal/batch/batch.go | 164 ++++++
internal/batch/batch_test.go | 133 +++++
internal/circuitbreaker/circuitbreaker.go | 138 +++++
.../circuitbreaker/circuitbreaker_test.go | 184 +++++++
internal/http/client.go | 129 -----
internal/httpclient/httpclient.go | 119 ++++
internal/retry/retry.go | 94 ++++
internal/retry/retry_test.go | 142 +++++
options.go | 135 +++++
retry.go | 114 ----
retry_test.go | 249 ---------
scope.go | 251 +++++++++
scope_test.go | 260 +++++++++
stacktrace.go | 141 +++++
stacktrace_test.go | 91 ++++
tracing.go | 46 ++
tracing_test.go | 77 +++
transport.go | 144 +++++
types.go | 131 +++--
validation.go | 73 ---
validation_test.go | 209 -------
60 files changed, 4915 insertions(+), 2883 deletions(-)
delete mode 100644 .claude/settings.local.json
delete mode 100644 batch.go
delete mode 100644 batch_test.go
delete mode 100644 circuit_breaker.go
delete mode 100644 circuit_breaker_test.go
delete mode 100644 config.go
delete mode 100644 context.go
delete mode 100644 context_test.go
create mode 100644 dsn.go
create mode 100644 dsn_test.go
create mode 100644 examples/echo/go.sum
create mode 100644 examples/gin/go.sum
create mode 100644 examples/stdlib/go.sum
create mode 100644 hub.go
create mode 100644 hub_test.go
create mode 100644 integration.go
create mode 100644 integration_test.go
create mode 100644 integrations/nethttp/nethttp.go
create mode 100644 integrations/nethttp/nethttp_test.go
create mode 100644 integrations/otelexport/otelexport.go
create mode 100644 integrations/otelexport/otelexport_test.go
create mode 100644 internal/batch/batch.go
create mode 100644 internal/batch/batch_test.go
create mode 100644 internal/circuitbreaker/circuitbreaker.go
create mode 100644 internal/circuitbreaker/circuitbreaker_test.go
delete mode 100644 internal/http/client.go
create mode 100644 internal/httpclient/httpclient.go
create mode 100644 internal/retry/retry.go
create mode 100644 internal/retry/retry_test.go
create mode 100644 options.go
delete mode 100644 retry.go
delete mode 100644 retry_test.go
create mode 100644 scope.go
create mode 100644 scope_test.go
create mode 100644 stacktrace.go
create mode 100644 stacktrace_test.go
create mode 100644 tracing.go
create mode 100644 tracing_test.go
create mode 100644 transport.go
delete mode 100644 validation.go
delete mode 100644 validation_test.go
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/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/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/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)
- }
- })
- }
-}
From 34019b9f19f1ee5db534a0919b122838f2073022 Mon Sep 17 00:00:00 2001
From: Polliog
Date: Sun, 22 Mar 2026 14:18:00 +0100
Subject: [PATCH 2/3] docs: rewrite README for v0.8.4 API and remove docs/
directory
---
README.md | 317 ++++++++++++++++-------------
docs/INSTALLATION.md | 149 --------------
docs/INTEGRATIONS.md | 460 -------------------------------------------
docs/QUICKSTART.md | 306 ----------------------------
4 files changed, 176 insertions(+), 1056 deletions(-)
delete mode 100644 docs/INSTALLATION.md
delete mode 100644 docs/INTEGRATIONS.md
delete mode 100644 docs/QUICKSTART.md
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/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)
From c7627817961ec61a26977a74ffeaaa764143b629 Mon Sep 17 00:00:00 2001
From: Polliog
Date: Sun, 22 Mar 2026 14:20:18 +0100
Subject: [PATCH 3/3] ci: add golangci-lint config, exclude errcheck from test
files
---
.golangci.yml | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 .golangci.yml
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