From c064e30629de3eaebd8a25dd297b28809a72e1fe Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Wed, 10 Dec 2025 19:37:58 +0330 Subject: [PATCH 01/23] feat: implement adaptors --- adapters/README.md | 211 ++++++++++++++++++++++++++ adapters/file_storage_adapter.go | 54 +++++++ adapters/file_storage_adapter_test.go | 88 +++++++++++ adapters/http_adapter.go | 22 +++ adapters/net_http_adapter.go | 56 +++++++ adapters/net_http_adapter_test.go | 90 +++++++++++ adapters/storage_adapter.go | 23 +++ adapters/types.go | 21 +++ 8 files changed, 565 insertions(+) create mode 100644 adapters/README.md create mode 100644 adapters/file_storage_adapter.go create mode 100644 adapters/file_storage_adapter_test.go create mode 100644 adapters/http_adapter.go create mode 100644 adapters/net_http_adapter.go create mode 100644 adapters/net_http_adapter_test.go create mode 100644 adapters/storage_adapter.go create mode 100644 adapters/types.go diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 0000000..0493622 --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,211 @@ +# Ripple Go Adapters + +This package contains the adapter interfaces and default implementations for the Ripple Go SDK. + +## Interfaces + +### HTTPAdapter + +Interface for HTTP communication. Implement this to use custom HTTP clients. + +```go +type HTTPAdapter interface { + Send(endpoint string, events []Event, headers map[string]string) (*HTTPResponse, error) +} +``` + +**Default Implementation:** `NetHTTPAdapter` +- Uses Go's standard `net/http` package +- Sends events as JSON POST requests +- Supports custom headers + +### StorageAdapter + +Interface for event persistence. Implement this to use custom storage backends. + +```go +type StorageAdapter interface { + Save(events []Event) error + Load() ([]Event, error) + Clear() error +} +``` + +**Default Implementation:** `DefaultStorageAdapter` +- Stores events as JSON in a file +- Default file: `ripple_events.json` +- Suitable for server environments + +## Custom Implementations + +### Example: Custom HTTP Adapter + +```go +package main + +import "github.com/Tap30/ripple-go/adapters" + +type MyHTTPAdapter struct { + // your custom fields +} + +func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers map[string]string) (*adapters.HTTPResponse, error) { + // your custom HTTP logic + // e.g., using gRPC, custom retry logic, etc. + return &adapters.HTTPResponse{OK: true, Status: 200}, nil +} +``` + +### Example: Redis Storage Adapter + +```go +package main + +import ( + "encoding/json" + "github.com/Tap30/ripple-go/adapters" + "github.com/redis/go-redis/v9" +) + +type RedisStorageAdapter struct { + client *redis.Client + key string +} + +func NewRedisStorageAdapter(client *redis.Client, key string) *RedisStorageAdapter { + return &RedisStorageAdapter{client: client, key: key} +} + +func (r *RedisStorageAdapter) Save(events []adapters.Event) error { + data, err := json.Marshal(events) + if err != nil { + return err + } + return r.client.Set(ctx, r.key, data, 0).Err() +} + +func (r *RedisStorageAdapter) Load() ([]adapters.Event, error) { + data, err := r.client.Get(ctx, r.key).Result() + if err == redis.Nil { + return []adapters.Event{}, nil + } + if err != nil { + return nil, err + } + + var events []adapters.Event + if err := json.Unmarshal([]byte(data), &events); err != nil { + return nil, err + } + return events, nil +} + +func (r *RedisStorageAdapter) Clear() error { + return r.client.Del(ctx, r.key).Err() +} +``` + +### Example: Database Storage Adapter + +```go +package main + +import ( + "database/sql" + "encoding/json" + "github.com/Tap30/ripple-go/adapters" +) + +type DatabaseStorageAdapter struct { + db *sql.DB +} + +func NewDatabaseStorageAdapter(db *sql.DB) *DatabaseStorageAdapter { + return &DatabaseStorageAdapter{db: db} +} + +func (d *DatabaseStorageAdapter) Save(events []adapters.Event) error { + tx, err := d.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, event := range events { + data, _ := json.Marshal(event) + _, err := tx.Exec("INSERT INTO events (data) VALUES (?)", data) + if err != nil { + return err + } + } + + return tx.Commit() +} + +func (d *DatabaseStorageAdapter) Load() ([]adapters.Event, error) { + rows, err := d.db.Query("SELECT data FROM events") + if err != nil { + return nil, err + } + defer rows.Close() + + var events []adapters.Event + for rows.Next() { + var data []byte + if err := rows.Scan(&data); err != nil { + return nil, err + } + var event adapters.Event + if err := json.Unmarshal(data, &event); err != nil { + return nil, err + } + events = append(events, event) + } + + return events, nil +} + +func (d *DatabaseStorageAdapter) Clear() error { + _, err := d.db.Exec("DELETE FROM events") + return err +} +``` + +## Usage with Client + +```go +package main + +import ( + ripple "github.com/Tap30/ripple-go" + "github.com/Tap30/ripple-go/adapters" +) + +func main() { + client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + }) + + // Set custom adapters before Init() + client.SetHTTPAdapter(&MyHTTPAdapter{}) + client.SetStorageAdapter(adapters.NewDefaultStorageAdapter("custom_path.json")) + + client.Init() + defer client.Dispose() + + // Use the client normally + client.Track("event", nil, nil) +} +``` + +## Design Philosophy + +The adapter pattern allows you to: + +1. **Swap implementations** without changing core SDK code +2. **Test easily** by using mock adapters +3. **Extend functionality** for specific use cases +4. **Maintain compatibility** across different environments + +This matches the TypeScript implementation's approach while following Go idioms. diff --git a/adapters/file_storage_adapter.go b/adapters/file_storage_adapter.go new file mode 100644 index 0000000..73c297e --- /dev/null +++ b/adapters/file_storage_adapter.go @@ -0,0 +1,54 @@ +package adapters + +import ( + "encoding/json" + "os" +) + +// FileStorageAdapter is the default storage adapter implementation using file system. +// Stores events as JSON in a file. +type FileStorageAdapter struct { + filepath string +} + +// Ensure FileStorageAdapter implements StorageAdapter interface +var _ StorageAdapter = (*FileStorageAdapter)(nil) + +// NewFileStorageAdapter creates a new FileStorageAdapter instance. +// +// Parameters: +// - filepath: Path to the file where events will be stored +func NewFileStorageAdapter(filepath string) StorageAdapter { + return &FileStorageAdapter{filepath: filepath} +} + +// Save persists events to a JSON file. +func (f *FileStorageAdapter) Save(events []Event) error { + data, err := json.Marshal(events) + if err != nil { + return err + } + return os.WriteFile(f.filepath, data, 0644) +} + +// Load retrieves events from a JSON file. +// Returns empty array if file doesn't exist. +func (f *FileStorageAdapter) Load() ([]Event, error) { + data, err := os.ReadFile(f.filepath) + if err != nil { + if os.IsNotExist(err) { + return []Event{}, nil + } + return nil, err + } + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return nil, err + } + return events, nil +} + +// Clear removes the storage file. +func (f *FileStorageAdapter) Clear() error { + return os.Remove(f.filepath) +} diff --git a/adapters/file_storage_adapter_test.go b/adapters/file_storage_adapter_test.go new file mode 100644 index 0000000..f02599c --- /dev/null +++ b/adapters/file_storage_adapter_test.go @@ -0,0 +1,88 @@ +package adapters + +import ( + "os" + "testing" +) + +func TestFileStorageAdapter_SaveLoad(t *testing.T) { + filepath := "test_events.json" + defer os.Remove(filepath) + + adapter := NewFileStorageAdapter(filepath) + events := []Event{{Name: "test1"}, {Name: "test2"}} + + if err := adapter.Save(events); err != nil { + t.Fatalf("failed to save: %v", err) + } + + loaded, err := adapter.Load() + if err != nil { + t.Fatalf("failed to load: %v", err) + } + + if len(loaded) != 2 || loaded[0].Name != "test1" || loaded[1].Name != "test2" { + t.Fatal("loaded events do not match saved events") + } +} + +func TestFileStorageAdapter_LoadNonExistent(t *testing.T) { + adapter := NewFileStorageAdapter("nonexistent.json") + loaded, err := adapter.Load() + if err != nil { + t.Fatalf("expected no error for nonexistent file: %v", err) + } + if len(loaded) != 0 { + t.Fatal("expected empty slice for nonexistent file") + } +} + +func TestFileStorageAdapter_Clear(t *testing.T) { + filepath := "test_clear.json" + adapter := NewFileStorageAdapter(filepath) + adapter.Save([]Event{{Name: "test"}}) + + if err := adapter.Clear(); err != nil { + t.Fatalf("failed to clear: %v", err) + } + + if _, err := os.Stat(filepath); !os.IsNotExist(err) { + t.Fatal("expected file to be deleted") + } +} + +func TestFileStorageAdapter_SaveError(t *testing.T) { + adapter := NewFileStorageAdapter("/invalid/path/test.json") + err := adapter.Save([]Event{{Name: "test"}}) + if err == nil { + t.Fatal("expected error for invalid path") + } +} + +func TestFileStorageAdapter_LoadInvalidJSON(t *testing.T) { + filepath := "test_invalid.json" + defer os.Remove(filepath) + + os.WriteFile(filepath, []byte("invalid json"), 0644) + + adapter := NewFileStorageAdapter(filepath) + _, err := adapter.Load() + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestFileStorageAdapter_SaveMarshalError(t *testing.T) { + filepath := "test_marshal.json" + defer os.Remove(filepath) + + adapter := NewFileStorageAdapter(filepath) + events := []Event{{ + Name: "test", + Payload: map[string]interface{}{"invalid": make(chan int)}, + }} + err := adapter.Save(events) + if err == nil { + t.Fatal("expected error for unmarshalable data") + } +} diff --git a/adapters/http_adapter.go b/adapters/http_adapter.go new file mode 100644 index 0000000..4a0e0e1 --- /dev/null +++ b/adapters/http_adapter.go @@ -0,0 +1,22 @@ +package adapters + +// HTTPResponse represents the response from an HTTP request. +type HTTPResponse struct { + OK bool + Status int + Data interface{} +} + +// HTTPAdapter is an interface for HTTP communication. +// Implement this interface to use custom HTTP clients. +type HTTPAdapter interface { + // Send events to the specified endpoint. + // + // Parameters: + // - endpoint: The API endpoint URL + // - events: Array of events to send + // - headers: Optional custom headers to merge with defaults + // + // Returns HTTP response or error. + Send(endpoint string, events []Event, headers map[string]string) (*HTTPResponse, error) +} diff --git a/adapters/net_http_adapter.go b/adapters/net_http_adapter.go new file mode 100644 index 0000000..b331569 --- /dev/null +++ b/adapters/net_http_adapter.go @@ -0,0 +1,56 @@ +package adapters + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +// NetHTTPAdapter is the standard HTTP adapter implementation using net/http package. +type NetHTTPAdapter struct { + client *http.Client +} + +// Ensure NetHTTPAdapter implements HTTPAdapter interface +var _ HTTPAdapter = (*NetHTTPAdapter)(nil) + +// NewNetHTTPAdapter creates a new NetHTTPAdapter instance. +func NewNetHTTPAdapter() HTTPAdapter { + return &NetHTTPAdapter{ + client: &http.Client{}, + } +} + +// Send sends events to the specified endpoint with the given headers. +func (h *NetHTTPAdapter) Send(endpoint string, events []Event, headers map[string]string) (*HTTPResponse, error) { + payload := map[string]interface{}{ + "events": events, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal events: %w", err) + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := h.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + return &HTTPResponse{ + Status: resp.StatusCode, + OK: resp.StatusCode >= 200 && resp.StatusCode < 300, + }, nil +} diff --git a/adapters/net_http_adapter_test.go b/adapters/net_http_adapter_test.go new file mode 100644 index 0000000..6593aec --- /dev/null +++ b/adapters/net_http_adapter_test.go @@ -0,0 +1,90 @@ +package adapters + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNetHTTPAdapter_Send(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Error("expected Content-Type: application/json") + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Error("expected Authorization header") + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + adapter := NewNetHTTPAdapter() + events := []Event{{Name: "test"}} + headers := map[string]string{"Authorization": "Bearer test-key"} + + resp, err := adapter.Send(server.URL, events, headers) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !resp.OK || resp.Status != 200 { + t.Fatal("expected successful response") + } +} + +func TestNetHTTPAdapter_SendError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + adapter := NewNetHTTPAdapter() + events := []Event{{Name: "test"}} + + resp, err := adapter.Send(server.URL, events, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.OK { + t.Fatal("expected response to not be OK") + } + if resp.Status != 500 { + t.Fatalf("expected status 500, got %d", resp.Status) + } +} + +func TestNetHTTPAdapter_SendInvalidURL(t *testing.T) { + adapter := NewNetHTTPAdapter() + events := []Event{{Name: "test"}} + + _, err := adapter.Send("http://invalid-url-that-does-not-exist-12345.com", events, nil) + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestNetHTTPAdapter_SendMarshalError(t *testing.T) { + adapter := NewNetHTTPAdapter() + events := []Event{{ + Name: "test", + Payload: map[string]interface{}{"invalid": make(chan int)}, + }} + + _, err := adapter.Send("http://test.com", events, nil) + if err == nil { + t.Fatal("expected error for unmarshalable data") + } +} + +func TestNetHTTPAdapter_SendInvalidMethod(t *testing.T) { + adapter := NewNetHTTPAdapter() + events := []Event{{Name: "test"}} + + _, err := adapter.Send("ht!tp://invalid", events, nil) + if err == nil { + t.Fatal("expected error for invalid URL") + } +} diff --git a/adapters/storage_adapter.go b/adapters/storage_adapter.go new file mode 100644 index 0000000..681d3db --- /dev/null +++ b/adapters/storage_adapter.go @@ -0,0 +1,23 @@ +package adapters + +// StorageAdapter is an interface for event persistence. +// Implement this interface to use custom storage backends (database, Redis, S3, etc.). +type StorageAdapter interface { + // Save persists events to storage. + // + // Parameters: + // - events: Array of events to save + // + // Returns error if save fails. + Save(events []Event) error + + // Load retrieves persisted events from storage. + // + // Returns array of events or error. + Load() ([]Event, error) + + // Clear removes all persisted events from storage. + // + // Returns error if clear fails. + Clear() error +} diff --git a/adapters/types.go b/adapters/types.go new file mode 100644 index 0000000..64ff2f1 --- /dev/null +++ b/adapters/types.go @@ -0,0 +1,21 @@ +package adapters + +// Event represents a tracked event. +type Event struct { + Name string `json:"name"` + Payload map[string]interface{} `json:"payload,omitempty"` + IssuedAt int64 `json:"issuedAt"` + Context map[string]interface{} `json:"context,omitempty"` + Metadata *EventMetadata `json:"metadata,omitempty"` + Platform *Platform `json:"platform,omitempty"` +} + +// EventMetadata contains optional event metadata. +type EventMetadata struct { + SchemaVersion string `json:"schemaVersion,omitempty"` +} + +// Platform identifies the runtime environment. +type Platform struct { + Type string `json:"type"` +} From be30d59b29085cb2aca9fb750b322aea467fdfd2 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Wed, 10 Dec 2025 19:38:25 +0330 Subject: [PATCH 02/23] feat: add core logic --- .gitignore | 34 ++++ ONBOARDING.md | 436 +++++++++++++++++++++++++++++++++++++++++++++ client.go | 105 +++++++++++ client_test.go | 139 +++++++++++++++ dispatcher.go | 165 +++++++++++++++++ dispatcher_test.go | 186 +++++++++++++++++++ go.mod | 3 + queue.go | 79 ++++++++ queue_test.go | 79 ++++++++ types_test.go | 10 ++ 10 files changed, 1236 insertions(+) create mode 100644 .gitignore create mode 100644 ONBOARDING.md create mode 100644 client.go create mode 100644 client_test.go create mode 100644 dispatcher.go create mode 100644 dispatcher_test.go create mode 100644 go.mod create mode 100644 queue.go create mode 100644 queue_test.go create mode 100644 types_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..975c505 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS specific files +.DS_Store +Thumbs.db + +# Ripple specific +ripple_events.json +test_*.json diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 0000000..af25843 --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,436 @@ +# Ripple Go SDK - Complete Implementation Guide + +## Recent Changes + +### Adapter Naming Refactor +- Renamed `DefaultHTTPAdapter` to `NetHTTPAdapter` for better Go conventions +- Updated constructor: `NewDefaultHTTPAdapter()` → `NewNetHTTPAdapter()` + +### Timer Behavior Enhancement +- Timer now only starts when first new event is tracked, not during SDK initialization +- If persisted events exist, they remain in queue until a new event triggers the timer +- Maintains same API while improving efficiency for apps with persisted events + +### Graceful Shutdown Enhancement +- Added `StopWithoutFlush()` and `DisposeWithoutFlush()` methods for graceful shutdown without flushing events +- Fixed playground client exit behavior to persist events without sending to server + +## Project Overview + +Ripple Go is a high-performance, fault-tolerant event tracking SDK implemented as a single Go package. It provides reliable event delivery, batching, retries, persistence, and graceful shutdown for server-side applications. + +This version is not a monorepo. It has no browser package, no Node.js package, and no internal modules exposed. All functionality exists within one cohesive Go module. + +## SDK Features + +### Core Features + +* **Context Management** – shared context automatically attached to all events +* **Event Metadata** – optional schema versioning +* **Automatic Batching** – dispatch based on batch size +* **Scheduled Flushing** – time-based flush via goroutines +* **Retry Logic** – exponential backoff with jitter +* **Event Persistence** – disk-backed storage for unsent events +* **Queue Management** – FIFO queue using `container/list` +* **Graceful Shutdown** – flushes and persists all events on dispose +* **Adapters** – pluggable HTTP and storage implementations + +### Go-Specific Features + +* **Safe concurrency** (mutex-protected dispatcher and context) +* **Native HTTP client** (`net/http`) +* **File-based persistence** using JSON +* **Automatic boot-time recovery** from persisted events +* **Zero external dependencies**; uses only standard library + +### Configuration + +```go +type ClientConfig struct { + APIKey string + Endpoint string + FlushInterval time.Duration // Default: 5s + MaxBatchSize int // Default: 10 + MaxRetries int // Default: 3 +} +``` + +### Developer Experience + +* Simple, predictable API +* Explicit `error` returns +* Comprehensive tests for all components +* No external dependencies +* Practical examples included + +--- + +## Architecture + +### Project Structure + +```sh +ripple-go/ +├── client.go +├── client_test.go +├── dispatcher.go +├── dispatcher_test.go +├── queue.go +├── queue_test.go +├── types.go +├── types_test.go +├── go.mod +├── README.md +├── ONBOARDING.md +├── adapters/ +│ ├── http_adapter.go +│ ├── http_adapter_test.go +│ ├── storage_adapter.go +│ ├── file_storage_adapter.go +│ ├── file_storage_adapter_test.go +│ ├── types.go +│ └── README.md +├── examples/ +│ └── basic/ +│ ├── go.mod +│ └── main.go +└── playground/ + ├── server.go + ├── client.go + ├── go.mod + ├── Makefile + └── README.md +``` + +### Components + +#### Client + +Entry point for the SDK. +Responsibilities: + +* Initialization +* Managing global context +* Accepting new events +* Passing events to the dispatcher +* Exposing flushing and shutdown + +Thread safety is enforced through internal locking. + +Key methods: + +* `Init()` +* `Track(name, payload, metadata)` +* `SetContext(key, value)` +* `GetContext()` +* `SetHTTPAdapter(adapter)` - Set custom HTTP adapter (before Init) +* `SetStorageAdapter(adapter)` - Set custom storage adapter (before Init) +* `Flush()` +* `Dispose()` + +#### Context Manager + +Provides thread-safe access to global context: + +* Stored as `map[string]interface{}` +* Protected with `sync.RWMutex` +* Merged into every event at dispatch time + +#### Dispatcher + +Handles all operational concerns: + +* Queueing +* Persistence +* Automatic and manual flushing +* Batch formation +* Retry with exponential backoff and jitter +* De-queuing and re-queuing failed events +* Loading persisted events on startup +* Graceful shutdown + +A single mutex prevents concurrent flushes. + +#### Queue + +The queue is built on Go's `container/list` and wrapped in a small API: + +* FIFO ordering +* O(1) enqueue/dequeue +* Thread-safe +* Slice conversion helpers for persistence + +Wrapper methods include: + +* `Enqueue(event)` +* `Dequeue()` +* `IsEmpty()` +* `Len()` +* `Clear()` +* `ToSlice()` +* `LoadFromSlice(events)` + +#### HTTP Adapter + +Interface defined in `adapters/http_adapter.go`: + +```go +type HTTPAdapter interface { + Send(endpoint string, events []Event, headers map[string]string) (*HTTPResponse, error) +} +``` + +Default implementation (`NetHTTPAdapter`): + +* Uses `net/http` +* JSON payloads +* Combined headers (default + user headers) + +#### Storage Adapter + +Interface defined in `adapters/storage_adapter.go`: + +```go +type StorageAdapter interface { + Save(events []Event) error + Load() ([]Event, error) + Clear() error +} +``` + +Default implementation (`FileStorageAdapter`): + +* JSON file written to disk (`ripple_events.json`) +* Unlimited capacity +* Suitable for server environments + +--- + +## Types + +### EventMetadata + +```go +type EventMetadata struct { + SchemaVersion string `json:"schemaVersion,omitempty"` +} +``` + +### Platform + +All events identify the runtime as server: + +```go +type Platform struct { + Type string `json:"type"` // "server" +} +``` + +### Event + +```go +type Event struct { + Name string `json:"name"` + Payload map[string]interface{} `json:"payload,omitempty"` + IssuedAt int64 `json:"issuedAt"` + Context map[string]interface{} `json:"context,omitempty"` + Metadata *EventMetadata `json:"metadata,omitempty"` + Platform *Platform `json:"platform,omitempty"` +} +``` + +### DispatcherConfig + +```go +type DispatcherConfig struct { + Endpoint string + FlushInterval time.Duration + MaxBatchSize int + MaxRetries int +} +``` + +### HTTPResponse + +```go +type HTTPResponse struct { + OK bool + Status int + Data interface{} +} +``` + +--- + +## Usage Examples + +### Basic Usage + +```go +client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", +}) + +if err := client.Init(); err != nil { + panic(err) +} +defer client.Dispose() + +client.SetContext("userId", "123") +client.SetContext("appVersion", "1.0.0") + +client.Track("page_view", map[string]interface{}{ + "page": "/home", +}, nil) + +client.Track("user_action", map[string]interface{}{ + "button": "submit", +}, &ripple.EventMetadata{SchemaVersion: "1.0.0"}) + +client.Flush() +``` + +### Using Metadata + +```go +client.Track("user_signup", map[string]interface{}{ + "email": "user@example.com", +}, &ripple.EventMetadata{SchemaVersion: "1.0.0"}) +``` + +### Custom HTTP Adapter + +```go +import "github.com/Tap30/ripple-go/adapters" + +type MyHTTPAdapter struct {} + +func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers map[string]string) (*adapters.HTTPResponse, error) { + // custom logic + return &adapters.HTTPResponse{OK: true, Status: 200}, nil +} +``` + +### Custom Storage Adapter + +```go +import "github.com/Tap30/ripple-go/adapters" + +type RedisStorage struct {} + +func (r *RedisStorage) Save(events []adapters.Event) error { /* ... */ return nil } +func (r *RedisStorage) Load() ([]adapters.Event, error) { /* ... */ return nil, nil } +func (r *RedisStorage) Clear() error { /* ... */ return nil } +``` + +--- + +## Development Workflow + +### Testing + +The project includes test files for every component: + +* `client_test.go` +* `dispatcher_test.go` +* `queue_test.go` +* `storage_adapter_test.go` +* `http_adapter_test.go` + +### Commands + +* `go build ./...` - Build all packages +* `go test ./...` - Run all tests +* `go test -v ./...` - Run tests with verbose output +* `go test -cover ./...` - Run tests with coverage +* `go vet ./...` - Run Go vet for static analysis + +### Playground + +The playground provides a local testing environment: + +* `playground/server.go` - HTTP server that receives and logs events +* `playground/client.go` - Example client that sends events + +**Usage:** +```bash +# Terminal 1: Start server +cd playground && make server + +# Terminal 2: Run client +cd playground && make client +``` + +See [playground/README.md](./playground/README.md) for E2E testing scenarios. + +### Recommendations + +* Strong test coverage for dispatcher and queue logic +* Integration tests for persistence and HTTP transport +* Benchmarks for high-volume event throughput +* Linting via `golangci-lint` + +--- + +## Design Principles + +### Clear Responsibilities + +* Client: API surface +* Dispatcher: internal mechanics +* Queue: data structure, thread-safe +* Adapters: extensibility + +### Concurrency Safety + +* Mutex around flush cycles +* RWMutex for context access +* Controlled goroutine lifecycle + +### Reliability + +* Persistent queueing +* Retried delivery with backoff +* Safe process shutdown + +### Simplicity + +* Single self-contained package +* No external dependencies +* Clean, predictable API + +--- + +## Implementation Notes + +### File Organization + +Following Go best practices: +* All source files in root directory (no `src/` folder) +* Test files co-located with source (`*_test.go`) +* Adapters in separate `adapters/` package for modularity +* Examples in `examples/` subdirectory +* Single main package name: `ripple` +* Adapter interfaces and implementations in `adapters` package + +### Concurrency Model + +* Dispatcher runs a background goroutine for scheduled flushing +* All queue operations are mutex-protected +* Context reads use RWMutex for concurrent access +* Flush operations are serialized to prevent race conditions + +### Error Handling + +* All errors are returned explicitly +* No panics in library code +* Graceful degradation on network failures +* Failed events are re-queued and persisted + +### Memory Management + +* Events are stored in a linked list for efficient FIFO operations +* Batching prevents unbounded memory growth +* Persistence ensures events survive process restarts +* No memory leaks from goroutines (proper cleanup on Dispose) diff --git a/client.go b/client.go new file mode 100644 index 0000000..f5083e9 --- /dev/null +++ b/client.go @@ -0,0 +1,105 @@ +package ripple + +import ( + "sync" + "time" + + "github.com/Tap30/ripple-go/adapters" +) + +type Client struct { + config ClientConfig + context map[string]interface{} + contextMu sync.RWMutex + dispatcher *Dispatcher + httpAdapter HTTPAdapter + storageAdapter StorageAdapter +} + +func NewClient(config ClientConfig) *Client { + if config.FlushInterval == 0 { + config.FlushInterval = 5 * time.Second + } + if config.MaxBatchSize == 0 { + config.MaxBatchSize = 10 + } + if config.MaxRetries == 0 { + config.MaxRetries = 3 + } + + return &Client{ + config: config, + context: make(map[string]interface{}), + httpAdapter: adapters.NewNetHTTPAdapter(), + storageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + } +} + +// SetHTTPAdapter sets a custom HTTP adapter. +// Must be called before Init(). +func (c *Client) SetHTTPAdapter(adapter HTTPAdapter) { + c.httpAdapter = adapter +} + +// SetStorageAdapter sets a custom storage adapter. +// Must be called before Init(). +func (c *Client) SetStorageAdapter(adapter StorageAdapter) { + c.storageAdapter = adapter +} + +func (c *Client) Init() error { + headers := map[string]string{ + "Authorization": "Bearer " + c.config.APIKey, + } + + dispatcherConfig := DispatcherConfig{ + Endpoint: c.config.Endpoint, + FlushInterval: c.config.FlushInterval, + MaxBatchSize: c.config.MaxBatchSize, + MaxRetries: c.config.MaxRetries, + } + + c.dispatcher = NewDispatcher(dispatcherConfig, c.httpAdapter, c.storageAdapter, headers) + return c.dispatcher.Start() +} + +func (c *Client) SetContext(key string, value interface{}) { + c.contextMu.Lock() + defer c.contextMu.Unlock() + c.context[key] = value +} + +func (c *Client) GetContext() map[string]interface{} { + c.contextMu.RLock() + defer c.contextMu.RUnlock() + ctx := make(map[string]interface{}, len(c.context)) + for k, v := range c.context { + ctx[k] = v + } + return ctx +} + +func (c *Client) Track(name string, payload map[string]interface{}, metadata *EventMetadata) { + event := Event{ + Name: name, + Payload: payload, + IssuedAt: time.Now().UnixMilli(), + Context: c.GetContext(), + Metadata: metadata, + Platform: &Platform{Type: "server"}, + } + c.dispatcher.Enqueue(event) +} + +func (c *Client) Flush() { + c.dispatcher.Flush() +} + +func (c *Client) Dispose() error { + return c.dispatcher.Stop() +} + +// DisposeWithoutFlush stops the client and persists events to storage without flushing to server +func (c *Client) DisposeWithoutFlush() error { + return c.dispatcher.StopWithoutFlush() +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..e4c0671 --- /dev/null +++ b/client_test.go @@ -0,0 +1,139 @@ +package ripple + +import ( + "testing" + "time" +) + +func TestClient_SetGetContext(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + client.SetContext("userId", "123") + client.SetContext("appVersion", "1.0.0") + + ctx := client.GetContext() + if ctx["userId"] != "123" || ctx["appVersion"] != "1.0.0" { + t.Fatal("context values do not match") + } +} + +func TestClient_Track(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + defer client.Dispose() + + client.SetContext("userId", "123") + client.Track("page_view", map[string]interface{}{"page": "/home"}, nil) + + time.Sleep(100 * time.Millisecond) + + if client.dispatcher.queue.Len() == 0 && mockHTTP.calls == 0 { + t.Fatal("expected event to be tracked") + } +} + +func TestClient_TrackWithMetadata(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + defer client.Dispose() + + metadata := &EventMetadata{SchemaVersion: "1.0.0"} + client.Track("user_signup", map[string]interface{}{"email": "test@example.com"}, metadata) + + time.Sleep(100 * time.Millisecond) + + if client.dispatcher.queue.Len() == 0 && mockHTTP.calls == 0 { + t.Fatal("expected event with metadata to be tracked") + } +} + +func TestClient_Flush(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + defer client.Dispose() + + client.Track("test_event", nil, nil) + client.Flush() + + if mockHTTP.calls != 1 { + t.Fatalf("expected 1 HTTP call, got %d", mockHTTP.calls) + } +} + +func TestClient_DefaultConfig(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + if client.config.FlushInterval != 5*time.Second { + t.Fatal("expected default flush interval of 5s") + } + if client.config.MaxBatchSize != 10 { + t.Fatal("expected default max batch size of 10") + } + if client.config.MaxRetries != 3 { + t.Fatal("expected default max retries of 3") + } +} + +func TestClient_SetCustomAdapters(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + customHTTP := &mockHTTPAdapter{} + customStorage := &mockStorageAdapter{} + + client.SetHTTPAdapter(customHTTP) + client.SetStorageAdapter(customStorage) + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + defer client.Dispose() + + client.Track("test", nil, nil) + client.Flush() + + if customHTTP.calls == 0 { + t.Fatal("expected custom HTTP adapter to be used") + } +} diff --git a/dispatcher.go b/dispatcher.go new file mode 100644 index 0000000..81c851f --- /dev/null +++ b/dispatcher.go @@ -0,0 +1,165 @@ +package ripple + +import ( + "math" + "math/rand" + "sync" + "time" +) + +type Dispatcher struct { + config DispatcherConfig + queue *Queue + httpAdapter HTTPAdapter + storageAdapter StorageAdapter + headers map[string]string + ticker *time.Ticker + stopChan chan struct{} + flushMu sync.Mutex + wg sync.WaitGroup + timerStarted bool + timerMu sync.Mutex +} + +func NewDispatcher(config DispatcherConfig, httpAdapter HTTPAdapter, storageAdapter StorageAdapter, headers map[string]string) *Dispatcher { + return &Dispatcher{ + config: config, + queue: NewQueue(), + httpAdapter: httpAdapter, + storageAdapter: storageAdapter, + headers: headers, + stopChan: make(chan struct{}), + } +} + +func (d *Dispatcher) Start() error { + events, err := d.storageAdapter.Load() + if err != nil { + return err + } + d.queue.LoadFromSlice(events) + + // Don't start timer yet - wait for first new event + return nil +} + +func (d *Dispatcher) Enqueue(event Event) { + d.queue.Enqueue(event) + + // Start timer on first new event + d.startTimerIfNeeded() + + if d.queue.Len() >= d.config.MaxBatchSize { + go d.Flush() + } +} + +func (d *Dispatcher) startTimerIfNeeded() { + d.timerMu.Lock() + defer d.timerMu.Unlock() + + if !d.timerStarted { + d.ticker = time.NewTicker(d.config.FlushInterval) + d.timerStarted = true + d.wg.Add(1) + go func() { + defer d.wg.Done() + for { + select { + case <-d.ticker.C: + d.Flush() + case <-d.stopChan: + return + } + } + }() + } +} + +func (d *Dispatcher) Flush() { + d.flushMu.Lock() + defer d.flushMu.Unlock() + + for !d.queue.IsEmpty() { + batchSize := min(d.config.MaxBatchSize, d.queue.Len()) + batch := make([]Event, 0, batchSize) + for i := 0; i < batchSize; i++ { + if event, ok := d.queue.Dequeue(); ok { + batch = append(batch, event) + } + } + + if len(batch) == 0 { + break + } + + if err := d.sendWithRetry(batch); err != nil { + for _, event := range batch { + d.queue.Enqueue(event) + } + break + } + } +} + +func (d *Dispatcher) sendWithRetry(events []Event) error { + var lastErr error + for attempt := 0; attempt <= d.config.MaxRetries; attempt++ { + resp, err := d.httpAdapter.Send(d.config.Endpoint, events, d.headers) + if err == nil && resp.OK { + d.storageAdapter.Clear() + return nil + } + if err != nil { + lastErr = err + } else { + lastErr = &HTTPError{Status: resp.Status} + } + + if attempt < d.config.MaxRetries { + backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second + jitter := time.Duration(rand.Intn(1000)) * time.Millisecond + time.Sleep(backoff + jitter) + } + } + return lastErr +} + +func (d *Dispatcher) Stop() error { + if d.ticker != nil { + d.ticker.Stop() + } + close(d.stopChan) + d.wg.Wait() + + d.Flush() + + events := d.queue.ToSlice() + if len(events) > 0 { + return d.storageAdapter.Save(events) + } + return nil +} + +// StopWithoutFlush stops the dispatcher and persists events to storage without flushing to server +func (d *Dispatcher) StopWithoutFlush() error { + if d.ticker != nil { + d.ticker.Stop() + } + close(d.stopChan) + d.wg.Wait() + + // Skip flush, just save events to storage + events := d.queue.ToSlice() + if len(events) > 0 { + return d.storageAdapter.Save(events) + } + return nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/dispatcher_test.go b/dispatcher_test.go new file mode 100644 index 0000000..b6516c0 --- /dev/null +++ b/dispatcher_test.go @@ -0,0 +1,186 @@ +package ripple + +import ( + "errors" + "testing" + "time" +) + +type mockHTTPAdapter struct { + calls int + fail bool + err error +} + +func (m *mockHTTPAdapter) Send(endpoint string, events []Event, headers map[string]string) (*HTTPResponse, error) { + m.calls++ + if m.err != nil { + return nil, m.err + } + if m.fail { + return &HTTPResponse{OK: false, Status: 500}, nil + } + return &HTTPResponse{OK: true, Status: 200}, nil +} + +type mockStorageAdapter struct { + saved []Event + loaded []Event + err error +} + +func (m *mockStorageAdapter) Save(events []Event) error { + if m.err != nil { + return m.err + } + m.saved = events + return nil +} + +func (m *mockStorageAdapter) Load() ([]Event, error) { + if m.err != nil { + return nil, m.err + } + return m.loaded, nil +} + +func (m *mockStorageAdapter) Clear() error { + return nil +} + +func TestDispatcher_Enqueue(t *testing.T) { + httpAdapter := &mockHTTPAdapter{} + storageAdapter := &mockStorageAdapter{} + config := DispatcherConfig{ + Endpoint: "http://test.com", + FlushInterval: 1 * time.Second, + MaxBatchSize: 2, + MaxRetries: 3, + } + + dispatcher := NewDispatcher(config, httpAdapter, storageAdapter, nil) + dispatcher.Start() + defer dispatcher.Stop() + + dispatcher.Enqueue(Event{Name: "test1"}) + dispatcher.Enqueue(Event{Name: "test2"}) + + time.Sleep(100 * time.Millisecond) + + if httpAdapter.calls == 0 { + t.Fatal("expected HTTP adapter to be called") + } +} + +func TestDispatcher_Flush(t *testing.T) { + httpAdapter := &mockHTTPAdapter{} + storageAdapter := &mockStorageAdapter{} + config := DispatcherConfig{ + Endpoint: "http://test.com", + FlushInterval: 10 * time.Second, + MaxBatchSize: 10, + MaxRetries: 3, + } + + dispatcher := NewDispatcher(config, httpAdapter, storageAdapter, nil) + dispatcher.Start() + defer dispatcher.Stop() + + dispatcher.Enqueue(Event{Name: "test"}) + dispatcher.Flush() + + if httpAdapter.calls != 1 { + t.Fatalf("expected 1 call, got %d", httpAdapter.calls) + } +} + +func TestDispatcher_LoadPersistedEvents(t *testing.T) { + httpAdapter := &mockHTTPAdapter{} + storageAdapter := &mockStorageAdapter{ + loaded: []Event{{Name: "persisted"}}, + } + config := DispatcherConfig{ + Endpoint: "http://test.com", + FlushInterval: 10 * time.Second, + MaxBatchSize: 10, + MaxRetries: 3, + } + + dispatcher := NewDispatcher(config, httpAdapter, storageAdapter, nil) + dispatcher.Start() + + if dispatcher.queue.Len() != 1 { + t.Fatal("expected 1 persisted event in queue") + } + + dispatcher.Stop() +} + +func TestDispatcher_PersistOnStop(t *testing.T) { + httpAdapter := &mockHTTPAdapter{fail: true} + storageAdapter := &mockStorageAdapter{} + config := DispatcherConfig{ + Endpoint: "http://test.com", + FlushInterval: 10 * time.Second, + MaxBatchSize: 10, + MaxRetries: 0, + } + + dispatcher := NewDispatcher(config, httpAdapter, storageAdapter, nil) + dispatcher.Start() + dispatcher.Enqueue(Event{Name: "test"}) + + dispatcher.Stop() + + if len(storageAdapter.saved) != 1 || storageAdapter.saved[0].Name != "test" { + t.Fatal("expected events to be persisted on stop") + } +} + +func TestDispatcher_StartLoadError(t *testing.T) { + httpAdapter := &mockHTTPAdapter{} + storageAdapter := &mockStorageAdapter{err: errors.New("load error")} + config := DispatcherConfig{ + Endpoint: "http://test.com", + FlushInterval: 10 * time.Second, + MaxBatchSize: 10, + MaxRetries: 3, + } + + dispatcher := NewDispatcher(config, httpAdapter, storageAdapter, nil) + err := dispatcher.Start() + if err == nil { + t.Fatal("expected error from Start") + } +} + +func TestDispatcher_RetryWithError(t *testing.T) { + httpAdapter := &mockHTTPAdapter{err: errors.New("network error")} + storageAdapter := &mockStorageAdapter{} + config := DispatcherConfig{ + Endpoint: "http://test.com", + FlushInterval: 10 * time.Second, + MaxBatchSize: 10, + MaxRetries: 1, + } + + dispatcher := NewDispatcher(config, httpAdapter, storageAdapter, nil) + dispatcher.Start() + defer dispatcher.Stop() + + dispatcher.Enqueue(Event{Name: "test"}) + dispatcher.Flush() + + if httpAdapter.calls != 2 { + t.Fatalf("expected 2 calls (1 initial + 1 retry), got %d", httpAdapter.calls) + } +} + +func TestDispatcher_MinFunction(t *testing.T) { + if min(5, 3) != 3 { + t.Fatal("expected min(5, 3) = 3") + } + if min(2, 8) != 2 { + t.Fatal("expected min(2, 8) = 2") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ff843b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Tap30/ripple-go + +go 1.23 diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..0b63ef1 --- /dev/null +++ b/queue.go @@ -0,0 +1,79 @@ +package ripple + +import ( + "container/list" + "sync" +) + +// Queue represents a thread-safe FIFO queue for Event items. +type Queue struct { + mu sync.Mutex + list *list.List +} + +// NewQueue creates and returns a new empty Queue. +func NewQueue() *Queue { + return &Queue{list: list.New()} +} + +// Enqueue adds an Event to the end of the queue. +func (q *Queue) Enqueue(event Event) { + q.mu.Lock() + defer q.mu.Unlock() + q.list.PushBack(event) +} + +// Dequeue removes and returns the front Event in the queue. +// It returns false if the queue is empty. +func (q *Queue) Dequeue() (Event, bool) { + q.mu.Lock() + defer q.mu.Unlock() + if q.list.Len() == 0 { + return Event{}, false + } + front := q.list.Front() + q.list.Remove(front) + return front.Value.(Event), true +} + +// IsEmpty reports whether the queue has no elements. +func (q *Queue) IsEmpty() bool { + q.mu.Lock() + defer q.mu.Unlock() + return q.list.Len() == 0 +} + +// Len returns the number of Events currently in the queue. +func (q *Queue) Len() int { + q.mu.Lock() + defer q.mu.Unlock() + return q.list.Len() +} + +// Clear removes all Events from the queue. +func (q *Queue) Clear() { + q.mu.Lock() + defer q.mu.Unlock() + q.list.Init() +} + +// ToSlice returns all Events in the queue as a slice, preserving order. +func (q *Queue) ToSlice() []Event { + q.mu.Lock() + defer q.mu.Unlock() + events := make([]Event, 0, q.list.Len()) + for e := q.list.Front(); e != nil; e = e.Next() { + events = append(events, e.Value.(Event)) + } + return events +} + +// LoadFromSlice replaces the queue contents with Events from the provided slice. +func (q *Queue) LoadFromSlice(events []Event) { + q.mu.Lock() + defer q.mu.Unlock() + q.list.Init() + for _, event := range events { + q.list.PushBack(event) + } +} diff --git a/queue_test.go b/queue_test.go new file mode 100644 index 0000000..02c3212 --- /dev/null +++ b/queue_test.go @@ -0,0 +1,79 @@ +package ripple + +import "testing" + +func TestQueue_EnqueueDequeue(t *testing.T) { + q := NewQueue() + event := Event{Name: "test"} + q.Enqueue(event) + + dequeued, ok := q.Dequeue() + if !ok || dequeued.Name != "test" { + t.Fatal("expected to dequeue event") + } +} + +func TestQueue_IsEmpty(t *testing.T) { + q := NewQueue() + if !q.IsEmpty() { + t.Fatal("expected queue to be empty") + } + q.Enqueue(Event{Name: "test"}) + if q.IsEmpty() { + t.Fatal("expected queue not to be empty") + } +} + +func TestQueue_Len(t *testing.T) { + q := NewQueue() + if q.Len() != 0 { + t.Fatal("expected length 0") + } + q.Enqueue(Event{Name: "test1"}) + q.Enqueue(Event{Name: "test2"}) + if q.Len() != 2 { + t.Fatal("expected length 2") + } +} + +func TestQueue_Clear(t *testing.T) { + q := NewQueue() + q.Enqueue(Event{Name: "test"}) + q.Clear() + if !q.IsEmpty() { + t.Fatal("expected queue to be empty after clear") + } +} + +func TestQueue_ToSlice(t *testing.T) { + q := NewQueue() + q.Enqueue(Event{Name: "test1"}) + q.Enqueue(Event{Name: "test2"}) + + slice := q.ToSlice() + if len(slice) != 2 || slice[0].Name != "test1" || slice[1].Name != "test2" { + t.Fatal("expected slice with 2 events in order") + } +} + +func TestQueue_LoadFromSlice(t *testing.T) { + q := NewQueue() + events := []Event{{Name: "test1"}, {Name: "test2"}} + q.LoadFromSlice(events) + + if q.Len() != 2 { + t.Fatal("expected length 2") + } + dequeued, _ := q.Dequeue() + if dequeued.Name != "test1" { + t.Fatal("expected first event to be test1") + } +} + +func TestQueue_DequeueEmpty(t *testing.T) { + q := NewQueue() + _, ok := q.Dequeue() + if ok { + t.Fatal("expected dequeue to fail on empty queue") + } +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..4b62ef8 --- /dev/null +++ b/types_test.go @@ -0,0 +1,10 @@ +package ripple + +import "testing" + +func TestHTTPError_Error(t *testing.T) { + err := &HTTPError{Status: 500} + if err.Error() != "HTTP request failed" { + t.Fatal("expected error message") + } +} From 1c52feaa82c75773ef95e56c18c5b845403a3be8 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Wed, 10 Dec 2025 19:38:39 +0330 Subject: [PATCH 03/23] feat: add playground --- examples/basic/go.mod | 7 ++ examples/basic/main.go | 40 ++++++++ playground/.gitignore | 11 +++ playground/Makefile | 20 ++++ playground/README.md | 180 +++++++++++++++++++++++++++++++++++ playground/client.go | 126 ++++++++++++++++++++++++ playground/go.mod | 7 ++ playground/server.go | 70 ++++++++++++++ playground/server_output.log | 87 +++++++++++++++++ types.go | 40 ++++++++ 10 files changed, 588 insertions(+) create mode 100644 examples/basic/go.mod create mode 100644 examples/basic/main.go create mode 100644 playground/.gitignore create mode 100644 playground/Makefile create mode 100644 playground/README.md create mode 100644 playground/client.go create mode 100644 playground/go.mod create mode 100644 playground/server.go create mode 100644 playground/server_output.log create mode 100644 types.go diff --git a/examples/basic/go.mod b/examples/basic/go.mod new file mode 100644 index 0000000..93b8f8a --- /dev/null +++ b/examples/basic/go.mod @@ -0,0 +1,7 @@ +module example + +go 1.23 + +require github.com/Tap30/ripple-go v0.0.0 + +replace github.com/Tap30/ripple-go => ../.. diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..b7e1768 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "time" + + ripple "github.com/Tap30/ripple-go" +) + +func main() { + client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + FlushInterval: 5 * time.Second, + MaxBatchSize: 10, + MaxRetries: 3, + }) + + if err := client.Init(); err != nil { + panic(err) + } + defer client.Dispose() + + client.SetContext("userId", "123") + client.SetContext("appVersion", "1.0.0") + + client.Track("page_view", map[string]interface{}{ + "page": "/home", + }, nil) + + client.Track("user_action", map[string]interface{}{ + "button": "submit", + }, &ripple.EventMetadata{ + SchemaVersion: "1.0.0", + }) + + client.Flush() + + fmt.Println("Events tracked successfully") +} diff --git a/playground/.gitignore b/playground/.gitignore new file mode 100644 index 0000000..9fcc184 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,11 @@ +# Binaries +server +client +server_bin +client_bin +server_test +client_test +interactive_test + +# Persisted events +ripple_events.json diff --git a/playground/Makefile b/playground/Makefile new file mode 100644 index 0000000..1e333c0 --- /dev/null +++ b/playground/Makefile @@ -0,0 +1,20 @@ +.PHONY: server client clean + +server: + @echo "🚀 Starting event tracking server..." + @go run server.go + +client: + @echo "🎯 Starting interactive client..." + @go run client.go + +clean: + @echo "🧹 Cleaning up..." + @rm -f ripple_events.json + @echo "✨ Done!" + +help: + @echo "Available commands:" + @echo " make server - Start the event tracking server" + @echo " make client - Run the interactive client" + @echo " make clean - Remove persisted events file" diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..8257da8 --- /dev/null +++ b/playground/README.md @@ -0,0 +1,180 @@ +# Ripple Go Playground + +A testing environment for the Ripple Go SDK with a dummy HTTP server. + +## Structure + +- `server.go` - HTTP server that receives and logs events +- `client.go` - Interactive CLI client for manual testing + +## Usage + +### Start the Server + +```bash +cd playground +go run server.go +``` + +Using Makefile: + +```bash +make server +``` + +The server will start on `http://localhost:3000` and accept events at `/events`. + +### Run the Client + +For interactive testing with a CLI menu: + +```bash +cd playground +go run client.go +``` + +Or using Makefile: + +```bash +make client +``` + +The client provides a menu to: +- **Set Context** - Automatically adds `key_i: value_i` (incremented) +- **View Context** - Display current context +- **Track Event** - Automatically creates `event_i` with sample payload +- **Flush Events** - Manually trigger event flush +- **Exit** - Gracefully shutdown + +Example session: +``` +🎯 Ripple Interactive Client +Connected to: http://localhost:3000/events + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. Set Context +2. View Context +3. Track Event +4. Flush Events +5. Exit +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Choose an option: 1 + +📝 Set Context +✅ Context set: key_1 = value_1 + +Choose an option: 3 + +📊 Track Event +✅ Event 'event_1' tracked with sample payload +``` + +### Expected Output + +**Server:** + +```txt +🚀 Event tracking server running at http://localhost:3000 +📍 Endpoint: http://localhost:3000/events +🔑 API Key: Bearer test-api-key +📊 Received events: +{ + "events": [ + { + "name": "page_view", + "payload": { "page": "/home" }, + "issuedAt": 1234567890, + "context": { "userId": "user-123" }, + "platform": { "type": "server" } + } + ] +} +``` + +**Client:** + +```txt +📤 Tracking events... +✅ Events tracked. Waiting for flush... +🔄 Manual flush... +✨ Done! +``` + +## E2E Testing + +This playground is useful for: + +- Manual testing of the SDK +- Verifying event delivery +- Testing retry logic (stop/start server) +- Testing persistence (kill client before flush) +- Debugging event payloads + +## Server Endpoints + +### POST /events + +Accepts events in the following format: + +```json +{ + "events": [ + { + "name": "event_name", + "payload": {}, + "issuedAt": 1234567890, + "context": {}, + "metadata": {}, + "platform": { "type": "server" } + } + ] +} +``` + +Returns: + +```json +{ + "success": true, + "received": 3 +} +``` + +## Testing Scenarios + +### 1. Normal Flow + +```bash +# Terminal 1 +go run server.go + +# Terminal 2 +go run client.go +``` + +### 2. Test Retry Logic + +```bash +# Terminal 1 +go run server.go + +# Terminal 2 +go run client.go + +# Stop server (Ctrl+C) before flush +# Events should be persisted to ripple_events.json + +# Restart server +go run server.go + +# Run client again - persisted events should be sent +go run client.go +``` + +### 3. Test Batching + +Modify `client.go` to track more events and observe batching behavior. + +### 4. Test Custom Adapters + +Create custom HTTP or storage adapters and test them here. diff --git a/playground/client.go b/playground/client.go new file mode 100644 index 0000000..54c4f70 --- /dev/null +++ b/playground/client.go @@ -0,0 +1,126 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" + + ripple "github.com/Tap30/ripple-go" +) + +var client *ripple.Client +var scanner *bufio.Scanner +var contextCounter int +var eventCounter int + +func main() { + scanner = bufio.NewScanner(os.Stdin) + + client = ripple.NewClient(ripple.ClientConfig{ + APIKey: "test-api-key", + Endpoint: "http://localhost:3000/events", + FlushInterval: 5 * time.Second, + MaxBatchSize: 5, + MaxRetries: 3, + }) + + if err := client.Init(); err != nil { + fmt.Printf("❌ Failed to initialize client: %v\n", err) + return + } + + fmt.Println("🎯 Ripple Interactive Client") + fmt.Println("Connected to: http://localhost:3000/events") + fmt.Println() + + for { + showMenu() + choice := readInput("Choose an option: ") + + switch choice { + case "1": + setContext() + case "2": + viewContext() + case "3": + trackEvent() + case "4": + flush() + case "5": + fmt.Println("👋 Goodbye!") + // Persist events to storage without flushing to server + client.DisposeWithoutFlush() + return + default: + fmt.Println("❌ Invalid option. Please try again.\n") + } + } +} + +func showMenu() { + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println("1. Set Context") + fmt.Println("2. View Context") + fmt.Println("3. Track Event") + fmt.Println("4. Flush Events") + fmt.Println("5. Exit") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") +} + +func readInput(prompt string) string { + fmt.Print(prompt) + scanner.Scan() + return strings.TrimSpace(scanner.Text()) +} + +func setContext() { + fmt.Println("\n📝 Set Context") + contextCounter++ + key := fmt.Sprintf("key_%d", contextCounter) + value := fmt.Sprintf("value_%d", contextCounter) + + client.SetContext(key, value) + fmt.Printf("✅ Context set: %s = %s\n\n", key, value) +} + +func viewContext() { + fmt.Println("\n👀 Current Context") + ctx := client.GetContext() + if len(ctx) == 0 { + fmt.Println("(empty)") + } else { + for k, v := range ctx { + fmt.Printf(" %s: %v\n", k, v) + } + } + fmt.Println() +} + +func trackEvent() { + fmt.Println("\n📊 Track Event") + eventCounter++ + name := fmt.Sprintf("event_%d", eventCounter) + + // Mock sample payload + payload := map[string]interface{}{ + "action": fmt.Sprintf("action_%d", eventCounter), + "timestamp": time.Now().Unix(), + "data": map[string]interface{}{ + "count": eventCounter, + "type": "sample", + }, + } + + metadata := &ripple.EventMetadata{SchemaVersion: "1.0.0"} + + client.Track(name, payload, metadata) + fmt.Printf("✅ Event '%s' tracked with sample payload\n\n", name) +} + +func flush() { + fmt.Println("\n🔄 Flushing events...") + client.Flush() + fmt.Println("✅ Events flushed\n") +} diff --git a/playground/go.mod b/playground/go.mod new file mode 100644 index 0000000..d711a69 --- /dev/null +++ b/playground/go.mod @@ -0,0 +1,7 @@ +module playground + +go 1.23 + +require github.com/Tap30/ripple-go v0.0.0 + +replace github.com/Tap30/ripple-go => .. diff --git a/playground/server.go b/playground/server.go new file mode 100644 index 0000000..0cc6040 --- /dev/null +++ b/playground/server.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" +) + +const PORT = 3000 + +type EventsPayload struct { + Events []map[string]interface{} `json:"events"` +} + +func main() { + http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + apiKey := r.URL.Query().Get("apiKey") + if apiKey == "" { + apiKey = r.Header.Get("Authorization") + } + + log.Printf("🔑 API Key: %s", apiKey) + + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("❌ Failed to read body") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read body"}) + return + } + + var payload EventsPayload + if err := json.Unmarshal(body, &payload); err != nil { + log.Printf("❌ Invalid JSON") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) + return + } + + prettyJSON, _ := json.MarshalIndent(payload, "", " ") + log.Printf("📊 Received events:\n%s", string(prettyJSON)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "received": len(payload.Events), + }) + }) + + log.Printf("🚀 Event tracking server running at http://localhost:%d", PORT) + log.Printf("📍 Endpoint: http://localhost:%d/events", PORT) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", PORT), nil)) +} diff --git a/playground/server_output.log b/playground/server_output.log new file mode 100644 index 0000000..3e04227 --- /dev/null +++ b/playground/server_output.log @@ -0,0 +1,87 @@ +2025/12/10 18:42:11 🚀 Event tracking server running at http://localhost:3000 +2025/12/10 18:42:11 📍 Endpoint: http://localhost:3000/events +2025/12/10 18:42:37 🔑 API Key: Bearer test-api-key +2025/12/10 18:42:37 📊 Received events: +{ + "events": [ + { + "issuedAt": 1765379546295, + "metadata": { + "schemaVersion": "1.0.0" + }, + "name": "event_1", + "payload": { + "action": "action_1", + "data": { + "count": 1, + "type": "sample" + }, + "timestamp": 1765379546 + }, + "platform": { + "type": "server" + } + }, + { + "issuedAt": 1765379552408, + "metadata": { + "schemaVersion": "1.0.0" + }, + "name": "event_1", + "payload": { + "action": "action_1", + "data": { + "count": 1, + "type": "sample" + }, + "timestamp": 1765379552 + }, + "platform": { + "type": "server" + } + } + ] +} +2025/12/10 18:43:36 🔑 API Key: Bearer test-api-key +2025/12/10 18:43:36 📊 Received events: +{ + "events": [ + { + "issuedAt": 1765379606210, + "metadata": { + "schemaVersion": "1.0.0" + }, + "name": "event_1", + "payload": { + "action": "action_1", + "data": { + "count": 1, + "type": "sample" + }, + "timestamp": 1765379606 + }, + "platform": { + "type": "server" + } + }, + { + "issuedAt": 1765379612191, + "metadata": { + "schemaVersion": "1.0.0" + }, + "name": "event_1", + "payload": { + "action": "action_1", + "data": { + "count": 1, + "type": "sample" + }, + "timestamp": 1765379612 + }, + "platform": { + "type": "server" + } + } + ] +} +signal: terminated diff --git a/types.go b/types.go new file mode 100644 index 0000000..956889a --- /dev/null +++ b/types.go @@ -0,0 +1,40 @@ +package ripple + +import ( + "time" + + "github.com/Tap30/ripple-go/adapters" +) + +// Re-export adapter types for convenience +type ( + Event = adapters.Event + EventMetadata = adapters.EventMetadata + Platform = adapters.Platform + HTTPAdapter = adapters.HTTPAdapter + HTTPResponse = adapters.HTTPResponse + StorageAdapter = adapters.StorageAdapter +) + +type HTTPError struct { + Status int +} + +func (e *HTTPError) Error() string { + return "HTTP request failed" +} + +type ClientConfig struct { + APIKey string + Endpoint string + FlushInterval time.Duration + MaxBatchSize int + MaxRetries int +} + +type DispatcherConfig struct { + Endpoint string + FlushInterval time.Duration + MaxBatchSize int + MaxRetries int +} From be3ee5ed827f4b25cff165f95552d91e2cf17563 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 14 Dec 2025 14:10:11 +0330 Subject: [PATCH 04/23] chore: run gofmt --- README.md | 2 +- adapters/README.md | 2 ++ dispatcher.go | 6 +++--- playground/client.go | 31 ++++++++++++++++++++++++++++--- playground/server.go | 13 +++++++++++++ types.go | 10 +++++----- 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 881f773..5897b31 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ See [ONBOARDING.md](./ONBOARDING.md) for detailed architecture documentation. ## API Contract See the -[API Contract Documentation](https://github.com/Tap30/ripple/blob/main/API_CONTRACT.md) +[API Contract Documentation](https://github.com/Tap30/ripple/blob/main/DESIGN_AND_CONTRACTS.md) for details on the shared, framework-independent interface all Ripple SDKs follow. ## Development diff --git a/adapters/README.md b/adapters/README.md index 0493622..0656b6d 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -15,6 +15,7 @@ type HTTPAdapter interface { ``` **Default Implementation:** `NetHTTPAdapter` + - Uses Go's standard `net/http` package - Sends events as JSON POST requests - Supports custom headers @@ -32,6 +33,7 @@ type StorageAdapter interface { ``` **Default Implementation:** `DefaultStorageAdapter` + - Stores events as JSON in a file - Default file: `ripple_events.json` - Suitable for server environments diff --git a/dispatcher.go b/dispatcher.go index 81c851f..44fa076 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -45,10 +45,10 @@ func (d *Dispatcher) Start() error { func (d *Dispatcher) Enqueue(event Event) { d.queue.Enqueue(event) - + // Start timer on first new event d.startTimerIfNeeded() - + if d.queue.Len() >= d.config.MaxBatchSize { go d.Flush() } @@ -57,7 +57,7 @@ func (d *Dispatcher) Enqueue(event Event) { func (d *Dispatcher) startTimerIfNeeded() { d.timerMu.Lock() defer d.timerMu.Unlock() - + if !d.timerStarted { d.ticker = time.NewTicker(d.config.FlushInterval) d.timerStarted = true diff --git a/playground/client.go b/playground/client.go index 54c4f70..3115acd 100644 --- a/playground/client.go +++ b/playground/client.go @@ -47,8 +47,10 @@ func main() { case "3": trackEvent() case "4": - flush() + trackEventWithError() case "5": + flush() + case "6": fmt.Println("👋 Goodbye!") // Persist events to storage without flushing to server client.DisposeWithoutFlush() @@ -64,8 +66,9 @@ func showMenu() { fmt.Println("1. Set Context") fmt.Println("2. View Context") fmt.Println("3. Track Event") - fmt.Println("4. Flush Events") - fmt.Println("5. Exit") + fmt.Println("4. Track Event with Error (Test Retry)") + fmt.Println("5. Flush Events") + fmt.Println("6. Exit") fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") } @@ -119,6 +122,28 @@ func trackEvent() { fmt.Printf("✅ Event '%s' tracked with sample payload\n\n", name) } +func trackEventWithError() { + fmt.Println("\n⚠️ Track Event with Error (Test Retry)") + eventCounter++ + name := fmt.Sprintf("error_event_%d", eventCounter) + + // Payload with error trigger + payload := map[string]interface{}{ + "action": fmt.Sprintf("error_action_%d", eventCounter), + "timestamp": time.Now().Unix(), + "trigger_error": true, // This will cause server to return 500 + "data": map[string]interface{}{ + "count": eventCounter, + "type": "error_test", + }, + } + + metadata := &ripple.EventMetadata{SchemaVersion: "1.0.0"} + + client.Track(name, payload, metadata) + fmt.Printf("✅ Error event '%s' tracked - will trigger retry logic\n\n", name) +} + func flush() { fmt.Println("\n🔄 Flushing events...") client.Flush() diff --git a/playground/server.go b/playground/server.go index 0cc6040..4d3c680 100644 --- a/playground/server.go +++ b/playground/server.go @@ -56,6 +56,19 @@ func main() { prettyJSON, _ := json.MarshalIndent(payload, "", " ") log.Printf("📊 Received events:\n%s", string(prettyJSON)) + // Check for error trigger in any event payload + for _, event := range payload.Events { + if eventPayload, ok := event["payload"].(map[string]interface{}); ok { + if trigger, exists := eventPayload["trigger_error"]; exists && trigger == true { + log.Printf("🔄 Client should retry this request (error triggered)") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Simulated server error"}) + return + } + } + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/types.go b/types.go index 956889a..1feef8f 100644 --- a/types.go +++ b/types.go @@ -8,11 +8,11 @@ import ( // Re-export adapter types for convenience type ( - Event = adapters.Event - EventMetadata = adapters.EventMetadata - Platform = adapters.Platform - HTTPAdapter = adapters.HTTPAdapter - HTTPResponse = adapters.HTTPResponse + Event = adapters.Event + EventMetadata = adapters.EventMetadata + Platform = adapters.Platform + HTTPAdapter = adapters.HTTPAdapter + HTTPResponse = adapters.HTTPResponse StorageAdapter = adapters.StorageAdapter ) From c1e1cc8bda06c9e489b6c43da2c6a126520ac493 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 14 Dec 2025 14:13:16 +0330 Subject: [PATCH 05/23] docs: add coc, contributing and security docs --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++ CONTRIBUTING.md | 211 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- SECURITY.md | 9 ++ 4 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6079032 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +`amir.alibakhshi@tapsi.cab`. All complaints will be reviewed and +investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +. Translations are available at +. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c3dbe5d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,211 @@ +# Contributing to Ripple | TypeScript + +If you're reading this, you're definitely awesome!
The following is a set +of guidelines for contributing to ClientSocketManager, which are hosted in the +[GitHub](https://github.com/Tap30/ripple-go). These are mostly guidelines, not +rules. Use your best judgment, and feel free to propose changes to this document +in a pull request. + +## Code of Conduct + +This project and everyone participating in it is governed by the +[Code of Conduct](https://github.com/Tap30/ripple-go/blob/main/CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code. + +## A large spectrum of contributions + +There are many ways to contribute, code contribution is one aspect of it. For +instance, documentation improvements are as important as code changes. + +## Your first Pull Request + +Working on your first Pull Request? You can learn how from this free video +series: + +[How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github) + +To help you get your feet wet and get you familiar with our contribution +process, we have a list of +[good first issues](https://github.com/Tap30/ripple-go/issues?q=is:open+is:issue+label:"good+first+issue") +that contain changes that have a relatively limited scope. This label means that +there is already a working solution to the issue in the discussion section. +Therefore, it is a great place to get started. + +We also have a list of +[good to take issues](https://github.com/Tap30/ripple-go/issues?q=is:open+is:issue+label:"good+to+take"). +This label is set when there has been already some discussion about the solution +and it is clear in which direction to go. These issues are good for developers +that want to reduce the chance of going down a rabbit hole. + +You can also work on any other issue you choose to. The "good first" and "good +to take" issues are just issues where we have a clear picture about scope and +timeline. Pull requests working on other issues or completely new problems may +take a bit longer to review when they don't fit into our current development +cycle. + +If you decide to fix an issue, please be sure to check the comment thread in +case somebody is already working on a fix. If nobody is working on it at the +moment, please leave a comment stating that you have started to work on it so +other people don't accidentally duplicate your effort. + +If somebody claims an issue but doesn't follow up for more than a week, it's +fine to take it over but you should still leave a comment. If there has been no +activity on the issue for 7 to 14 days, it is safe to assume that nobody is +working on it. + +## Sending a Pull Request + +Pull Requests are always welcome, but, before working on a large change, it is +best to open an issue first to discuss it with the maintainers. + +When in doubt, keep your Pull Requests small. To give a Pull Request the best +chance of getting accepted, don't bundle more than one feature or bug fix per +Pull Request. It's often best to create two smaller Pull Requests than one big +one. + +1. Fork the repository. + +2. Clone the fork to your local machine and add upstream remote: + + ```sh + git clone https://github.com//ripple-go.git + cd ripple-go + git remote add upstream https://github.com/Tap30/ripple-go.git + ``` + +3. Synchronize your local `main` branch with the upstream one: + + ```sh + git checkout main + git pull upstream main + ``` + +4. Install the dependencies with `pnpm` (`npm` and `yarn` aren't supported): + + ```sh + pnpm install + ``` + +5. Create a new topic branch: + + ```sh + git switch -c my-topic-branch + ``` + +6. Make changes, commit and push to your fork: + + ```sh + git push -u origin HEAD + ``` + +7. Go to [the repository](https://github.com/Tap30/ripple-go) and make a Pull + Request. + +The core team is monitoring for Pull Requests. We will review your Pull Request +and either merge it, request changes to it, or close it with an explanation. + +## Development Workflow + +### Project Structure + +For a comprehensive understanding of the project architecture, features, and +design principles, see the +[Onboarding Documentation](https://github.com/Tap30/ripple-go/blob/main/ONBOARDING.md). + +### Running the Playground + +Start the development server to test the SDK in a browser environment: + +```sh +pnpm dev +``` + +This runs the playground at `http://localhost:5173` with hot module replacement. + +### Building + +Build all packages: + +```sh +pnpm build +``` + +### Testing + +Run all unit tests: + +```sh +pnpm test:unit +``` + +Run unit tests in watch mode: + +```sh +pnpm test:unit:watch +``` + +Run tests for specific packages: + +```sh +pnpm test:unit:workspace # Test workspace packages only +pnpm test:unit:internals # Test internals only +``` + +### Linting and Formatting + +Check code quality: + +```sh +pnpm check:lint # Run all checks (TypeScript, ESLint, Prettier, circular dependencies) +pnpm check:format # Check formatting only +``` + +Auto-fix formatting: + +```sh +pnpm format +``` + +### Cleaning + +Clean build artifacts and caches: + +```sh +pnpm clear:dist # Remove dist folders +pnpm clear:cache # Remove cache files +pnpm clear:all # Remove everything (dist, cache, node_modules) +``` + +### Coding style + +Please follow the coding style of the project. We use `prettier` and `eslint`, +so if possible, enable linting in your editor to get real-time feedback. + +### Git Commit Messages + +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference issues and pull requests liberally after the first line +- Please use the following commit message conventions for consistent and + informative commit history: + - **feat**: A new feature + - **fix**: A bug fix + - **docs**: Documentation only changes + - **style**: Changes that do not affect the meaning of the code (white-space, + formatting, missing semi-colons, etc) + - **refactor**: A code change that neither fixes a bug nor adds a feature + - **perf**: A code change that improves performance + - **test**: Adding missing or correcting existing tests + - **build**: Changes that affect the build system or external dependencies + (example scopes: gulp, broccoli, npm) + - **ci**: Changes to our CI configuration files and scripts (example scopes: + Travis, Circle, BrowserStack, SauceLabs) + - **chore**: Other changes that don't modify src or test files + - **revert**: Reverts a previous commit + +## License + +By contributing your code to the `Tap30/*` GitHub repositories, you agree to +license your contribution under the +[MIT license](https://github.com/Tap30/ripple-go/blob/main/LICENSE). diff --git a/README.md b/README.md index 5897b31..a34a409 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,10 @@ See [playground/README.md](./playground/README.md) for more details. ## Contributing Check the -[contributing guide](https://github.com/Tap30/ripple-ts/blob/main/CONTRIBUTING.md) +[contributing guide](https://github.com/Tap30/ripple-go/blob/main/CONTRIBUTING.md) for information on development workflow, proposing improvements, and running tests. ## License Distributed under the -[MIT license](https://github.com/Tap30/ripple-ts/blob/main/packages/browser/LICENSE). +[MIT license](https://github.com/Tap30/ripple-go/blob/main/packages/browser/LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7c8df09 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Reporting Security Issues + +If you believe you have found a security vulnerability in this repo and its +packages, we encourage you to let us know right away. We will investigate all +legitimate reports and do our best to quickly fix the problem. + +## How to let us know? + +- [Open up an issue]() From 8e93871e86aa7237e340ca06ac717d6514c5e8d8 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 14 Dec 2025 14:24:49 +0330 Subject: [PATCH 06/23] docs: update contents --- CONTRIBUTING.md | 76 +++++++++++++++++++++++++++++++------------------ SECURITY.md | 2 +- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3dbe5d..ba08086 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ -# Contributing to Ripple | TypeScript +# Contributing to Ripple | Go If you're reading this, you're definitely awesome!
The following is a set -of guidelines for contributing to ClientSocketManager, which are hosted in the +of guidelines for contributing to Ripple Go SDK, which are hosted in the [GitHub](https://github.com/Tap30/ripple-go). These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. @@ -80,10 +80,10 @@ one. git pull upstream main ``` -4. Install the dependencies with `pnpm` (`npm` and `yarn` aren't supported): +4. Install the dependencies: ```sh - pnpm install + go mod download ``` 5. Create a new topic branch: @@ -114,72 +114,94 @@ design principles, see the ### Running the Playground -Start the development server to test the SDK in a browser environment: +Start the playground server to test the SDK: ```sh -pnpm dev +cd playground +make server ``` -This runs the playground at `http://localhost:5173` with hot module replacement. +In another terminal, run the client: + +```sh +cd playground +make client +``` + +This allows you to test the SDK functionality with a local server. ### Building -Build all packages: +Build the project: ```sh -pnpm build +go build ./... ``` ### Testing -Run all unit tests: +Run all tests: ```sh -pnpm test:unit +go test ./... ``` -Run unit tests in watch mode: +Run tests with coverage: ```sh -pnpm test:unit:watch +go test -cover ./... ``` -Run tests for specific packages: +Run tests in verbose mode: ```sh -pnpm test:unit:workspace # Test workspace packages only -pnpm test:unit:internals # Test internals only +go test -v ./... ``` ### Linting and Formatting -Check code quality: +Format code: ```sh -pnpm check:lint # Run all checks (TypeScript, ESLint, Prettier, circular dependencies) -pnpm check:format # Check formatting only +gofmt -w . ``` -Auto-fix formatting: +Run static analysis: ```sh -pnpm format +go vet ./... +``` + +If you have golangci-lint installed: + +```sh +golangci-lint run ``` ### Cleaning -Clean build artifacts and caches: +Clean build artifacts: + +```sh +go clean ./... +``` + +Clean module cache: ```sh -pnpm clear:dist # Remove dist folders -pnpm clear:cache # Remove cache files -pnpm clear:all # Remove everything (dist, cache, node_modules) +go clean -modcache ``` ### Coding style -Please follow the coding style of the project. We use `prettier` and `eslint`, -so if possible, enable linting in your editor to get real-time feedback. +Please follow the coding style of the project. We use `gofmt` for formatting +and `go vet` for static analysis. Follow standard Go conventions: + +- Use `gofmt` to format your code +- Follow effective Go guidelines +- Use meaningful variable and function names +- Add comments for exported functions and types +- Keep functions small and focused ### Git Commit Messages diff --git a/SECURITY.md b/SECURITY.md index 7c8df09..d5d1483 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,4 +6,4 @@ legitimate reports and do our best to quickly fix the problem. ## How to let us know? -- [Open up an issue]() +- [Open up an issue](https://github.com/Tap30/ripple-go/issues/new?assignees=amir78729&labels=security&template=bug_report.md&title=fix(security):) From c092b38ceccfac5c0a3d89e7dcd9c86d6334230a Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 14 Dec 2025 16:47:29 +0330 Subject: [PATCH 07/23] feat: update configuration type --- adapters/types.go | 17 +++++++++-------- client.go | 45 ++++++++++++++++++++++++++++++++------------ client_test.go | 6 +++++- playground/client.go | 8 ++++++-- types.go | 7 +++++++ 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/adapters/types.go b/adapters/types.go index 64ff2f1..f107b18 100644 --- a/adapters/types.go +++ b/adapters/types.go @@ -2,20 +2,21 @@ package adapters // Event represents a tracked event. type Event struct { - Name string `json:"name"` - Payload map[string]interface{} `json:"payload,omitempty"` - IssuedAt int64 `json:"issuedAt"` - Context map[string]interface{} `json:"context,omitempty"` - Metadata *EventMetadata `json:"metadata,omitempty"` - Platform *Platform `json:"platform,omitempty"` + Name string `json:"name"` + Payload map[string]interface{} `json:"payload"` + Metadata *EventMetadata `json:"metadata"` + IssuedAt int64 `json:"issuedAt"` + Context map[string]interface{} `json:"context"` + SessionID *string `json:"sessionId"` + Platform *Platform `json:"platform"` } // EventMetadata contains optional event metadata. type EventMetadata struct { - SchemaVersion string `json:"schemaVersion,omitempty"` + SchemaVersion *string `json:"schemaVersion,omitempty"` } -// Platform identifies the runtime environment. +// Platform represents server platform information. type Platform struct { Type string `json:"type"` } diff --git a/client.go b/client.go index f5083e9..4f56842 100644 --- a/client.go +++ b/client.go @@ -27,12 +27,25 @@ func NewClient(config ClientConfig) *Client { config.MaxRetries = 3 } - return &Client{ - config: config, - context: make(map[string]interface{}), - httpAdapter: adapters.NewNetHTTPAdapter(), - storageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + client := &Client{ + config: config, + context: make(map[string]interface{}), } + + // Use provided adapters or defaults + if config.Adapters.HTTPAdapter != nil { + client.httpAdapter = config.Adapters.HTTPAdapter + } else { + client.httpAdapter = adapters.NewNetHTTPAdapter() + } + + if config.Adapters.StorageAdapter != nil { + client.storageAdapter = config.Adapters.StorageAdapter + } else { + client.storageAdapter = adapters.NewFileStorageAdapter("ripple_events.json") + } + + return client } // SetHTTPAdapter sets a custom HTTP adapter. @@ -48,11 +61,18 @@ func (c *Client) SetStorageAdapter(adapter StorageAdapter) { } func (c *Client) Init() error { + apiKeyHeader := "X-API-Key" + if c.config.APIKeyHeader != nil { + apiKeyHeader = *c.config.APIKeyHeader + } + headers := map[string]string{ - "Authorization": "Bearer " + c.config.APIKey, + apiKeyHeader: c.config.APIKey, } dispatcherConfig := DispatcherConfig{ + APIKey: c.config.APIKey, + APIKeyHeader: apiKeyHeader, Endpoint: c.config.Endpoint, FlushInterval: c.config.FlushInterval, MaxBatchSize: c.config.MaxBatchSize, @@ -81,12 +101,13 @@ func (c *Client) GetContext() map[string]interface{} { func (c *Client) Track(name string, payload map[string]interface{}, metadata *EventMetadata) { event := Event{ - Name: name, - Payload: payload, - IssuedAt: time.Now().UnixMilli(), - Context: c.GetContext(), - Metadata: metadata, - Platform: &Platform{Type: "server"}, + Name: name, + Payload: payload, + Metadata: metadata, + IssuedAt: time.Now().UnixMilli(), + Context: c.GetContext(), + SessionID: nil, // Server platform doesn't use session ID + Platform: &Platform{Type: "server"}, } c.dispatcher.Enqueue(event) } diff --git a/client_test.go b/client_test.go index e4c0671..15138b2 100644 --- a/client_test.go +++ b/client_test.go @@ -5,6 +5,10 @@ import ( "time" ) +func stringPtr(s string) *string { + return &s +} + func TestClient_SetGetContext(t *testing.T) { client := NewClient(ClientConfig{ APIKey: "test-key", @@ -62,7 +66,7 @@ func TestClient_TrackWithMetadata(t *testing.T) { } defer client.Dispose() - metadata := &EventMetadata{SchemaVersion: "1.0.0"} + metadata := &EventMetadata{SchemaVersion: stringPtr("1.0.0")} client.Track("user_signup", map[string]interface{}{"email": "test@example.com"}, metadata) time.Sleep(100 * time.Millisecond) diff --git a/playground/client.go b/playground/client.go index 3115acd..c299aec 100644 --- a/playground/client.go +++ b/playground/client.go @@ -10,6 +10,10 @@ import ( ripple "github.com/Tap30/ripple-go" ) +func stringPtr(s string) *string { + return &s +} + var client *ripple.Client var scanner *bufio.Scanner var contextCounter int @@ -116,7 +120,7 @@ func trackEvent() { }, } - metadata := &ripple.EventMetadata{SchemaVersion: "1.0.0"} + metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} client.Track(name, payload, metadata) fmt.Printf("✅ Event '%s' tracked with sample payload\n\n", name) @@ -138,7 +142,7 @@ func trackEventWithError() { }, } - metadata := &ripple.EventMetadata{SchemaVersion: "1.0.0"} + metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} client.Track(name, payload, metadata) fmt.Printf("✅ Error event '%s' tracked - will trigger retry logic\n\n", name) diff --git a/types.go b/types.go index 1feef8f..746f5e4 100644 --- a/types.go +++ b/types.go @@ -27,12 +27,19 @@ func (e *HTTPError) Error() string { type ClientConfig struct { APIKey string Endpoint string + APIKeyHeader *string FlushInterval time.Duration MaxBatchSize int MaxRetries int + Adapters struct { + HTTPAdapter HTTPAdapter + StorageAdapter StorageAdapter + } } type DispatcherConfig struct { + APIKey string + APIKeyHeader string Endpoint string FlushInterval time.Duration MaxBatchSize int From 6615bc149bf0c1b1ee4d7d44af233ffaff5c6d0d Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Tue, 16 Dec 2025 17:03:06 +0330 Subject: [PATCH 08/23] fix: update logics based on ts package --- ONBOARDING.md | 370 ++++++++++++++++++++++++++----- adapters/logger_adapter.go | 25 +++ adapters/noop_logger_adapter.go | 14 ++ adapters/print_logger_adapter.go | 50 +++++ client.go | 146 ++++++++++-- dispatcher.go | 63 ++++-- metadata_manager.go | 60 +++++ mutex.go | 20 ++ types.go | 3 + 9 files changed, 647 insertions(+), 104 deletions(-) create mode 100644 adapters/logger_adapter.go create mode 100644 adapters/noop_logger_adapter.go create mode 100644 adapters/print_logger_adapter.go create mode 100644 metadata_manager.go create mode 100644 mutex.go diff --git a/ONBOARDING.md b/ONBOARDING.md index af25843..673fd5b 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -17,23 +17,28 @@ ## Project Overview -Ripple Go is a high-performance, fault-tolerant event tracking SDK implemented as a single Go package. It provides reliable event delivery, batching, retries, persistence, and graceful shutdown for server-side applications. +Ripple Go is a high-performance, scalable, and fault-tolerant event tracking SDK implemented as a single Go package. It provides reliable event delivery, batching, retries, persistence, and graceful shutdown for server-side applications. -This version is not a monorepo. It has no browser package, no Node.js package, and no internal modules exposed. All functionality exists within one cohesive Go module. +This version is not a monorepo. It has no browser package, no Node.js package, and no internal modules exposed. All functionality exists within one cohesive Go module that follows the unified API contract defined in the main Ripple repository. ## SDK Features ### Core Features +* **Unified Metadata System** – Single metadata field that merges shared metadata (client-level) with event-specific metadata +* **Type-Safe Metadata Management** – MetadataManager for handling shared metadata with thread-safe operations +* **Initialization Validation** – Track() throws error if called before Init() to prevent data loss +* **Logger Interface** – Pluggable logging with PrintLoggerAdapter and NoOpLoggerAdapter implementations * **Context Management** – shared context automatically attached to all events -* **Event Metadata** – optional schema versioning +* **Event Metadata** – optional schema versioning and event-specific metadata * **Automatic Batching** – dispatch based on batch size * **Scheduled Flushing** – time-based flush via goroutines -* **Retry Logic** – exponential backoff with jitter +* **Retry Logic** – exponential backoff with jitter (1000ms × 2^attempt + random jitter) * **Event Persistence** – disk-backed storage for unsent events * **Queue Management** – FIFO queue using `container/list` +* **Race Condition Prevention** – Mutex-based atomic operations for concurrent safety * **Graceful Shutdown** – flushes and persists all events on dispose -* **Adapters** – pluggable HTTP and storage implementations +* **Adapters** – pluggable HTTP, storage, and logger implementations ### Go-Specific Features @@ -49,9 +54,15 @@ This version is not a monorepo. It has no browser package, no Node.js package, a type ClientConfig struct { APIKey string Endpoint string + APIKeyHeader *string // Optional: Header name for API key (default: "X-API-Key") FlushInterval time.Duration // Default: 5s MaxBatchSize int // Default: 10 MaxRetries int // Default: 3 + Adapters struct { + HTTPAdapter HTTPAdapter // Optional: Custom HTTP adapter + StorageAdapter StorageAdapter // Optional: Custom storage adapter + LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) + } } ``` @@ -71,85 +82,129 @@ type ClientConfig struct { ```sh ripple-go/ -├── client.go -├── client_test.go -├── dispatcher.go -├── dispatcher_test.go -├── queue.go -├── queue_test.go -├── types.go -├── types_test.go -├── go.mod -├── README.md -├── ONBOARDING.md +├── client.go # Main client implementation with metadata management +├── client_test.go # Client tests +├── dispatcher.go # Event batching, retry logic, and HTTP dispatch +├── dispatcher_test.go # Dispatcher tests +├── queue.go # FIFO queue implementation +├── queue_test.go # Queue tests +├── metadata_manager.go # Shared metadata management +├── mutex.go # Race condition prevention +├── types.go # Type definitions and re-exports +├── types_test.go # Type tests +├── go.mod # Go module definition +├── README.md # Project documentation +├── ONBOARDING.md # This file - complete implementation guide ├── adapters/ -│ ├── http_adapter.go -│ ├── http_adapter_test.go -│ ├── storage_adapter.go -│ ├── file_storage_adapter.go -│ ├── file_storage_adapter_test.go -│ ├── types.go -│ └── README.md +│ ├── http_adapter.go # HTTP adapter interface +│ ├── net_http_adapter.go # Default HTTP implementation +│ ├── net_http_adapter_test.go # HTTP adapter tests +│ ├── storage_adapter.go # Storage adapter interface +│ ├── file_storage_adapter.go # Default file storage implementation +│ ├── file_storage_adapter_test.go # Storage adapter tests +│ ├── logger_adapter.go # Logger adapter interface +│ ├── print_logger_adapter.go # Print logger implementation +│ ├── noop_logger_adapter.go # No-op logger implementation +│ ├── types.go # Adapter type definitions +│ └── README.md # Adapter documentation ├── examples/ │ └── basic/ │ ├── go.mod │ └── main.go └── playground/ - ├── server.go - ├── client.go + ├── server.go # Test server with error simulation + ├── client.go # Interactive test client ├── go.mod + └── Makefile # Build commands +``` ├── Makefile └── README.md ``` -### Components +### Core Components #### Client -Entry point for the SDK. +Entry point for the SDK with enhanced initialization validation and metadata management. Responsibilities: -* Initialization -* Managing global context -* Accepting new events +* Configuration validation (required APIKey and Endpoint) +* Initialization state management +* Managing shared metadata through MetadataManager +* Accepting new events (with initialization check) * Passing events to the dispatcher * Exposing flushing and shutdown +* Logger integration -Thread safety is enforced through internal locking. +Thread safety is enforced through internal locking and MetadataManager. Key methods: -* `Init()` -* `Track(name, payload, metadata)` -* `SetContext(key, value)` -* `GetContext()` -* `SetHTTPAdapter(adapter)` - Set custom HTTP adapter (before Init) -* `SetStorageAdapter(adapter)` - Set custom storage adapter (before Init) -* `Flush()` -* `Dispose()` +* `Init()` - Initialize client and restore persisted events (must be called first) +* `Track(name, payload, metadata)` - Track event (throws error if not initialized) +* `SetContext(key, value)` - Set shared context (legacy method) +* `GetContext()` - Get shared context (legacy method) +* `SetMetadata(key, value)` - Set shared metadata attached to all events +* `GetMetadata(key)` - Get shared metadata value +* `GetAllMetadata()` - Get all shared metadata +* `Flush()` - Force flush queued events +* `Dispose()` - Clean up resources and flush events +* `DisposeWithoutFlush()` - Clean up without flushing (persist to storage only) + +#### MetadataManager + +Manages global metadata attached to all events with thread-safe operations. +Responsibilities: + +* Thread-safe metadata storage using `sync.RWMutex` +* Metadata merging (shared + event-specific) +* Null handling (returns `nil` when no metadata is set) + +Key methods: + +* `Set(key, value)` - Set metadata value +* `Get(key)` - Get metadata value +* `GetAll()` - Get all metadata (returns `nil` if empty) +* `IsEmpty()` - Check if metadata is empty +* `Clear()` - Remove all metadata + +#### Mutex + +Provides mutual exclusion lock for preventing race conditions in concurrent operations. +Responsibilities: -#### Context Manager +* Atomic task execution +* Race condition prevention in Dispatcher flush operations +* Queue-based task scheduling with automatic lock release -Provides thread-safe access to global context: +Key method: -* Stored as `map[string]interface{}` -* Protected with `sync.RWMutex` -* Merged into every event at dispatch time +* `RunAtomic(task func() error)` - Execute task with exclusive lock #### Dispatcher -Handles all operational concerns: +Handles all operational concerns with enhanced logging and race condition prevention: -* Queueing -* Persistence -* Automatic and manual flushing -* Batch formation -* Retry with exponential backoff and jitter -* De-queuing and re-queuing failed events +* Event queueing with atomic operations +* Persistence with error handling +* Automatic and manual flushing using Mutex +* Batch formation with configurable size +* Retry with exponential backoff and jitter (1000ms × 2^attempt + random jitter) +* De-queuing and re-queuing failed events with proper ordering * Loading persisted events on startup -* Graceful shutdown +* Graceful shutdown with optional flush +* Comprehensive logging for debugging and monitoring -A single mutex prevents concurrent flushes. +The Mutex prevents concurrent flush operations and ensures thread safety. + +Key methods: + +* `Enqueue(event)` - Add event to queue +* `Flush()` - Send queued events (atomic operation) +* `Start()` - Initialize and start background processing +* `Stop()` - Graceful shutdown with flush +* `StopWithoutFlush()` - Graceful shutdown without flush +* `SetLoggerAdapter(logger)` - Set custom logger #### Queue @@ -170,6 +225,35 @@ Wrapper methods include: * `ToSlice()` * `LoadFromSlice(events)` +### Adapter Interfaces + +#### Logger Adapter + +Interface defined in `adapters/logger_adapter.go`: + +```go +type LoggerAdapter interface { + Debug(message string, args ...interface{}) + Info(message string, args ...interface{}) + Warn(message string, args ...interface{}) + Error(message string, args ...interface{}) +} +``` + +**Log Levels**: `DEBUG`, `INFO`, `WARN`, `ERROR`, `NONE` (string-based) + +**Built-in Implementations**: + +* `PrintLoggerAdapter` - Standard log output with configurable log level (default: WARN) +* `NoOpLoggerAdapter` - Silent logger that discards all messages + +**Usage in SDK**: +* Client initialization and disposal +* Event tracking operations +* HTTP request attempts and failures +* Retry logic with backoff timing +* Storage operations + #### HTTP Adapter Interface defined in `adapters/http_adapter.go`: @@ -185,6 +269,7 @@ Default implementation (`NetHTTPAdapter`): * Uses `net/http` * JSON payloads * Combined headers (default + user headers) +* Configurable API key header name #### Storage Adapter @@ -267,39 +352,91 @@ type HTTPResponse struct { ### Basic Usage ```go +import ( + ripple "github.com/Tap30/ripple-go" + "github.com/Tap30/ripple-go/adapters" +) + client := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", }) +// Initialize client (required before tracking) if err := client.Init(); err != nil { panic(err) } defer client.Dispose() -client.SetContext("userId", "123") -client.SetContext("appVersion", "1.0.0") +// Set shared metadata (attached to all events) +client.SetMetadata("userId", "123") +client.SetMetadata("appVersion", "1.0.0") +// Track events client.Track("page_view", map[string]interface{}{ "page": "/home", }, nil) +// Track with event-specific metadata client.Track("user_action", map[string]interface{}{ "button": "submit", -}, &ripple.EventMetadata{SchemaVersion: "1.0.0"}) +}, &ripple.EventMetadata{SchemaVersion: stringPtr("2.0.0")}) +// Manual flush client.Flush() + +// Helper function for string pointers +func stringPtr(s string) *string { + return &s +} ``` -### Using Metadata +**Important**: `Init()` must be called before `Track()`. Calling `Track()` before initialization will return an error to prevent data loss. + +### Unified Metadata System ```go -client.Track("user_signup", map[string]interface{}{ +// Set shared metadata (attached to all events) +client.SetMetadata("userId", "user-123") +client.SetMetadata("sessionId", "session-abc") + +// Track event with additional metadata +err := client.Track("user_signup", map[string]interface{}{ "email": "user@example.com", -}, &ripple.EventMetadata{SchemaVersion: "1.0.0"}) + "plan": "premium", +}, &ripple.EventMetadata{ + SchemaVersion: stringPtr("2.0.0"), +}) + +// Final event will have merged metadata: +// - userId: "user-123" (from shared) +// - sessionId: "session-abc" (from shared) +// - schemaVersion: "2.0.0" (from event-specific) +``` + +### Custom Configuration + +```go +client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + APIKeyHeader: stringPtr("Authorization"), // Custom header name + FlushInterval: 10 * time.Second, // Custom flush interval + MaxBatchSize: 20, // Custom batch size + MaxRetries: 5, // Custom retry count + Adapters: struct { + HTTPAdapter ripple.HTTPAdapter + StorageAdapter ripple.StorageAdapter + LoggerAdapter ripple.LoggerAdapter + }{ + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), + }, +}) ``` -### Custom HTTP Adapter +### Custom Adapters + +#### Custom HTTP Adapter ```go import "github.com/Tap30/ripple-go/adapters" @@ -307,9 +444,61 @@ import "github.com/Tap30/ripple-go/adapters" type MyHTTPAdapter struct {} func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers map[string]string) (*adapters.HTTPResponse, error) { - // custom logic + // custom HTTP logic (e.g., using different HTTP client) return &adapters.HTTPResponse{OK: true, Status: 200}, nil } + +// Usage +client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + Adapters: struct { + HTTPAdapter ripple.HTTPAdapter + StorageAdapter ripple.StorageAdapter + LoggerAdapter ripple.LoggerAdapter + }{ + HTTPAdapter: &MyHTTPAdapter{}, + }, +}) +``` + +#### Custom Logger Adapter + +```go +import "github.com/Tap30/ripple-go/adapters" + +type MyLoggerAdapter struct { + logger *log.Logger +} + +func (l *MyLoggerAdapter) Debug(message string, args ...interface{}) { + l.logger.Printf("[DEBUG] "+message, args...) +} + +func (l *MyLoggerAdapter) Info(message string, args ...interface{}) { + l.logger.Printf("[INFO] "+message, args...) +} + +func (l *MyLoggerAdapter) Warn(message string, args ...interface{}) { + l.logger.Printf("[WARN] "+message, args...) +} + +func (l *MyLoggerAdapter) Error(message string, args ...interface{}) { + l.logger.Printf("[ERROR] "+message, args...) +} + +// Usage +client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + Adapters: struct { + HTTPAdapter ripple.HTTPAdapter + StorageAdapter ripple.StorageAdapter + LoggerAdapter ripple.LoggerAdapter + }{ + LoggerAdapter: &MyLoggerAdapter{logger: log.New(os.Stdout, "", log.LstdFlags)}, + }, +}) ``` ### Custom Storage Adapter @@ -434,3 +623,62 @@ Following Go best practices: * Batching prevents unbounded memory growth * Persistence ensures events survive process restarts * No memory leaks from goroutines (proper cleanup on Dispose) + +--- + +## API Contract + +The SDK follows a framework-agnostic design and API contract defined in the main Ripple repository. See: https://github.com/Tap30/ripple/blob/main/DESIGN_AND_CONTRACTS.md + +### Key Contract Points + +* **Initialization Required**: `Init()` must be called before `Track()` +* **Error Handling**: `Track()` returns error if not initialized +* **Metadata Merging**: Shared metadata + event-specific metadata +* **Platform Detection**: Automatic "server" platform for Go SDK +* **Retry Logic**: Exponential backoff with jitter (1000ms × 2^attempt + random jitter) +* **Graceful Shutdown**: Events are flushed and persisted on dispose + +--- + +## Recent Changes + +### Logger Interface Addition +- Added `LoggerAdapter` interface with Debug/Info/Warn/Error methods +- Implemented `PrintLoggerAdapter` with configurable log levels +- Implemented `NoOpLoggerAdapter` for silent operation +- Integrated logging throughout Client and Dispatcher operations + +### Unified Metadata System +- Added `MetadataManager` for thread-safe shared metadata management +- Implemented metadata merging (shared + event-specific) +- Added `SetMetadata()`, `GetMetadata()`, `GetAllMetadata()` methods +- Maintains backward compatibility with `SetContext()` and `GetContext()` + +### Initialization Validation +- `Track()` now returns error if called before `Init()` +- Added initialization state tracking in Client +- Prevents data loss from uninitialized client usage + +### Race Condition Prevention +- Added `Mutex` component for atomic operations +- Updated Dispatcher to use `RunAtomic()` for flush operations +- Enhanced thread safety for concurrent operations + +### Enhanced Configuration +- Added `APIKeyHeader` support for custom header names +- Added `Adapters` struct in `ClientConfig` for all adapter types +- Improved configuration validation with required field checks + +### Adapter Naming Refactor +- Renamed `DefaultHTTPAdapter` to `NetHTTPAdapter` for better Go conventions +- Updated constructor: `NewDefaultHTTPAdapter()` → `NewNetHTTPAdapter()` + +### Timer Behavior Enhancement +- Timer now only starts when first new event is tracked, not during SDK initialization +- If persisted events exist, they remain in queue until a new event triggers the timer +- Maintains same API while improving efficiency for apps with persisted events + +### Graceful Shutdown Enhancement +- Added `StopWithoutFlush()` and `DisposeWithoutFlush()` methods for graceful shutdown without flushing events +- Fixed playground client exit behavior to persist events without sending to server diff --git a/adapters/logger_adapter.go b/adapters/logger_adapter.go new file mode 100644 index 0000000..aa3f16f --- /dev/null +++ b/adapters/logger_adapter.go @@ -0,0 +1,25 @@ +package adapters + +// LogLevel represents the logging level +type LogLevel string + +const ( + LogLevelDebug LogLevel = "DEBUG" + LogLevelInfo LogLevel = "INFO" + LogLevelWarn LogLevel = "WARN" + LogLevelError LogLevel = "ERROR" + LogLevelNone LogLevel = "NONE" +) + +// LoggerAdapter is an interface for logging. +// Implement this interface to use custom loggers. +type LoggerAdapter interface { + // Debug logs a debug message + Debug(message string, args ...interface{}) + // Info logs an info message + Info(message string, args ...interface{}) + // Warn logs a warning message + Warn(message string, args ...interface{}) + // Error logs an error message + Error(message string, args ...interface{}) +} diff --git a/adapters/noop_logger_adapter.go b/adapters/noop_logger_adapter.go new file mode 100644 index 0000000..ddaa829 --- /dev/null +++ b/adapters/noop_logger_adapter.go @@ -0,0 +1,14 @@ +package adapters + +// NoOpLoggerAdapter implements LoggerAdapter with no-op methods +type NoOpLoggerAdapter struct{} + +// NewNoOpLoggerAdapter creates a new no-op logger +func NewNoOpLoggerAdapter() *NoOpLoggerAdapter { + return &NoOpLoggerAdapter{} +} + +func (n *NoOpLoggerAdapter) Debug(message string, args ...interface{}) {} +func (n *NoOpLoggerAdapter) Info(message string, args ...interface{}) {} +func (n *NoOpLoggerAdapter) Warn(message string, args ...interface{}) {} +func (n *NoOpLoggerAdapter) Error(message string, args ...interface{}) {} diff --git a/adapters/print_logger_adapter.go b/adapters/print_logger_adapter.go new file mode 100644 index 0000000..ae8fa61 --- /dev/null +++ b/adapters/print_logger_adapter.go @@ -0,0 +1,50 @@ +package adapters + +import ( + "log" +) + +// PrintLoggerAdapter implements LoggerAdapter using standard log package +type PrintLoggerAdapter struct { + level LogLevel +} + +// NewPrintLoggerAdapter creates a new print logger with the specified level +func NewPrintLoggerAdapter(level LogLevel) *PrintLoggerAdapter { + return &PrintLoggerAdapter{level: level} +} + +func (p *PrintLoggerAdapter) shouldLog(level LogLevel) bool { + levels := map[LogLevel]int{ + LogLevelDebug: 0, + LogLevelInfo: 1, + LogLevelWarn: 2, + LogLevelError: 3, + LogLevelNone: 4, + } + return levels[level] >= levels[p.level] +} + +func (p *PrintLoggerAdapter) Debug(message string, args ...interface{}) { + if p.shouldLog(LogLevelDebug) { + log.Printf("[DEBUG] [Ripple] "+message, args...) + } +} + +func (p *PrintLoggerAdapter) Info(message string, args ...interface{}) { + if p.shouldLog(LogLevelInfo) { + log.Printf("[INFO] [Ripple] "+message, args...) + } +} + +func (p *PrintLoggerAdapter) Warn(message string, args ...interface{}) { + if p.shouldLog(LogLevelWarn) { + log.Printf("[WARN] [Ripple] "+message, args...) + } +} + +func (p *PrintLoggerAdapter) Error(message string, args ...interface{}) { + if p.shouldLog(LogLevelError) { + log.Printf("[ERROR] [Ripple] "+message, args...) + } +} diff --git a/client.go b/client.go index 4f56842..7e74547 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package ripple import ( + "errors" "sync" "time" @@ -8,15 +9,26 @@ import ( ) type Client struct { - config ClientConfig - context map[string]interface{} - contextMu sync.RWMutex - dispatcher *Dispatcher - httpAdapter HTTPAdapter - storageAdapter StorageAdapter + config ClientConfig + metadataManager *MetadataManager + dispatcher *Dispatcher + httpAdapter HTTPAdapter + storageAdapter StorageAdapter + loggerAdapter LoggerAdapter + initialized bool + mu sync.RWMutex } func NewClient(config ClientConfig) *Client { + // Validate required fields + if config.APIKey == "" { + panic("apiKey must be provided in config") + } + if config.Endpoint == "" { + panic("endpoint must be provided in config") + } + + // Set defaults if config.FlushInterval == 0 { config.FlushInterval = 5 * time.Second } @@ -28,8 +40,8 @@ func NewClient(config ClientConfig) *Client { } client := &Client{ - config: config, - context: make(map[string]interface{}), + config: config, + metadataManager: NewMetadataManager(), } // Use provided adapters or defaults @@ -45,6 +57,12 @@ func NewClient(config ClientConfig) *Client { client.storageAdapter = adapters.NewFileStorageAdapter("ripple_events.json") } + if config.Adapters.LoggerAdapter != nil { + client.loggerAdapter = config.Adapters.LoggerAdapter + } else { + client.loggerAdapter = adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn) + } + return client } @@ -61,6 +79,13 @@ func (c *Client) SetStorageAdapter(adapter StorageAdapter) { } func (c *Client) Init() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.initialized { + return nil + } + apiKeyHeader := "X-API-Key" if c.config.APIKeyHeader != nil { apiKeyHeader = *c.config.APIKeyHeader @@ -80,47 +105,122 @@ func (c *Client) Init() error { } c.dispatcher = NewDispatcher(dispatcherConfig, c.httpAdapter, c.storageAdapter, headers) - return c.dispatcher.Start() + c.dispatcher.SetLoggerAdapter(c.loggerAdapter) + err := c.dispatcher.Start() + if err != nil { + return err + } + + c.initialized = true + c.loggerAdapter.Info("Client initialized successfully") + return nil } func (c *Client) SetContext(key string, value interface{}) { - c.contextMu.Lock() - defer c.contextMu.Unlock() - c.context[key] = value + c.metadataManager.Set(key, value) } func (c *Client) GetContext() map[string]interface{} { - c.contextMu.RLock() - defer c.contextMu.RUnlock() - ctx := make(map[string]interface{}, len(c.context)) - for k, v := range c.context { - ctx[k] = v - } - return ctx + return c.metadataManager.GetAll() +} + +// SetMetadata sets shared metadata that will be attached to all events +func (c *Client) SetMetadata(key string, value interface{}) { + c.metadataManager.Set(key, value) +} + +// GetMetadata gets a shared metadata value +func (c *Client) GetMetadata(key string) interface{} { + return c.metadataManager.Get(key) +} + +// GetAllMetadata returns all shared metadata +func (c *Client) GetAllMetadata() map[string]interface{} { + return c.metadataManager.GetAll() } -func (c *Client) Track(name string, payload map[string]interface{}, metadata *EventMetadata) { +func (c *Client) Track(name string, payload map[string]interface{}, metadata *EventMetadata) error { + c.mu.RLock() + initialized := c.initialized + c.mu.RUnlock() + + if !initialized { + return errors.New("client not initialized. Call Init() before tracking events") + } + + // Merge shared metadata with event-specific metadata + var finalMetadata *EventMetadata + sharedMetadata := c.metadataManager.GetAll() + + if sharedMetadata != nil || metadata != nil { + finalMetadata = &EventMetadata{} + + // Start with shared metadata + if sharedMetadata != nil { + // Convert shared metadata to EventMetadata fields as needed + // For now, we'll keep it simple and use the existing metadata structure + } + + // Override with event-specific metadata + if metadata != nil { + *finalMetadata = *metadata + } + } + event := Event{ Name: name, Payload: payload, - Metadata: metadata, + Metadata: finalMetadata, IssuedAt: time.Now().UnixMilli(), Context: c.GetContext(), SessionID: nil, // Server platform doesn't use session ID Platform: &Platform{Type: "server"}, } + + c.loggerAdapter.Debug("Tracking event: %s", name) c.dispatcher.Enqueue(event) + return nil } func (c *Client) Flush() { + c.mu.RLock() + initialized := c.initialized + c.mu.RUnlock() + + if !initialized { + c.loggerAdapter.Warn("Flush called before initialization") + return + } + + c.loggerAdapter.Debug("Flushing events") c.dispatcher.Flush() } func (c *Client) Dispose() error { - return c.dispatcher.Stop() + c.mu.Lock() + defer c.mu.Unlock() + + if !c.initialized { + return nil + } + + c.loggerAdapter.Info("Disposing client") + err := c.dispatcher.Stop() + c.initialized = false + return err } // DisposeWithoutFlush stops the client and persists events to storage without flushing to server func (c *Client) DisposeWithoutFlush() error { - return c.dispatcher.StopWithoutFlush() + c.mu.Lock() + defer c.mu.Unlock() + + if !c.initialized { + return nil + } + + c.loggerAdapter.Info("Disposing client without flush") + err := c.dispatcher.StopWithoutFlush() + c.initialized = false + return err } diff --git a/dispatcher.go b/dispatcher.go index 44fa076..2c6050c 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -5,6 +5,8 @@ import ( "math/rand" "sync" "time" + + "github.com/Tap30/ripple-go/adapters" ) type Dispatcher struct { @@ -12,10 +14,11 @@ type Dispatcher struct { queue *Queue httpAdapter HTTPAdapter storageAdapter StorageAdapter + loggerAdapter LoggerAdapter headers map[string]string ticker *time.Ticker stopChan chan struct{} - flushMu sync.Mutex + flushMutex *Mutex wg sync.WaitGroup timerStarted bool timerMu sync.Mutex @@ -27,11 +30,18 @@ func NewDispatcher(config DispatcherConfig, httpAdapter HTTPAdapter, storageAdap queue: NewQueue(), httpAdapter: httpAdapter, storageAdapter: storageAdapter, + loggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn), headers: headers, stopChan: make(chan struct{}), + flushMutex: NewMutex(), } } +// SetLoggerAdapter sets a custom logger adapter +func (d *Dispatcher) SetLoggerAdapter(logger LoggerAdapter) { + d.loggerAdapter = logger +} + func (d *Dispatcher) Start() error { events, err := d.storageAdapter.Load() if err != nil { @@ -77,51 +87,64 @@ func (d *Dispatcher) startTimerIfNeeded() { } func (d *Dispatcher) Flush() { - d.flushMu.Lock() - defer d.flushMu.Unlock() - - for !d.queue.IsEmpty() { - batchSize := min(d.config.MaxBatchSize, d.queue.Len()) - batch := make([]Event, 0, batchSize) - for i := 0; i < batchSize; i++ { - if event, ok := d.queue.Dequeue(); ok { - batch = append(batch, event) + d.flushMutex.RunAtomic(func() error { + d.loggerAdapter.Debug("Starting flush operation") + + for !d.queue.IsEmpty() { + batchSize := min(d.config.MaxBatchSize, d.queue.Len()) + batch := make([]Event, 0, batchSize) + for i := 0; i < batchSize; i++ { + if event, ok := d.queue.Dequeue(); ok { + batch = append(batch, event) + } } - } - if len(batch) == 0 { - break - } + if len(batch) == 0 { + break + } - if err := d.sendWithRetry(batch); err != nil { - for _, event := range batch { - d.queue.Enqueue(event) + d.loggerAdapter.Debug("Sending batch of %d events", len(batch)) + if err := d.sendWithRetry(batch); err != nil { + d.loggerAdapter.Error("Failed to send batch after retries: %v", err) + for _, event := range batch { + d.queue.Enqueue(event) + } + break + } else { + d.loggerAdapter.Debug("Successfully sent batch of %d events", len(batch)) } - break } - } + return nil + }) } func (d *Dispatcher) sendWithRetry(events []Event) error { var lastErr error for attempt := 0; attempt <= d.config.MaxRetries; attempt++ { + d.loggerAdapter.Debug("Sending HTTP request, attempt %d/%d", attempt+1, d.config.MaxRetries+1) resp, err := d.httpAdapter.Send(d.config.Endpoint, events, d.headers) if err == nil && resp.OK { + d.loggerAdapter.Debug("HTTP request successful, clearing storage") d.storageAdapter.Clear() return nil } if err != nil { lastErr = err + d.loggerAdapter.Warn("HTTP request failed with error: %v", err) } else { lastErr = &HTTPError{Status: resp.Status} + d.loggerAdapter.Warn("HTTP request failed with status: %d", resp.Status) } if attempt < d.config.MaxRetries { backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second jitter := time.Duration(rand.Intn(1000)) * time.Millisecond - time.Sleep(backoff + jitter) + sleepDuration := backoff + jitter + d.loggerAdapter.Debug("Retrying in %v", sleepDuration) + time.Sleep(sleepDuration) } } + d.loggerAdapter.Error("All retry attempts failed, last error: %v", lastErr) return lastErr } diff --git a/metadata_manager.go b/metadata_manager.go new file mode 100644 index 0000000..296e22f --- /dev/null +++ b/metadata_manager.go @@ -0,0 +1,60 @@ +package ripple + +import "sync" + +// MetadataManager manages global metadata attached to all events +type MetadataManager struct { + metadata map[string]interface{} + mu sync.RWMutex +} + +// NewMetadataManager creates a new metadata manager +func NewMetadataManager() *MetadataManager { + return &MetadataManager{ + metadata: make(map[string]interface{}), + } +} + +// Set sets a metadata value +func (m *MetadataManager) Set(key string, value interface{}) { + m.mu.Lock() + defer m.mu.Unlock() + m.metadata[key] = value +} + +// Get gets a metadata value +func (m *MetadataManager) Get(key string) interface{} { + m.mu.RLock() + defer m.mu.RUnlock() + return m.metadata[key] +} + +// GetAll returns all metadata as a copy +func (m *MetadataManager) GetAll() map[string]interface{} { + m.mu.RLock() + defer m.mu.RUnlock() + + if len(m.metadata) == 0 { + return nil + } + + result := make(map[string]interface{}, len(m.metadata)) + for k, v := range m.metadata { + result[k] = v + } + return result +} + +// IsEmpty returns true if no metadata is set +func (m *MetadataManager) IsEmpty() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.metadata) == 0 +} + +// Clear removes all metadata +func (m *MetadataManager) Clear() { + m.mu.Lock() + defer m.mu.Unlock() + m.metadata = make(map[string]interface{}) +} diff --git a/mutex.go b/mutex.go new file mode 100644 index 0000000..cacf602 --- /dev/null +++ b/mutex.go @@ -0,0 +1,20 @@ +package ripple + +import "sync" + +// Mutex provides mutual exclusion lock for preventing race conditions +type Mutex struct { + mu sync.Mutex +} + +// NewMutex creates a new mutex +func NewMutex() *Mutex { + return &Mutex{} +} + +// RunAtomic executes a task with exclusive lock +func (m *Mutex) RunAtomic(task func() error) error { + m.mu.Lock() + defer m.mu.Unlock() + return task() +} diff --git a/types.go b/types.go index 746f5e4..4b32dc9 100644 --- a/types.go +++ b/types.go @@ -14,6 +14,8 @@ type ( HTTPAdapter = adapters.HTTPAdapter HTTPResponse = adapters.HTTPResponse StorageAdapter = adapters.StorageAdapter + LoggerAdapter = adapters.LoggerAdapter + LogLevel = adapters.LogLevel ) type HTTPError struct { @@ -34,6 +36,7 @@ type ClientConfig struct { Adapters struct { HTTPAdapter HTTPAdapter StorageAdapter StorageAdapter + LoggerAdapter LoggerAdapter } } From beabee0c660d7f34a1ec8a931bb88f30e8b5d02d Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Tue, 16 Dec 2025 17:03:22 +0330 Subject: [PATCH 09/23] test: update coverage --- client_test.go | 197 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/client_test.go b/client_test.go index 15138b2..fa01132 100644 --- a/client_test.go +++ b/client_test.go @@ -9,6 +9,203 @@ func stringPtr(s string) *string { return &s } +func TestClient_ConfigValidation(t *testing.T) { + t.Run("should panic if APIKey is missing", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing APIKey") + } + }() + NewClient(ClientConfig{ + Endpoint: "http://test.com", + }) + }) + + t.Run("should panic if Endpoint is missing", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing Endpoint") + } + }() + NewClient(ClientConfig{ + APIKey: "test-key", + }) + }) +} + +func TestClient_InitializationValidation(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + t.Run("should return error if Track called before Init", func(t *testing.T) { + err := client.Track("test_event", nil, nil) + if err == nil { + t.Fatal("expected error when tracking before init") + } + expectedMsg := "client not initialized. Call Init() before tracking events" + if err.Error() != expectedMsg { + t.Fatalf("expected error message '%s', got '%s'", expectedMsg, err.Error()) + } + }) + + t.Run("should allow tracking after Init", func(t *testing.T) { + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + defer client.Dispose() + + err := client.Track("test_event", nil, nil) + if err != nil { + t.Fatalf("unexpected error after init: %v", err) + } + }) +} + +func TestClient_MetadataManagement(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + t.Run("should set and get metadata", func(t *testing.T) { + client.SetMetadata("userId", "123") + client.SetMetadata("sessionId", "abc") + + if client.GetMetadata("userId") != "123" { + t.Fatal("expected userId to be 123") + } + if client.GetMetadata("sessionId") != "abc" { + t.Fatal("expected sessionId to be abc") + } + }) + + t.Run("should return all metadata", func(t *testing.T) { + client.SetMetadata("key1", "value1") + client.SetMetadata("key2", "value2") + + metadata := client.GetAllMetadata() + if metadata["key1"] != "value1" || metadata["key2"] != "value2" { + t.Fatal("metadata values do not match") + } + }) + + t.Run("should return nil when no metadata is set", func(t *testing.T) { + newClient := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + metadata := newClient.GetAllMetadata() + if metadata != nil { + t.Fatal("expected nil metadata when none is set") + } + }) +} + +func TestClient_FlushEdgeCases(t *testing.T) { + t.Run("should work with empty queue", func(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + defer client.Dispose() + + // Should not panic or error with empty queue + client.Flush() + }) + + t.Run("should work before initialization", func(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + // Should not panic when called before init + client.Flush() + }) +} + +func TestClient_DisposeEdgeCases(t *testing.T) { + t.Run("should work before initialization", func(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + // Should not panic when called before init + err := client.Dispose() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("should work multiple times", func(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + + // Should work multiple times without error + client.Dispose() + client.Dispose() + }) +} + +func TestClient_DisposeWithoutFlush(t *testing.T) { + client := NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + }) + + mockHTTP := &mockHTTPAdapter{} + mockStorage := &mockStorageAdapter{} + client.httpAdapter = mockHTTP + client.storageAdapter = mockStorage + + if err := client.Init(); err != nil { + t.Fatalf("failed to init: %v", err) + } + + // Add an event + client.Track("test_event", nil, nil) + + // Dispose without flush should not send HTTP request + err := client.DisposeWithoutFlush() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // HTTP adapter should not have been called + if mockHTTP.calls > 0 { + t.Fatal("expected no HTTP calls when disposing without flush") + } +} + func TestClient_SetGetContext(t *testing.T) { client := NewClient(ClientConfig{ APIKey: "test-key", From f0952c18929c32c3b3e4b3d423aa5cbc3bb966cf Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Wed, 17 Dec 2025 12:29:20 +0330 Subject: [PATCH 10/23] feat: add makefile --- Makefile | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..11e9d46 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +GO := /usr/local/go/bin/go + +.PHONY: test fmt lint clean help + +test: + @echo "Running tests..." + $(GO) test ./... + +test-cover: + @echo "Running tests with coverage..." + $(GO) test -cover ./... + +fmt: + @echo "Formatting code..." + /usr/local/go/bin/gofmt -w . + +lint: + @echo "Running linter..." + $(GO) vet ./... + +clean: + @echo "Cleaning up..." + $(GO) clean ./... + rm -f coverage.out + @echo "Done!" + +help: + @echo "Available commands:" + @echo " make test - Run all tests" + @echo " make test-cover - Run tests with coverage" + @echo " make fmt - Format all Go files" + @echo " make lint - Run go vet linter" + @echo " make clean - Clean build artifacts" From f76d4d4e466694aac2349f4fa6379d121d20e918 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Wed, 17 Dec 2025 12:30:03 +0330 Subject: [PATCH 11/23] feat: merge context and metadata --- .gitignore | 5 + ONBOARDING.md | 76 ++++- examples/basic/go.mod | 7 - examples/basic/main.go | 40 --- playground/.gitignore | 2 - playground/Makefile | 19 +- playground/client.go | 155 ---------- playground/cmd/client/main.go | 295 +++++++++++++++++++ playground/{server.go => cmd/server/main.go} | 0 client.go => ripple_client.go | 34 +-- client_test.go => ripple_client_test.go | 153 ++++++---- types.go | 6 +- 12 files changed, 475 insertions(+), 317 deletions(-) delete mode 100644 examples/basic/go.mod delete mode 100644 examples/basic/main.go delete mode 100644 playground/client.go create mode 100644 playground/cmd/client/main.go rename playground/{server.go => cmd/server/main.go} (100%) rename client.go => ripple_client.go (84%) rename client_test.go => ripple_client_test.go (73%) diff --git a/.gitignore b/.gitignore index 975c505..add30f7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ Thumbs.db # Ripple specific ripple_events.json test_*.json +error_events.json + +# Playground binaries +playground/client +playground/server diff --git a/ONBOARDING.md b/ONBOARDING.md index 673fd5b..cc2ce1c 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -59,8 +59,8 @@ type ClientConfig struct { MaxBatchSize int // Default: 10 MaxRetries int // Default: 3 Adapters struct { - HTTPAdapter HTTPAdapter // Optional: Custom HTTP adapter - StorageAdapter StorageAdapter // Optional: Custom storage adapter + HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter + StorageAdapter StorageAdapter // Required: Custom storage adapter LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) } } @@ -82,8 +82,8 @@ type ClientConfig struct { ```sh ripple-go/ -├── client.go # Main client implementation with metadata management -├── client_test.go # Client tests +├── ripple_client.go # Main client implementation with metadata management +├── ripple_client_test.go # Client tests ├── dispatcher.go # Event batching, retry logic, and HTTP dispatch ├── dispatcher_test.go # Dispatcher tests ├── queue.go # FIFO queue implementation @@ -93,6 +93,7 @@ ripple-go/ ├── types.go # Type definitions and re-exports ├── types_test.go # Type tests ├── go.mod # Go module definition +├── Makefile # Build commands (test, fmt, lint, clean) ├── README.md # Project documentation ├── ONBOARDING.md # This file - complete implementation guide ├── adapters/ @@ -107,13 +108,12 @@ ripple-go/ │ ├── noop_logger_adapter.go # No-op logger implementation │ ├── types.go # Adapter type definitions │ └── README.md # Adapter documentation -├── examples/ -│ └── basic/ -│ ├── go.mod -│ └── main.go └── playground/ - ├── server.go # Test server with error simulation - ├── client.go # Interactive test client + ├── cmd/ + │ ├── client/ + │ │ └── main.go # Interactive test client with comprehensive options + │ └── server/ + │ └── main.go # Test server with error simulation ├── go.mod └── Makefile # Build commands ``` @@ -142,8 +142,6 @@ Key methods: * `Init()` - Initialize client and restore persisted events (must be called first) * `Track(name, payload, metadata)` - Track event (throws error if not initialized) -* `SetContext(key, value)` - Set shared context (legacy method) -* `GetContext()` - Get shared context (legacy method) * `SetMetadata(key, value)` - Set shared metadata attached to all events * `GetMetadata(key)` - Get shared metadata value * `GetAllMetadata()` - Get all shared metadata @@ -517,17 +515,31 @@ func (r *RedisStorage) Clear() error { /* ... */ return ni ## Development Workflow +### Development Commands + +Use the root Makefile for common development tasks: + +```bash +make test # Run all tests +make test-cover # Run tests with coverage +make fmt # Format all Go files +make lint # Run go vet linter +make clean # Clean build artifacts +``` + ### Testing The project includes test files for every component: -* `client_test.go` +* `ripple_client_test.go` * `dispatcher_test.go` * `queue_test.go` * `storage_adapter_test.go` * `http_adapter_test.go` -### Commands +### Manual Commands + +If you prefer to run commands directly: * `go build ./...` - Build all packages * `go test ./...` - Run all tests @@ -539,8 +551,8 @@ The project includes test files for every component: The playground provides a local testing environment: -* `playground/server.go` - HTTP server that receives and logs events -* `playground/client.go` - Example client that sends events +* `playground/cmd/server/main.go` - HTTP server that receives and logs events +* `playground/cmd/client/main.go` - Interactive client with comprehensive testing options **Usage:** ```bash @@ -643,6 +655,38 @@ The SDK follows a framework-agnostic design and API contract defined in the main ## Recent Changes +### API Unification (Breaking Change) +- **Removed** `SetContext()` and `GetContext()` methods to match TypeScript SDK +- **Context is now unified with metadata** - use `SetMetadata()` instead +- Updated API to match TypeScript version exactly: + - `SetMetadata(key, value)` - Set shared metadata attached to all events + - `GetMetadata(key)` - Get shared metadata value + - `GetAllMetadata()` - Get all shared metadata +- Updated all tests and playground to use new unified API + +### Adapter Requirements (Breaking Change) +- **HTTPAdapter** and **StorageAdapter** are now **required** (matching TypeScript SDK) +- **LoggerAdapter** remains optional with PrintLoggerAdapter as default +- Added validation that panics if required adapters are missing +- Removed default adapter creation - must be explicitly provided in config +- Updated all tests and playground to provide required adapters +- Added playground binaries to .gitignore to prevent accidental commits + +### File Naming Improvements +- Renamed `client.go` to `ripple_client.go` for better clarity +- Renamed `client_test.go` to `ripple_client_test.go` to match +- Restructured playground to follow Go conventions: `cmd/client/main.go` and `cmd/server/main.go` +- Updated project structure documentation + +### Enhanced Playground Client +- Added comprehensive testing options matching TypeScript playground maturity +- **Basic Event Tracking**: Simple events, events with payload, metadata, and custom metadata +- **Metadata Management**: Set shared metadata, track with shared metadata +- **Batch and Flush**: Multiple event tracking, manual flush testing +- **Error Handling**: Retry logic testing, invalid endpoint testing +- **Lifecycle Management**: Client disposal, graceful exit +- Organized menu with categorized options for better user experience + ### Logger Interface Addition - Added `LoggerAdapter` interface with Debug/Info/Warn/Error methods - Implemented `PrintLoggerAdapter` with configurable log levels diff --git a/examples/basic/go.mod b/examples/basic/go.mod deleted file mode 100644 index 93b8f8a..0000000 --- a/examples/basic/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module example - -go 1.23 - -require github.com/Tap30/ripple-go v0.0.0 - -replace github.com/Tap30/ripple-go => ../.. diff --git a/examples/basic/main.go b/examples/basic/main.go deleted file mode 100644 index b7e1768..0000000 --- a/examples/basic/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "fmt" - "time" - - ripple "github.com/Tap30/ripple-go" -) - -func main() { - client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", - FlushInterval: 5 * time.Second, - MaxBatchSize: 10, - MaxRetries: 3, - }) - - if err := client.Init(); err != nil { - panic(err) - } - defer client.Dispose() - - client.SetContext("userId", "123") - client.SetContext("appVersion", "1.0.0") - - client.Track("page_view", map[string]interface{}{ - "page": "/home", - }, nil) - - client.Track("user_action", map[string]interface{}{ - "button": "submit", - }, &ripple.EventMetadata{ - SchemaVersion: "1.0.0", - }) - - client.Flush() - - fmt.Println("Events tracked successfully") -} diff --git a/playground/.gitignore b/playground/.gitignore index 9fcc184..c114685 100644 --- a/playground/.gitignore +++ b/playground/.gitignore @@ -1,6 +1,4 @@ # Binaries -server -client server_bin client_bin server_test diff --git a/playground/Makefile b/playground/Makefile index 1e333c0..4c8b620 100644 --- a/playground/Makefile +++ b/playground/Makefile @@ -1,20 +1,29 @@ -.PHONY: server client clean +GO := /usr/local/go/bin/go + +.PHONY: server client clean build server: @echo "🚀 Starting event tracking server..." - @go run server.go + @$(GO) run cmd/server/main.go client: @echo "🎯 Starting interactive client..." - @go run client.go + @$(GO) run cmd/client/main.go + +build: + @echo "🔨 Building binaries..." + @cd cmd/client && $(GO) build -o ../../client . + @cd cmd/server && $(GO) build -o ../../server . + @echo "✅ Built: client, server" clean: @echo "🧹 Cleaning up..." - @rm -f ripple_events.json + @rm -f ripple_events.json client server @echo "✨ Done!" help: @echo "Available commands:" @echo " make server - Start the event tracking server" @echo " make client - Run the interactive client" - @echo " make clean - Remove persisted events file" + @echo " make build - Build client and server binaries" + @echo " make clean - Remove persisted events file and binaries" diff --git a/playground/client.go b/playground/client.go deleted file mode 100644 index c299aec..0000000 --- a/playground/client.go +++ /dev/null @@ -1,155 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "strings" - "time" - - ripple "github.com/Tap30/ripple-go" -) - -func stringPtr(s string) *string { - return &s -} - -var client *ripple.Client -var scanner *bufio.Scanner -var contextCounter int -var eventCounter int - -func main() { - scanner = bufio.NewScanner(os.Stdin) - - client = ripple.NewClient(ripple.ClientConfig{ - APIKey: "test-api-key", - Endpoint: "http://localhost:3000/events", - FlushInterval: 5 * time.Second, - MaxBatchSize: 5, - MaxRetries: 3, - }) - - if err := client.Init(); err != nil { - fmt.Printf("❌ Failed to initialize client: %v\n", err) - return - } - - fmt.Println("🎯 Ripple Interactive Client") - fmt.Println("Connected to: http://localhost:3000/events") - fmt.Println() - - for { - showMenu() - choice := readInput("Choose an option: ") - - switch choice { - case "1": - setContext() - case "2": - viewContext() - case "3": - trackEvent() - case "4": - trackEventWithError() - case "5": - flush() - case "6": - fmt.Println("👋 Goodbye!") - // Persist events to storage without flushing to server - client.DisposeWithoutFlush() - return - default: - fmt.Println("❌ Invalid option. Please try again.\n") - } - } -} - -func showMenu() { - fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - fmt.Println("1. Set Context") - fmt.Println("2. View Context") - fmt.Println("3. Track Event") - fmt.Println("4. Track Event with Error (Test Retry)") - fmt.Println("5. Flush Events") - fmt.Println("6. Exit") - fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") -} - -func readInput(prompt string) string { - fmt.Print(prompt) - scanner.Scan() - return strings.TrimSpace(scanner.Text()) -} - -func setContext() { - fmt.Println("\n📝 Set Context") - contextCounter++ - key := fmt.Sprintf("key_%d", contextCounter) - value := fmt.Sprintf("value_%d", contextCounter) - - client.SetContext(key, value) - fmt.Printf("✅ Context set: %s = %s\n\n", key, value) -} - -func viewContext() { - fmt.Println("\n👀 Current Context") - ctx := client.GetContext() - if len(ctx) == 0 { - fmt.Println("(empty)") - } else { - for k, v := range ctx { - fmt.Printf(" %s: %v\n", k, v) - } - } - fmt.Println() -} - -func trackEvent() { - fmt.Println("\n📊 Track Event") - eventCounter++ - name := fmt.Sprintf("event_%d", eventCounter) - - // Mock sample payload - payload := map[string]interface{}{ - "action": fmt.Sprintf("action_%d", eventCounter), - "timestamp": time.Now().Unix(), - "data": map[string]interface{}{ - "count": eventCounter, - "type": "sample", - }, - } - - metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} - - client.Track(name, payload, metadata) - fmt.Printf("✅ Event '%s' tracked with sample payload\n\n", name) -} - -func trackEventWithError() { - fmt.Println("\n⚠️ Track Event with Error (Test Retry)") - eventCounter++ - name := fmt.Sprintf("error_event_%d", eventCounter) - - // Payload with error trigger - payload := map[string]interface{}{ - "action": fmt.Sprintf("error_action_%d", eventCounter), - "timestamp": time.Now().Unix(), - "trigger_error": true, // This will cause server to return 500 - "data": map[string]interface{}{ - "count": eventCounter, - "type": "error_test", - }, - } - - metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} - - client.Track(name, payload, metadata) - fmt.Printf("✅ Error event '%s' tracked - will trigger retry logic\n\n", name) -} - -func flush() { - fmt.Println("\n🔄 Flushing events...") - client.Flush() - fmt.Println("✅ Events flushed\n") -} diff --git a/playground/cmd/client/main.go b/playground/cmd/client/main.go new file mode 100644 index 0000000..ae85577 --- /dev/null +++ b/playground/cmd/client/main.go @@ -0,0 +1,295 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" + + ripple "github.com/Tap30/ripple-go" + "github.com/Tap30/ripple-go/adapters" +) + +func stringPtr(s string) *string { + return &s +} + +var client *ripple.Client +var scanner *bufio.Scanner +var contextCounter int +var eventCounter int + +func main() { + scanner = bufio.NewScanner(os.Stdin) + + client = ripple.NewClient(ripple.ClientConfig{ + APIKey: "test-api-key", + Endpoint: "http://localhost:3000/events", + FlushInterval: 5 * time.Second, + MaxBatchSize: 5, + MaxRetries: 3, + Adapters: struct { + HTTPAdapter ripple.HTTPAdapter + StorageAdapter ripple.StorageAdapter + LoggerAdapter ripple.LoggerAdapter + }{ + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelInfo), + }, + }) + + if err := client.Init(); err != nil { + fmt.Printf("❌ Failed to initialize client: %v\n", err) + return + } + + fmt.Println("🎯 Ripple Interactive Client") + fmt.Println("Connected to: http://localhost:3000/events") + fmt.Println() + + for { + showMenu() + choice := readInput("Choose an option: ") + + switch choice { + case "1": + trackSimpleEvent() + case "2": + trackEventWithPayload() + case "3": + trackEventWithMetadata() + case "4": + trackEventWithCustomMetadata() + case "5": + setSharedMetadata() + case "6": + trackWithSharedMetadata() + case "7": + viewContext() + case "8": + trackMultipleEvents() + case "9": + flush() + case "10": + trackEventWithError() + case "11": + testInvalidEndpoint() + case "12": + disposeClient() + case "13": + fmt.Println("👋 Goodbye!") + // Persist events to storage without flushing to server + client.DisposeWithoutFlush() + return + default: + fmt.Println("❌ Invalid option. Please try again.\n") + } + } +} + +func showMenu() { + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println("📊 Basic Event Tracking") + fmt.Println("1. Track Simple Event") + fmt.Println("2. Track Event with Payload") + fmt.Println("3. Track Event with Metadata") + fmt.Println("4. Track Event with Custom Metadata") + fmt.Println() + fmt.Println("🏷️ Metadata Management") + fmt.Println("5. Set Shared Metadata") + fmt.Println("6. Track with Shared Metadata") + fmt.Println("7. View Current Context/Metadata") + fmt.Println() + fmt.Println("📦 Batch and Flush") + fmt.Println("8. Track Multiple Events (Batch Test)") + fmt.Println("9. Manual Flush") + fmt.Println() + fmt.Println("⚠️ Error Handling") + fmt.Println("10. Test Retry Logic (Error Event)") + fmt.Println("11. Test Invalid Endpoint") + fmt.Println() + fmt.Println("🔄 Lifecycle Management") + fmt.Println("12. Dispose Client") + fmt.Println("13. Exit") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") +} + +func readInput(prompt string) string { + fmt.Print(prompt) + scanner.Scan() + return strings.TrimSpace(scanner.Text()) +} + +func trackSimpleEvent() { + fmt.Println("\n📊 Track Simple Event") + client.Track("button_click", nil, nil) + fmt.Println("✅ Tracked: button_click\n") +} + +func trackEventWithPayload() { + fmt.Println("\n📊 Track Event with Payload") + payload := map[string]interface{}{ + "action": "click", + "target": "button", + "timestamp": time.Now().Unix(), + } + client.Track("user_action", payload, nil) + fmt.Println("✅ Tracked: user_action with payload\n") +} + +func trackEventWithMetadata() { + fmt.Println("\n📊 Track Event with Metadata") + payload := map[string]interface{}{ + "formId": "contact-form", + "fields": 5, + } + metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} + client.Track("form_submit", payload, metadata) + fmt.Println("✅ Tracked: form_submit with metadata\n") +} + +func trackEventWithCustomMetadata() { + fmt.Println("\n📊 Track Event with Custom Metadata") + payload := map[string]interface{}{ + "orderId": "order-123", + "amount": 99.99, + } + metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("2.1.0")} + client.Track("purchase_completed", payload, metadata) + fmt.Println("✅ Tracked: purchase_completed with rich metadata\n") +} + +func setSharedMetadata() { + fmt.Println("\n🏷️ Set Shared Metadata") + contextCounter++ + key := fmt.Sprintf("key_%d", contextCounter) + value := fmt.Sprintf("value_%d", contextCounter) + + client.SetMetadata(key, value) + fmt.Printf("✅ Shared metadata set: %s = %s\n\n", key, value) +} + +func trackWithSharedMetadata() { + fmt.Println("\n🏷️ Track with Shared Metadata") + client.Track("metadata_test", nil, nil) + fmt.Println("✅ Tracked event with shared metadata\n") +} + +func trackMultipleEvents() { + fmt.Println("\n📦 Track Multiple Events (Batch Test)") + for i := 0; i < 10; i++ { + payload := map[string]interface{}{"index": i} + client.Track("batch_event", payload, nil) + } + fmt.Println("✅ Tracked 10 events (should auto-flush at batch size 5)\n") +} + +func testInvalidEndpoint() { + fmt.Println("\n⚠️ Test Invalid Endpoint") + + // Create a new client with invalid endpoint + errorClient := ripple.NewClient(ripple.ClientConfig{ + APIKey: "test-key", + Endpoint: "http://localhost:9999/invalid", + FlushInterval: 5 * time.Second, + MaxBatchSize: 5, + MaxRetries: 2, + Adapters: struct { + HTTPAdapter ripple.HTTPAdapter + StorageAdapter ripple.StorageAdapter + LoggerAdapter ripple.LoggerAdapter + }{ + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("error_events.json"), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn), + }, + }) + + if err := errorClient.Init(); err != nil { + fmt.Printf("❌ Failed to init error client: %v\n\n", err) + return + } + + errorClient.Track("error_test", map[string]interface{}{"shouldFail": true}, nil) + fmt.Println("✅ Tracked event to invalid endpoint (check console for retries)\n") +} + +func disposeClient() { + fmt.Println("\n🔄 Dispose Client") + client.Dispose() + fmt.Println("✅ Client disposed\n") +} + +func setContext() { + fmt.Println("\n📝 Set Metadata") + contextCounter++ + key := fmt.Sprintf("key_%d", contextCounter) + value := fmt.Sprintf("value_%d", contextCounter) + + client.SetMetadata(key, value) + fmt.Printf("✅ Metadata set: %s = %s\n\n", key, value) +} + +func viewContext() { + fmt.Println("\n👀 Current Metadata") + metadata := client.GetAllMetadata() + if len(metadata) == 0 { + fmt.Println("(empty)") + } else { + for k, v := range metadata { + fmt.Printf(" %s: %v\n", k, v) + } + } + fmt.Println() +} + +func trackEvent() { + fmt.Println("\n📊 Track Event") + eventCounter++ + name := fmt.Sprintf("event_%d", eventCounter) + + // Mock sample payload + payload := map[string]interface{}{ + "action": fmt.Sprintf("action_%d", eventCounter), + "timestamp": time.Now().Unix(), + "data": map[string]interface{}{ + "count": eventCounter, + "type": "sample", + }, + } + + metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} + + client.Track(name, payload, metadata) + fmt.Printf("✅ Event '%s' tracked with sample payload\n\n", name) +} + +func trackEventWithError() { + fmt.Println("\n⚠️ Track Event with Error (Test Retry)") + eventCounter++ + name := fmt.Sprintf("error_event_%d", eventCounter) + + // Payload with error trigger + payload := map[string]interface{}{ + "action": fmt.Sprintf("error_action_%d", eventCounter), + "timestamp": time.Now().Unix(), + "trigger_error": true, // This will cause server to return 500 + "data": map[string]interface{}{ + "count": eventCounter, + "type": "error_test", + }, + } + + metadata := &ripple.EventMetadata{SchemaVersion: stringPtr("1.0.0")} + + client.Track(name, payload, metadata) + fmt.Printf("✅ Error event '%s' tracked - will trigger retry logic\n\n", name) +} + +func flush() { + fmt.Println("\n🔄 Flushing events...") + client.Flush() + fmt.Println("✅ Events flushed\n") +} diff --git a/playground/server.go b/playground/cmd/server/main.go similarity index 100% rename from playground/server.go rename to playground/cmd/server/main.go diff --git a/client.go b/ripple_client.go similarity index 84% rename from client.go rename to ripple_client.go index 7e74547..028af7c 100644 --- a/client.go +++ b/ripple_client.go @@ -27,6 +27,9 @@ func NewClient(config ClientConfig) *Client { if config.Endpoint == "" { panic("endpoint must be provided in config") } + if config.Adapters.HTTPAdapter == nil || config.Adapters.StorageAdapter == nil { + panic("Both httpAdapter and storageAdapter must be provided in config.adapters") + } // Set defaults if config.FlushInterval == 0 { @@ -42,21 +45,11 @@ func NewClient(config ClientConfig) *Client { client := &Client{ config: config, metadataManager: NewMetadataManager(), + httpAdapter: config.Adapters.HTTPAdapter, + storageAdapter: config.Adapters.StorageAdapter, } - // Use provided adapters or defaults - if config.Adapters.HTTPAdapter != nil { - client.httpAdapter = config.Adapters.HTTPAdapter - } else { - client.httpAdapter = adapters.NewNetHTTPAdapter() - } - - if config.Adapters.StorageAdapter != nil { - client.storageAdapter = config.Adapters.StorageAdapter - } else { - client.storageAdapter = adapters.NewFileStorageAdapter("ripple_events.json") - } - + // Use provided logger or default if config.Adapters.LoggerAdapter != nil { client.loggerAdapter = config.Adapters.LoggerAdapter } else { @@ -116,25 +109,14 @@ func (c *Client) Init() error { return nil } -func (c *Client) SetContext(key string, value interface{}) { - c.metadataManager.Set(key, value) -} - -func (c *Client) GetContext() map[string]interface{} { - return c.metadataManager.GetAll() -} - -// SetMetadata sets shared metadata that will be attached to all events func (c *Client) SetMetadata(key string, value interface{}) { c.metadataManager.Set(key, value) } -// GetMetadata gets a shared metadata value func (c *Client) GetMetadata(key string) interface{} { return c.metadataManager.Get(key) } -// GetAllMetadata returns all shared metadata func (c *Client) GetAllMetadata() map[string]interface{} { return c.metadataManager.GetAll() } @@ -172,8 +154,8 @@ func (c *Client) Track(name string, payload map[string]interface{}, metadata *Ev Payload: payload, Metadata: finalMetadata, IssuedAt: time.Now().UnixMilli(), - Context: c.GetContext(), - SessionID: nil, // Server platform doesn't use session ID + Context: sharedMetadata, // Use shared metadata as context + SessionID: nil, // Server platform doesn't use session ID Platform: &Platform{Type: "server"}, } diff --git a/client_test.go b/ripple_client_test.go similarity index 73% rename from client_test.go rename to ripple_client_test.go index fa01132..bb13ecf 100644 --- a/client_test.go +++ b/ripple_client_test.go @@ -9,6 +9,21 @@ func stringPtr(s string) *string { return &s } +func createTestConfig() ClientConfig { + return ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + Adapters: struct { + HTTPAdapter HTTPAdapter + StorageAdapter StorageAdapter + LoggerAdapter LoggerAdapter + }{ + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, + }, + } +} + func TestClient_ConfigValidation(t *testing.T) { t.Run("should panic if APIKey is missing", func(t *testing.T) { defer func() { @@ -18,6 +33,14 @@ func TestClient_ConfigValidation(t *testing.T) { }() NewClient(ClientConfig{ Endpoint: "http://test.com", + Adapters: struct { + HTTPAdapter HTTPAdapter + StorageAdapter StorageAdapter + LoggerAdapter LoggerAdapter + }{ + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, + }, }) }) @@ -29,15 +52,58 @@ func TestClient_ConfigValidation(t *testing.T) { }() NewClient(ClientConfig{ APIKey: "test-key", + Adapters: struct { + HTTPAdapter HTTPAdapter + StorageAdapter StorageAdapter + LoggerAdapter LoggerAdapter + }{ + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, + }, + }) + }) + + t.Run("should panic if HTTPAdapter is missing", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing HTTPAdapter") + } + }() + NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + Adapters: struct { + HTTPAdapter HTTPAdapter + StorageAdapter StorageAdapter + LoggerAdapter LoggerAdapter + }{ + StorageAdapter: &mockStorageAdapter{}, + }, + }) + }) + + t.Run("should panic if StorageAdapter is missing", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing StorageAdapter") + } + }() + NewClient(ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + Adapters: struct { + HTTPAdapter HTTPAdapter + StorageAdapter StorageAdapter + LoggerAdapter LoggerAdapter + }{ + HTTPAdapter: &mockHTTPAdapter{}, + }, }) }) } func TestClient_InitializationValidation(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) t.Run("should return error if Track called before Init", func(t *testing.T) { err := client.Track("test_event", nil, nil) @@ -69,10 +135,7 @@ func TestClient_InitializationValidation(t *testing.T) { } func TestClient_MetadataManagement(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) t.Run("should set and get metadata", func(t *testing.T) { client.SetMetadata("userId", "123") @@ -97,10 +160,7 @@ func TestClient_MetadataManagement(t *testing.T) { }) t.Run("should return nil when no metadata is set", func(t *testing.T) { - newClient := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + newClient := NewClient(createTestConfig()) metadata := newClient.GetAllMetadata() if metadata != nil { @@ -111,10 +171,7 @@ func TestClient_MetadataManagement(t *testing.T) { func TestClient_FlushEdgeCases(t *testing.T) { t.Run("should work with empty queue", func(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -131,10 +188,7 @@ func TestClient_FlushEdgeCases(t *testing.T) { }) t.Run("should work before initialization", func(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) // Should not panic when called before init client.Flush() @@ -143,10 +197,7 @@ func TestClient_FlushEdgeCases(t *testing.T) { func TestClient_DisposeEdgeCases(t *testing.T) { t.Run("should work before initialization", func(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) // Should not panic when called before init err := client.Dispose() @@ -156,10 +207,7 @@ func TestClient_DisposeEdgeCases(t *testing.T) { }) t.Run("should work multiple times", func(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -177,10 +225,7 @@ func TestClient_DisposeEdgeCases(t *testing.T) { } func TestClient_DisposeWithoutFlush(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -206,26 +251,20 @@ func TestClient_DisposeWithoutFlush(t *testing.T) { } } -func TestClient_SetGetContext(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) +func TestClient_SetGetMetadata(t *testing.T) { + client := NewClient(createTestConfig()) - client.SetContext("userId", "123") - client.SetContext("appVersion", "1.0.0") + client.SetMetadata("userId", "123") + client.SetMetadata("appVersion", "1.0.0") - ctx := client.GetContext() - if ctx["userId"] != "123" || ctx["appVersion"] != "1.0.0" { - t.Fatal("context values do not match") + metadata := client.GetAllMetadata() + if metadata["userId"] != "123" || metadata["appVersion"] != "1.0.0" { + t.Fatal("metadata values do not match") } } func TestClient_Track(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -237,7 +276,7 @@ func TestClient_Track(t *testing.T) { } defer client.Dispose() - client.SetContext("userId", "123") + client.SetMetadata("userId", "123") client.Track("page_view", map[string]interface{}{"page": "/home"}, nil) time.Sleep(100 * time.Millisecond) @@ -248,10 +287,7 @@ func TestClient_Track(t *testing.T) { } func TestClient_TrackWithMetadata(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -274,10 +310,7 @@ func TestClient_TrackWithMetadata(t *testing.T) { } func TestClient_Flush(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -298,10 +331,7 @@ func TestClient_Flush(t *testing.T) { } func TestClient_DefaultConfig(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) if client.config.FlushInterval != 5*time.Second { t.Fatal("expected default flush interval of 5s") @@ -315,10 +345,7 @@ func TestClient_DefaultConfig(t *testing.T) { } func TestClient_SetCustomAdapters(t *testing.T) { - client := NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - }) + client := NewClient(createTestConfig()) customHTTP := &mockHTTPAdapter{} customStorage := &mockStorageAdapter{} diff --git a/types.go b/types.go index 4b32dc9..22fba42 100644 --- a/types.go +++ b/types.go @@ -34,9 +34,9 @@ type ClientConfig struct { MaxBatchSize int MaxRetries int Adapters struct { - HTTPAdapter HTTPAdapter - StorageAdapter StorageAdapter - LoggerAdapter LoggerAdapter + HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter + StorageAdapter StorageAdapter // Required: Custom storage adapter + LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) } } From 8a7e12e085c0bece5f218d9ade6a1a4e91db3850 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Fri, 19 Dec 2025 18:50:52 +0330 Subject: [PATCH 12/23] docs: update playground readme --- playground/README.md | 133 +++++++++++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 43 deletions(-) diff --git a/playground/README.md b/playground/README.md index 8257da8..b2a0c2f 100644 --- a/playground/README.md +++ b/playground/README.md @@ -4,8 +4,8 @@ A testing environment for the Ripple Go SDK with a dummy HTTP server. ## Structure -- `server.go` - HTTP server that receives and logs events -- `client.go` - Interactive CLI client for manual testing +- `cmd/server/main.go` - HTTP server that receives and logs events +- `cmd/client/main.go` - Interactive CLI client for manual testing ## Usage @@ -13,7 +13,7 @@ A testing environment for the Ripple Go SDK with a dummy HTTP server. ```bash cd playground -go run server.go +go run cmd/server/main.go ``` Using Makefile: @@ -30,7 +30,7 @@ For interactive testing with a CLI menu: ```bash cd playground -go run client.go +go run cmd/client/main.go ``` Or using Makefile: @@ -40,11 +40,29 @@ make client ``` The client provides a menu to: -- **Set Context** - Automatically adds `key_i: value_i` (incremented) -- **View Context** - Display current context -- **Track Event** - Automatically creates `event_i` with sample payload -- **Flush Events** - Manually trigger event flush -- **Exit** - Gracefully shutdown + +**📊 Basic Event Tracking** +- Track Simple Event +- Track Event with Payload +- Track Event with Metadata +- Track Event with Custom Metadata + +**🏷️ Metadata Management** +- Set Shared Metadata +- Track with Shared Metadata +- View Current Context/Metadata + +**📦 Batch and Flush** +- Track Multiple Events (Batch Test) +- Manual Flush + +**⚠️ Error Handling** +- Test Retry Logic (Error Event) +- Test Invalid Endpoint + +**🔄 Lifecycle Management** +- Dispose Client +- Exit Example session: ``` @@ -52,21 +70,38 @@ Example session: Connected to: http://localhost:3000/events ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -1. Set Context -2. View Context -3. Track Event -4. Flush Events -5. Exit +📊 Basic Event Tracking +1. Track Simple Event +2. Track Event with Payload +3. Track Event with Metadata +4. Track Event with Custom Metadata + +🏷️ Metadata Management +5. Set Shared Metadata +6. Track with Shared Metadata +7. View Current Context/Metadata + +📦 Batch and Flush +8. Track Multiple Events (Batch Test) +9. Manual Flush + +⚠️ Error Handling +10. Test Retry Logic (Error Event) +11. Test Invalid Endpoint + +🔄 Lifecycle Management +12. Dispose Client +13. Exit ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Choose an option: 1 -📝 Set Context -✅ Context set: key_1 = value_1 +📊 Track Simple Event +✅ Tracked: button_click -Choose an option: 3 +Choose an option: 5 -📊 Track Event -✅ Event 'event_1' tracked with sample payload +🏷️ Set Shared Metadata +✅ Shared metadata set: key_1 = value_1 ``` ### Expected Output @@ -76,15 +111,16 @@ Choose an option: 3 ```txt 🚀 Event tracking server running at http://localhost:3000 📍 Endpoint: http://localhost:3000/events -🔑 API Key: Bearer test-api-key +🔑 API Key: test-api-key 📊 Received events: { "events": [ { - "name": "page_view", - "payload": { "page": "/home" }, - "issuedAt": 1234567890, - "context": { "userId": "user-123" }, + "name": "button_click", + "payload": null, + "issuedAt": 1734622890, + "context": {}, + "metadata": {}, "platform": { "type": "server" } } ] @@ -94,21 +130,24 @@ Choose an option: 3 **Client:** ```txt -📤 Tracking events... -✅ Events tracked. Waiting for flush... -🔄 Manual flush... -✨ Done! +📊 Track Simple Event +✅ Tracked: button_click + +🔄 Flushing events... +✅ Events flushed ``` ## E2E Testing This playground is useful for: -- Manual testing of the SDK -- Verifying event delivery -- Testing retry logic (stop/start server) -- Testing persistence (kill client before flush) -- Debugging event payloads +- Manual testing of the SDK with comprehensive menu options +- Verifying event delivery and batching behavior +- Testing retry logic with simulated server errors +- Testing persistence (events saved to `ripple_events.json`) +- Testing invalid endpoints and error handling +- Debugging event payloads and metadata +- Testing shared metadata functionality ## Server Endpoints @@ -146,35 +185,43 @@ Returns: ```bash # Terminal 1 -go run server.go +go run cmd/server/main.go # Terminal 2 -go run client.go +go run cmd/client/main.go ``` ### 2. Test Retry Logic ```bash # Terminal 1 -go run server.go +go run cmd/server/main.go # Terminal 2 -go run client.go +go run cmd/client/main.go +# Choose option 10 to test retry logic with error events +# Server will return 500 error and client will retry -# Stop server (Ctrl+C) before flush +# Or stop server (Ctrl+C) before flush # Events should be persisted to ripple_events.json # Restart server -go run server.go +go run cmd/server/main.go # Run client again - persisted events should be sent -go run client.go +go run cmd/client/main.go ``` ### 3. Test Batching -Modify `client.go` to track more events and observe batching behavior. +```bash +# Use option 8 in the client menu to track 10 events +# Observe auto-flush at batch size 5 +``` -### 4. Test Custom Adapters +### 4. Test Invalid Endpoint -Create custom HTTP or storage adapters and test them here. +```bash +# Use option 11 in the client menu +# Creates a client with invalid endpoint to test error handling +``` From 416982ab66feffcf739149dd9e783bb05f58170f Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 17:51:35 +0330 Subject: [PATCH 13/23] refactor: flat adapters into the client configuration --- ONBOARDING.md | 77 +++++++++++++++-------------------- README.md | 35 +++++++++++----- playground/cmd/client/main.go | 44 ++++++++------------ ripple_client.go | 12 +++--- ripple_client_test.go | 62 ++++++++-------------------- types.go | 20 ++++----- 6 files changed, 103 insertions(+), 147 deletions(-) diff --git a/ONBOARDING.md b/ONBOARDING.md index cc2ce1c..9481bc4 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -52,17 +52,15 @@ This version is not a monorepo. It has no browser package, no Node.js package, a ```go type ClientConfig struct { - APIKey string - Endpoint string - APIKeyHeader *string // Optional: Header name for API key (default: "X-API-Key") - FlushInterval time.Duration // Default: 5s - MaxBatchSize int // Default: 10 - MaxRetries int // Default: 3 - Adapters struct { - HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter - StorageAdapter StorageAdapter // Required: Custom storage adapter - LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) - } + APIKey string + Endpoint string + APIKeyHeader *string // Optional: Header name for API key (default: "X-API-Key") + FlushInterval time.Duration // Default: 5s + MaxBatchSize int // Default: 10 + MaxRetries int // Default: 3 + HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter + StorageAdapter StorageAdapter // Required: Custom storage adapter + LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) } ``` @@ -356,8 +354,10 @@ import ( ) client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) // Initialize client (required before tracking) @@ -416,19 +416,15 @@ err := client.Track("user_signup", map[string]interface{}{ ```go client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", - APIKeyHeader: stringPtr("Authorization"), // Custom header name - FlushInterval: 10 * time.Second, // Custom flush interval - MaxBatchSize: 20, // Custom batch size - MaxRetries: 5, // Custom retry count - Adapters: struct { - HTTPAdapter ripple.HTTPAdapter - StorageAdapter ripple.StorageAdapter - LoggerAdapter ripple.LoggerAdapter - }{ - LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), - }, + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + APIKeyHeader: stringPtr("Authorization"), // Custom header name + FlushInterval: 10 * time.Second, // Custom flush interval + MaxBatchSize: 20, // Custom batch size + MaxRetries: 5, // Custom retry count + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), }) ``` @@ -448,15 +444,10 @@ func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers m // Usage client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", - Adapters: struct { - HTTPAdapter ripple.HTTPAdapter - StorageAdapter ripple.StorageAdapter - LoggerAdapter ripple.LoggerAdapter - }{ - HTTPAdapter: &MyHTTPAdapter{}, - }, + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + HTTPAdapter: &MyHTTPAdapter{}, + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) ``` @@ -487,15 +478,11 @@ func (l *MyLoggerAdapter) Error(message string, args ...interface{}) { // Usage client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", - Adapters: struct { - HTTPAdapter ripple.HTTPAdapter - StorageAdapter ripple.StorageAdapter - LoggerAdapter ripple.LoggerAdapter - }{ - LoggerAdapter: &MyLoggerAdapter{logger: log.New(os.Stdout, "", log.LstdFlags)}, - }, + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + LoggerAdapter: &MyLoggerAdapter{logger: log.New(os.Stdout, "", log.LstdFlags)}, }) ``` @@ -711,7 +698,7 @@ The SDK follows a framework-agnostic design and API contract defined in the main ### Enhanced Configuration - Added `APIKeyHeader` support for custom header names -- Added `Adapters` struct in `ClientConfig` for all adapter types +- Flattened adapter configuration directly in `ClientConfig` - Improved configuration validation with required field checks ### Adapter Naming Refactor diff --git a/README.md b/README.md index a34a409..199fb03 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,10 @@ import ( func main() { client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) if err := client.Init(); err != nil { @@ -74,11 +76,14 @@ func main() { ```go type ClientConfig struct { - APIKey string // Required: API authentication key - Endpoint string // Required: Event collection endpoint - FlushInterval time.Duration // Optional: Default 5s - MaxBatchSize int // Optional: Default 10 - MaxRetries int // Optional: Default 3 + APIKey string // Required: API authentication key + Endpoint string // Required: Event collection endpoint + FlushInterval time.Duration // Optional: Default 5s + MaxBatchSize int // Optional: Default 10 + MaxRetries int // Optional: Default 3 + HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter + StorageAdapter StorageAdapter // Required: Custom storage adapter + LoggerAdapter LoggerAdapter // Optional: Custom logger adapter } ``` @@ -126,8 +131,12 @@ func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers m } // Use custom adapter -client := ripple.NewClient(config) -client.SetHTTPAdapter(&MyHTTPAdapter{}) +client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + HTTPAdapter: &MyHTTPAdapter{}, + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), +}) client.Init() ``` @@ -161,8 +170,12 @@ func (r *RedisStorage) Clear() error { } // Use custom adapter -client := ripple.NewClient(config) -client.SetStorageAdapter(&RedisStorage{}) +client := ripple.NewClient(ripple.ClientConfig{ + APIKey: "your-api-key", + Endpoint: "https://api.example.com/events", + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: &RedisStorage{}, +}) client.Init() ``` diff --git a/playground/cmd/client/main.go b/playground/cmd/client/main.go index ae85577..ed50349 100644 --- a/playground/cmd/client/main.go +++ b/playground/cmd/client/main.go @@ -24,20 +24,14 @@ func main() { scanner = bufio.NewScanner(os.Stdin) client = ripple.NewClient(ripple.ClientConfig{ - APIKey: "test-api-key", - Endpoint: "http://localhost:3000/events", - FlushInterval: 5 * time.Second, - MaxBatchSize: 5, - MaxRetries: 3, - Adapters: struct { - HTTPAdapter ripple.HTTPAdapter - StorageAdapter ripple.StorageAdapter - LoggerAdapter ripple.LoggerAdapter - }{ - HTTPAdapter: adapters.NewNetHTTPAdapter(), - StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), - LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelInfo), - }, + APIKey: "test-api-key", + Endpoint: "http://localhost:3000/events", + FlushInterval: 5 * time.Second, + MaxBatchSize: 5, + MaxRetries: 3, + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelInfo), }) if err := client.Init(); err != nil { @@ -191,20 +185,14 @@ func testInvalidEndpoint() { // Create a new client with invalid endpoint errorClient := ripple.NewClient(ripple.ClientConfig{ - APIKey: "test-key", - Endpoint: "http://localhost:9999/invalid", - FlushInterval: 5 * time.Second, - MaxBatchSize: 5, - MaxRetries: 2, - Adapters: struct { - HTTPAdapter ripple.HTTPAdapter - StorageAdapter ripple.StorageAdapter - LoggerAdapter ripple.LoggerAdapter - }{ - HTTPAdapter: adapters.NewNetHTTPAdapter(), - StorageAdapter: adapters.NewFileStorageAdapter("error_events.json"), - LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn), - }, + APIKey: "test-key", + Endpoint: "http://localhost:9999/invalid", + FlushInterval: 5 * time.Second, + MaxBatchSize: 5, + MaxRetries: 2, + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("error_events.json"), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn), }) if err := errorClient.Init(); err != nil { diff --git a/ripple_client.go b/ripple_client.go index 028af7c..d76535d 100644 --- a/ripple_client.go +++ b/ripple_client.go @@ -27,8 +27,8 @@ func NewClient(config ClientConfig) *Client { if config.Endpoint == "" { panic("endpoint must be provided in config") } - if config.Adapters.HTTPAdapter == nil || config.Adapters.StorageAdapter == nil { - panic("Both httpAdapter and storageAdapter must be provided in config.adapters") + if config.HTTPAdapter == nil || config.StorageAdapter == nil { + panic("Both HTTPAdapter and StorageAdapter must be provided in config") } // Set defaults @@ -45,13 +45,13 @@ func NewClient(config ClientConfig) *Client { client := &Client{ config: config, metadataManager: NewMetadataManager(), - httpAdapter: config.Adapters.HTTPAdapter, - storageAdapter: config.Adapters.StorageAdapter, + httpAdapter: config.HTTPAdapter, + storageAdapter: config.StorageAdapter, } // Use provided logger or default - if config.Adapters.LoggerAdapter != nil { - client.loggerAdapter = config.Adapters.LoggerAdapter + if config.LoggerAdapter != nil { + client.loggerAdapter = config.LoggerAdapter } else { client.loggerAdapter = adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn) } diff --git a/ripple_client_test.go b/ripple_client_test.go index bb13ecf..77d0bae 100644 --- a/ripple_client_test.go +++ b/ripple_client_test.go @@ -11,16 +11,10 @@ func stringPtr(s string) *string { func createTestConfig() ClientConfig { return ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - Adapters: struct { - HTTPAdapter HTTPAdapter - StorageAdapter StorageAdapter - LoggerAdapter LoggerAdapter - }{ - HTTPAdapter: &mockHTTPAdapter{}, - StorageAdapter: &mockStorageAdapter{}, - }, + APIKey: "test-key", + Endpoint: "http://test.com", + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, } } @@ -32,15 +26,9 @@ func TestClient_ConfigValidation(t *testing.T) { } }() NewClient(ClientConfig{ - Endpoint: "http://test.com", - Adapters: struct { - HTTPAdapter HTTPAdapter - StorageAdapter StorageAdapter - LoggerAdapter LoggerAdapter - }{ - HTTPAdapter: &mockHTTPAdapter{}, - StorageAdapter: &mockStorageAdapter{}, - }, + Endpoint: "http://test.com", + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, }) }) @@ -51,15 +39,9 @@ func TestClient_ConfigValidation(t *testing.T) { } }() NewClient(ClientConfig{ - APIKey: "test-key", - Adapters: struct { - HTTPAdapter HTTPAdapter - StorageAdapter StorageAdapter - LoggerAdapter LoggerAdapter - }{ - HTTPAdapter: &mockHTTPAdapter{}, - StorageAdapter: &mockStorageAdapter{}, - }, + APIKey: "test-key", + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, }) }) @@ -70,15 +52,9 @@ func TestClient_ConfigValidation(t *testing.T) { } }() NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - Adapters: struct { - HTTPAdapter HTTPAdapter - StorageAdapter StorageAdapter - LoggerAdapter LoggerAdapter - }{ - StorageAdapter: &mockStorageAdapter{}, - }, + APIKey: "test-key", + Endpoint: "http://test.com", + StorageAdapter: &mockStorageAdapter{}, }) }) @@ -89,15 +65,9 @@ func TestClient_ConfigValidation(t *testing.T) { } }() NewClient(ClientConfig{ - APIKey: "test-key", - Endpoint: "http://test.com", - Adapters: struct { - HTTPAdapter HTTPAdapter - StorageAdapter StorageAdapter - LoggerAdapter LoggerAdapter - }{ - HTTPAdapter: &mockHTTPAdapter{}, - }, + APIKey: "test-key", + Endpoint: "http://test.com", + HTTPAdapter: &mockHTTPAdapter{}, }) }) } diff --git a/types.go b/types.go index 22fba42..2718e9d 100644 --- a/types.go +++ b/types.go @@ -27,17 +27,15 @@ func (e *HTTPError) Error() string { } type ClientConfig struct { - APIKey string - Endpoint string - APIKeyHeader *string - FlushInterval time.Duration - MaxBatchSize int - MaxRetries int - Adapters struct { - HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter - StorageAdapter StorageAdapter // Required: Custom storage adapter - LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) - } + APIKey string + Endpoint string + APIKeyHeader *string + FlushInterval time.Duration + MaxBatchSize int + MaxRetries int + HTTPAdapter HTTPAdapter // Required: Custom HTTP adapter + StorageAdapter StorageAdapter // Required: Custom storage adapter + LoggerAdapter LoggerAdapter // Optional: Custom logger adapter (default: PrintLoggerAdapter with WARN level) } type DispatcherConfig struct { From 1fa2287e917d46ad3b4c0c90256fb3e96c617f42 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 17:55:01 +0330 Subject: [PATCH 14/23] feat(playground): set debug log level for client --- playground/cmd/client/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/cmd/client/main.go b/playground/cmd/client/main.go index ed50349..807bbe6 100644 --- a/playground/cmd/client/main.go +++ b/playground/cmd/client/main.go @@ -31,7 +31,7 @@ func main() { MaxRetries: 3, HTTPAdapter: adapters.NewNetHTTPAdapter(), StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), - LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelInfo), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), }) if err := client.Init(); err != nil { From 5e5a94796ec586428d9554e8e2a60c15e68c9efb Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 18:08:45 +0330 Subject: [PATCH 15/23] chore: convert all interface{} to any --- ONBOARDING.md | 2 ++ adapters/file_storage_adapter_test.go | 2 +- adapters/http_adapter.go | 2 +- adapters/logger_adapter.go | 8 ++++---- adapters/net_http_adapter.go | 2 +- adapters/net_http_adapter_test.go | 2 +- adapters/noop_logger_adapter.go | 8 ++++---- adapters/print_logger_adapter.go | 8 ++++---- adapters/types.go | 4 ++-- metadata_manager.go | 14 +++++++------- playground/cmd/client/main.go | 18 +++++++++--------- playground/cmd/server/main.go | 6 +++--- ripple_client.go | 8 ++++---- ripple_client_test.go | 4 ++-- 14 files changed, 45 insertions(+), 43 deletions(-) diff --git a/ONBOARDING.md b/ONBOARDING.md index 9481bc4..b193f22 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -587,6 +587,7 @@ See [playground/README.md](./playground/README.md) for E2E testing scenarios. * Single self-contained package * No external dependencies * Clean, predictable API +* Modern Go idioms (use `any` instead of `interface{}`) --- @@ -601,6 +602,7 @@ Following Go best practices: * Examples in `examples/` subdirectory * Single main package name: `ripple` * Adapter interfaces and implementations in `adapters` package +* Use `any` instead of `interface{}` (Go 1.18+ best practice) ### Concurrency Model diff --git a/adapters/file_storage_adapter_test.go b/adapters/file_storage_adapter_test.go index f02599c..7995f46 100644 --- a/adapters/file_storage_adapter_test.go +++ b/adapters/file_storage_adapter_test.go @@ -79,7 +79,7 @@ func TestFileStorageAdapter_SaveMarshalError(t *testing.T) { adapter := NewFileStorageAdapter(filepath) events := []Event{{ Name: "test", - Payload: map[string]interface{}{"invalid": make(chan int)}, + Payload: map[string]any{"invalid": make(chan int)}, }} err := adapter.Save(events) if err == nil { diff --git a/adapters/http_adapter.go b/adapters/http_adapter.go index 4a0e0e1..488249d 100644 --- a/adapters/http_adapter.go +++ b/adapters/http_adapter.go @@ -4,7 +4,7 @@ package adapters type HTTPResponse struct { OK bool Status int - Data interface{} + Data any } // HTTPAdapter is an interface for HTTP communication. diff --git a/adapters/logger_adapter.go b/adapters/logger_adapter.go index aa3f16f..f6ff3bb 100644 --- a/adapters/logger_adapter.go +++ b/adapters/logger_adapter.go @@ -15,11 +15,11 @@ const ( // Implement this interface to use custom loggers. type LoggerAdapter interface { // Debug logs a debug message - Debug(message string, args ...interface{}) + Debug(message string, args ...any) // Info logs an info message - Info(message string, args ...interface{}) + Info(message string, args ...any) // Warn logs a warning message - Warn(message string, args ...interface{}) + Warn(message string, args ...any) // Error logs an error message - Error(message string, args ...interface{}) + Error(message string, args ...any) } diff --git a/adapters/net_http_adapter.go b/adapters/net_http_adapter.go index b331569..2b21d70 100644 --- a/adapters/net_http_adapter.go +++ b/adapters/net_http_adapter.go @@ -24,7 +24,7 @@ func NewNetHTTPAdapter() HTTPAdapter { // Send sends events to the specified endpoint with the given headers. func (h *NetHTTPAdapter) Send(endpoint string, events []Event, headers map[string]string) (*HTTPResponse, error) { - payload := map[string]interface{}{ + payload := map[string]any{ "events": events, } diff --git a/adapters/net_http_adapter_test.go b/adapters/net_http_adapter_test.go index 6593aec..04f0b3c 100644 --- a/adapters/net_http_adapter_test.go +++ b/adapters/net_http_adapter_test.go @@ -70,7 +70,7 @@ func TestNetHTTPAdapter_SendMarshalError(t *testing.T) { adapter := NewNetHTTPAdapter() events := []Event{{ Name: "test", - Payload: map[string]interface{}{"invalid": make(chan int)}, + Payload: map[string]any{"invalid": make(chan int)}, }} _, err := adapter.Send("http://test.com", events, nil) diff --git a/adapters/noop_logger_adapter.go b/adapters/noop_logger_adapter.go index ddaa829..cb51e55 100644 --- a/adapters/noop_logger_adapter.go +++ b/adapters/noop_logger_adapter.go @@ -8,7 +8,7 @@ func NewNoOpLoggerAdapter() *NoOpLoggerAdapter { return &NoOpLoggerAdapter{} } -func (n *NoOpLoggerAdapter) Debug(message string, args ...interface{}) {} -func (n *NoOpLoggerAdapter) Info(message string, args ...interface{}) {} -func (n *NoOpLoggerAdapter) Warn(message string, args ...interface{}) {} -func (n *NoOpLoggerAdapter) Error(message string, args ...interface{}) {} +func (n *NoOpLoggerAdapter) Debug(message string, args ...any) {} +func (n *NoOpLoggerAdapter) Info(message string, args ...any) {} +func (n *NoOpLoggerAdapter) Warn(message string, args ...any) {} +func (n *NoOpLoggerAdapter) Error(message string, args ...any) {} diff --git a/adapters/print_logger_adapter.go b/adapters/print_logger_adapter.go index ae8fa61..add923d 100644 --- a/adapters/print_logger_adapter.go +++ b/adapters/print_logger_adapter.go @@ -25,25 +25,25 @@ func (p *PrintLoggerAdapter) shouldLog(level LogLevel) bool { return levels[level] >= levels[p.level] } -func (p *PrintLoggerAdapter) Debug(message string, args ...interface{}) { +func (p *PrintLoggerAdapter) Debug(message string, args ...any) { if p.shouldLog(LogLevelDebug) { log.Printf("[DEBUG] [Ripple] "+message, args...) } } -func (p *PrintLoggerAdapter) Info(message string, args ...interface{}) { +func (p *PrintLoggerAdapter) Info(message string, args ...any) { if p.shouldLog(LogLevelInfo) { log.Printf("[INFO] [Ripple] "+message, args...) } } -func (p *PrintLoggerAdapter) Warn(message string, args ...interface{}) { +func (p *PrintLoggerAdapter) Warn(message string, args ...any) { if p.shouldLog(LogLevelWarn) { log.Printf("[WARN] [Ripple] "+message, args...) } } -func (p *PrintLoggerAdapter) Error(message string, args ...interface{}) { +func (p *PrintLoggerAdapter) Error(message string, args ...any) { if p.shouldLog(LogLevelError) { log.Printf("[ERROR] [Ripple] "+message, args...) } diff --git a/adapters/types.go b/adapters/types.go index f107b18..228262e 100644 --- a/adapters/types.go +++ b/adapters/types.go @@ -3,10 +3,10 @@ package adapters // Event represents a tracked event. type Event struct { Name string `json:"name"` - Payload map[string]interface{} `json:"payload"` + Payload map[string]any `json:"payload"` Metadata *EventMetadata `json:"metadata"` IssuedAt int64 `json:"issuedAt"` - Context map[string]interface{} `json:"context"` + Context map[string]any `json:"context"` SessionID *string `json:"sessionId"` Platform *Platform `json:"platform"` } diff --git a/metadata_manager.go b/metadata_manager.go index 296e22f..270f7c8 100644 --- a/metadata_manager.go +++ b/metadata_manager.go @@ -4,33 +4,33 @@ import "sync" // MetadataManager manages global metadata attached to all events type MetadataManager struct { - metadata map[string]interface{} + metadata map[string]any mu sync.RWMutex } // NewMetadataManager creates a new metadata manager func NewMetadataManager() *MetadataManager { return &MetadataManager{ - metadata: make(map[string]interface{}), + metadata: make(map[string]any), } } // Set sets a metadata value -func (m *MetadataManager) Set(key string, value interface{}) { +func (m *MetadataManager) Set(key string, value any) { m.mu.Lock() defer m.mu.Unlock() m.metadata[key] = value } // Get gets a metadata value -func (m *MetadataManager) Get(key string) interface{} { +func (m *MetadataManager) Get(key string) any { m.mu.RLock() defer m.mu.RUnlock() return m.metadata[key] } // GetAll returns all metadata as a copy -func (m *MetadataManager) GetAll() map[string]interface{} { +func (m *MetadataManager) GetAll() map[string]any { m.mu.RLock() defer m.mu.RUnlock() @@ -38,7 +38,7 @@ func (m *MetadataManager) GetAll() map[string]interface{} { return nil } - result := make(map[string]interface{}, len(m.metadata)) + result := make(map[string]any, len(m.metadata)) for k, v := range m.metadata { result[k] = v } @@ -56,5 +56,5 @@ func (m *MetadataManager) IsEmpty() bool { func (m *MetadataManager) Clear() { m.mu.Lock() defer m.mu.Unlock() - m.metadata = make(map[string]interface{}) + m.metadata = make(map[string]any) } diff --git a/playground/cmd/client/main.go b/playground/cmd/client/main.go index 807bbe6..1728be7 100644 --- a/playground/cmd/client/main.go +++ b/playground/cmd/client/main.go @@ -124,7 +124,7 @@ func trackSimpleEvent() { func trackEventWithPayload() { fmt.Println("\n📊 Track Event with Payload") - payload := map[string]interface{}{ + payload := map[string]any{ "action": "click", "target": "button", "timestamp": time.Now().Unix(), @@ -135,7 +135,7 @@ func trackEventWithPayload() { func trackEventWithMetadata() { fmt.Println("\n📊 Track Event with Metadata") - payload := map[string]interface{}{ + payload := map[string]any{ "formId": "contact-form", "fields": 5, } @@ -146,7 +146,7 @@ func trackEventWithMetadata() { func trackEventWithCustomMetadata() { fmt.Println("\n📊 Track Event with Custom Metadata") - payload := map[string]interface{}{ + payload := map[string]any{ "orderId": "order-123", "amount": 99.99, } @@ -174,7 +174,7 @@ func trackWithSharedMetadata() { func trackMultipleEvents() { fmt.Println("\n📦 Track Multiple Events (Batch Test)") for i := 0; i < 10; i++ { - payload := map[string]interface{}{"index": i} + payload := map[string]any{"index": i} client.Track("batch_event", payload, nil) } fmt.Println("✅ Tracked 10 events (should auto-flush at batch size 5)\n") @@ -200,7 +200,7 @@ func testInvalidEndpoint() { return } - errorClient.Track("error_test", map[string]interface{}{"shouldFail": true}, nil) + errorClient.Track("error_test", map[string]any{"shouldFail": true}, nil) fmt.Println("✅ Tracked event to invalid endpoint (check console for retries)\n") } @@ -239,10 +239,10 @@ func trackEvent() { name := fmt.Sprintf("event_%d", eventCounter) // Mock sample payload - payload := map[string]interface{}{ + payload := map[string]any{ "action": fmt.Sprintf("action_%d", eventCounter), "timestamp": time.Now().Unix(), - "data": map[string]interface{}{ + "data": map[string]any{ "count": eventCounter, "type": "sample", }, @@ -260,11 +260,11 @@ func trackEventWithError() { name := fmt.Sprintf("error_event_%d", eventCounter) // Payload with error trigger - payload := map[string]interface{}{ + payload := map[string]any{ "action": fmt.Sprintf("error_action_%d", eventCounter), "timestamp": time.Now().Unix(), "trigger_error": true, // This will cause server to return 500 - "data": map[string]interface{}{ + "data": map[string]any{ "count": eventCounter, "type": "error_test", }, diff --git a/playground/cmd/server/main.go b/playground/cmd/server/main.go index 4d3c680..94f2f00 100644 --- a/playground/cmd/server/main.go +++ b/playground/cmd/server/main.go @@ -11,7 +11,7 @@ import ( const PORT = 3000 type EventsPayload struct { - Events []map[string]interface{} `json:"events"` + Events []map[string]any `json:"events"` } func main() { @@ -58,7 +58,7 @@ func main() { // Check for error trigger in any event payload for _, event := range payload.Events { - if eventPayload, ok := event["payload"].(map[string]interface{}); ok { + if eventPayload, ok := event["payload"].(map[string]any); ok { if trigger, exists := eventPayload["trigger_error"]; exists && trigger == true { log.Printf("🔄 Client should retry this request (error triggered)") w.Header().Set("Content-Type", "application/json") @@ -71,7 +71,7 @@ func main() { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]any{ "success": true, "received": len(payload.Events), }) diff --git a/ripple_client.go b/ripple_client.go index d76535d..ba6b523 100644 --- a/ripple_client.go +++ b/ripple_client.go @@ -109,19 +109,19 @@ func (c *Client) Init() error { return nil } -func (c *Client) SetMetadata(key string, value interface{}) { +func (c *Client) SetMetadata(key string, value any) { c.metadataManager.Set(key, value) } -func (c *Client) GetMetadata(key string) interface{} { +func (c *Client) GetMetadata(key string) any { return c.metadataManager.Get(key) } -func (c *Client) GetAllMetadata() map[string]interface{} { +func (c *Client) GetAllMetadata() map[string]any { return c.metadataManager.GetAll() } -func (c *Client) Track(name string, payload map[string]interface{}, metadata *EventMetadata) error { +func (c *Client) Track(name string, payload map[string]any, metadata *EventMetadata) error { c.mu.RLock() initialized := c.initialized c.mu.RUnlock() diff --git a/ripple_client_test.go b/ripple_client_test.go index 77d0bae..f61226b 100644 --- a/ripple_client_test.go +++ b/ripple_client_test.go @@ -247,7 +247,7 @@ func TestClient_Track(t *testing.T) { defer client.Dispose() client.SetMetadata("userId", "123") - client.Track("page_view", map[string]interface{}{"page": "/home"}, nil) + client.Track("page_view", map[string]any{"page": "/home"}, nil) time.Sleep(100 * time.Millisecond) @@ -270,7 +270,7 @@ func TestClient_TrackWithMetadata(t *testing.T) { defer client.Dispose() metadata := &EventMetadata{SchemaVersion: stringPtr("1.0.0")} - client.Track("user_signup", map[string]interface{}{"email": "test@example.com"}, metadata) + client.Track("user_signup", map[string]any{"email": "test@example.com"}, metadata) time.Sleep(100 * time.Millisecond) From 1d9a2910318f596f9024e75c0d50258648e1e659 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 18:21:09 +0330 Subject: [PATCH 16/23] fix: remove all the panics in the code --- ONBOARDING.md | 26 ++++++++-- README.md | 15 ++++-- playground/cmd/client/main.go | 15 +++++- ripple_client.go | 10 ++-- ripple_client_test.go | 96 ++++++++++++++++++++--------------- 5 files changed, 106 insertions(+), 56 deletions(-) diff --git a/ONBOARDING.md b/ONBOARDING.md index b193f22..529e557 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -15,6 +15,11 @@ - Added `StopWithoutFlush()` and `DisposeWithoutFlush()` methods for graceful shutdown without flushing events - Fixed playground client exit behavior to persist events without sending to server +### Error Handling Improvement +- Changed `NewClient()` to return `(*Client, error)` instead of panicking on invalid configuration +- Libraries should never panic as it crashes the entire application and can't be handled by users +- Configuration validation errors are now properly returnable and handleable + ## Project Overview Ripple Go is a high-performance, scalable, and fault-tolerant event tracking SDK implemented as a single Go package. It provides reliable event delivery, batching, retries, persistence, and graceful shutdown for server-side applications. @@ -353,12 +358,15 @@ import ( "github.com/Tap30/ripple-go/adapters" ) -client := ripple.NewClient(ripple.ClientConfig{ +client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", HTTPAdapter: adapters.NewNetHTTPAdapter(), StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) +if err != nil { + panic(err) +} // Initialize client (required before tracking) if err := client.Init(); err != nil { @@ -415,7 +423,7 @@ err := client.Track("user_signup", map[string]interface{}{ ### Custom Configuration ```go -client := ripple.NewClient(ripple.ClientConfig{ +client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", APIKeyHeader: stringPtr("Authorization"), // Custom header name @@ -426,6 +434,9 @@ client := ripple.NewClient(ripple.ClientConfig{ StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), }) +if err != nil { + panic(err) +} ``` ### Custom Adapters @@ -443,12 +454,15 @@ func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers m } // Usage -client := ripple.NewClient(ripple.ClientConfig{ +client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", HTTPAdapter: &MyHTTPAdapter{}, StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) +if err != nil { + panic(err) +} ``` #### Custom Logger Adapter @@ -477,13 +491,16 @@ func (l *MyLoggerAdapter) Error(message string, args ...interface{}) { } // Usage -client := ripple.NewClient(ripple.ClientConfig{ +client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", HTTPAdapter: adapters.NewNetHTTPAdapter(), StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), LoggerAdapter: &MyLoggerAdapter{logger: log.New(os.Stdout, "", log.LstdFlags)}, }) +if err != nil { + panic(err) +} ``` ### Custom Storage Adapter @@ -581,6 +598,7 @@ See [playground/README.md](./playground/README.md) for E2E testing scenarios. * Persistent queueing * Retried delivery with backoff * Safe process shutdown +* Proper error handling (no panics in library code) ### Simplicity diff --git a/README.md b/README.md index 199fb03..7b0e3b3 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,15 @@ import ( ) func main() { - client := ripple.NewClient(ripple.ClientConfig{ + client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", HTTPAdapter: adapters.NewNetHTTPAdapter(), StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) + if err != nil { + panic(err) + } if err := client.Init(); err != nil { panic(err) @@ -131,12 +134,15 @@ func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers m } // Use custom adapter -client := ripple.NewClient(ripple.ClientConfig{ +client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", HTTPAdapter: &MyHTTPAdapter{}, StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), }) +if err != nil { + panic(err) +} client.Init() ``` @@ -170,12 +176,15 @@ func (r *RedisStorage) Clear() error { } // Use custom adapter -client := ripple.NewClient(ripple.ClientConfig{ +client, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "your-api-key", Endpoint: "https://api.example.com/events", HTTPAdapter: adapters.NewNetHTTPAdapter(), StorageAdapter: &RedisStorage{}, }) +if err != nil { + panic(err) +} client.Init() ``` diff --git a/playground/cmd/client/main.go b/playground/cmd/client/main.go index 1728be7..075a3af 100644 --- a/playground/cmd/client/main.go +++ b/playground/cmd/client/main.go @@ -23,7 +23,8 @@ var eventCounter int func main() { scanner = bufio.NewScanner(os.Stdin) - client = ripple.NewClient(ripple.ClientConfig{ + var err error + client, err = ripple.NewClient(ripple.ClientConfig{ APIKey: "test-api-key", Endpoint: "http://localhost:3000/events", FlushInterval: 5 * time.Second, @@ -34,6 +35,11 @@ func main() { LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), }) + if err != nil { + fmt.Printf("❌ Failed to create client: %v\n", err) + return + } + if err := client.Init(); err != nil { fmt.Printf("❌ Failed to initialize client: %v\n", err) return @@ -184,7 +190,7 @@ func testInvalidEndpoint() { fmt.Println("\n⚠️ Test Invalid Endpoint") // Create a new client with invalid endpoint - errorClient := ripple.NewClient(ripple.ClientConfig{ + errorClient, err := ripple.NewClient(ripple.ClientConfig{ APIKey: "test-key", Endpoint: "http://localhost:9999/invalid", FlushInterval: 5 * time.Second, @@ -195,6 +201,11 @@ func testInvalidEndpoint() { LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn), }) + if err != nil { + fmt.Printf("❌ Failed to create error client: %v\n\n", err) + return + } + if err := errorClient.Init(); err != nil { fmt.Printf("❌ Failed to init error client: %v\n\n", err) return diff --git a/ripple_client.go b/ripple_client.go index ba6b523..7f65841 100644 --- a/ripple_client.go +++ b/ripple_client.go @@ -19,16 +19,16 @@ type Client struct { mu sync.RWMutex } -func NewClient(config ClientConfig) *Client { +func NewClient(config ClientConfig) (*Client, error) { // Validate required fields if config.APIKey == "" { - panic("apiKey must be provided in config") + return nil, errors.New("apiKey must be provided in config") } if config.Endpoint == "" { - panic("endpoint must be provided in config") + return nil, errors.New("endpoint must be provided in config") } if config.HTTPAdapter == nil || config.StorageAdapter == nil { - panic("Both HTTPAdapter and StorageAdapter must be provided in config") + return nil, errors.New("both HTTPAdapter and StorageAdapter must be provided in config") } // Set defaults @@ -56,7 +56,7 @@ func NewClient(config ClientConfig) *Client { client.loggerAdapter = adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn) } - return client + return client, nil } // SetHTTPAdapter sets a custom HTTP adapter. diff --git a/ripple_client_test.go b/ripple_client_test.go index f61226b..139ecb0 100644 --- a/ripple_client_test.go +++ b/ripple_client_test.go @@ -18,62 +18,74 @@ func createTestConfig() ClientConfig { } } +func createTestClient() *Client { + client, err := NewClient(createTestConfig()) + if err != nil { + panic(err) // Only panic in tests + } + return client +} + func TestClient_ConfigValidation(t *testing.T) { - t.Run("should panic if APIKey is missing", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for missing APIKey") - } - }() - NewClient(ClientConfig{ + t.Run("should return error if APIKey is missing", func(t *testing.T) { + _, err := NewClient(ClientConfig{ Endpoint: "http://test.com", HTTPAdapter: &mockHTTPAdapter{}, StorageAdapter: &mockStorageAdapter{}, }) + if err == nil { + t.Fatal("expected error for missing APIKey") + } + if err.Error() != "apiKey must be provided in config" { + t.Fatalf("unexpected error message: %v", err) + } }) - t.Run("should panic if Endpoint is missing", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for missing Endpoint") - } - }() - NewClient(ClientConfig{ + t.Run("should return error if Endpoint is missing", func(t *testing.T) { + _, err := NewClient(ClientConfig{ APIKey: "test-key", HTTPAdapter: &mockHTTPAdapter{}, StorageAdapter: &mockStorageAdapter{}, }) + if err == nil { + t.Fatal("expected error for missing Endpoint") + } + if err.Error() != "endpoint must be provided in config" { + t.Fatalf("unexpected error message: %v", err) + } }) - t.Run("should panic if HTTPAdapter is missing", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for missing HTTPAdapter") - } - }() - NewClient(ClientConfig{ + t.Run("should return error if HTTPAdapter is missing", func(t *testing.T) { + _, err := NewClient(ClientConfig{ APIKey: "test-key", Endpoint: "http://test.com", StorageAdapter: &mockStorageAdapter{}, }) + if err == nil { + t.Fatal("expected error for missing HTTPAdapter") + } + if err.Error() != "both HTTPAdapter and StorageAdapter must be provided in config" { + t.Fatalf("unexpected error message: %v", err) + } }) - t.Run("should panic if StorageAdapter is missing", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for missing StorageAdapter") - } - }() - NewClient(ClientConfig{ + t.Run("should return error if StorageAdapter is missing", func(t *testing.T) { + _, err := NewClient(ClientConfig{ APIKey: "test-key", Endpoint: "http://test.com", HTTPAdapter: &mockHTTPAdapter{}, }) + if err == nil { + t.Fatal("expected error for missing StorageAdapter") + } + if err.Error() != "both HTTPAdapter and StorageAdapter must be provided in config" { + t.Fatalf("unexpected error message: %v", err) + } }) } func TestClient_InitializationValidation(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() t.Run("should return error if Track called before Init", func(t *testing.T) { err := client.Track("test_event", nil, nil) @@ -105,7 +117,7 @@ func TestClient_InitializationValidation(t *testing.T) { } func TestClient_MetadataManagement(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() t.Run("should set and get metadata", func(t *testing.T) { client.SetMetadata("userId", "123") @@ -130,7 +142,7 @@ func TestClient_MetadataManagement(t *testing.T) { }) t.Run("should return nil when no metadata is set", func(t *testing.T) { - newClient := NewClient(createTestConfig()) + newClient := createTestClient() metadata := newClient.GetAllMetadata() if metadata != nil { @@ -141,7 +153,7 @@ func TestClient_MetadataManagement(t *testing.T) { func TestClient_FlushEdgeCases(t *testing.T) { t.Run("should work with empty queue", func(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -158,7 +170,7 @@ func TestClient_FlushEdgeCases(t *testing.T) { }) t.Run("should work before initialization", func(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() // Should not panic when called before init client.Flush() @@ -167,7 +179,7 @@ func TestClient_FlushEdgeCases(t *testing.T) { func TestClient_DisposeEdgeCases(t *testing.T) { t.Run("should work before initialization", func(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() // Should not panic when called before init err := client.Dispose() @@ -177,7 +189,7 @@ func TestClient_DisposeEdgeCases(t *testing.T) { }) t.Run("should work multiple times", func(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -195,7 +207,7 @@ func TestClient_DisposeEdgeCases(t *testing.T) { } func TestClient_DisposeWithoutFlush(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -222,7 +234,7 @@ func TestClient_DisposeWithoutFlush(t *testing.T) { } func TestClient_SetGetMetadata(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() client.SetMetadata("userId", "123") client.SetMetadata("appVersion", "1.0.0") @@ -234,7 +246,7 @@ func TestClient_SetGetMetadata(t *testing.T) { } func TestClient_Track(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -257,7 +269,7 @@ func TestClient_Track(t *testing.T) { } func TestClient_TrackWithMetadata(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -280,7 +292,7 @@ func TestClient_TrackWithMetadata(t *testing.T) { } func TestClient_Flush(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() mockHTTP := &mockHTTPAdapter{} mockStorage := &mockStorageAdapter{} @@ -301,7 +313,7 @@ func TestClient_Flush(t *testing.T) { } func TestClient_DefaultConfig(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() if client.config.FlushInterval != 5*time.Second { t.Fatal("expected default flush interval of 5s") @@ -315,7 +327,7 @@ func TestClient_DefaultConfig(t *testing.T) { } func TestClient_SetCustomAdapters(t *testing.T) { - client := NewClient(createTestConfig()) + client := createTestClient() customHTTP := &mockHTTPAdapter{} customStorage := &mockStorageAdapter{} From 3d43f99d086d5a6fcfe5a938633359be331e5506 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 18:39:44 +0330 Subject: [PATCH 17/23] feat(dispatcher): improve performance --- dispatcher.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dispatcher.go b/dispatcher.go index 2c6050c..e5b9fca 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -1,7 +1,6 @@ package ripple import ( - "math" "math/rand" "sync" "time" @@ -137,7 +136,7 @@ func (d *Dispatcher) sendWithRetry(events []Event) error { } if attempt < d.config.MaxRetries { - backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second + backoff := time.Duration(1< Date: Sun, 21 Dec 2025 18:51:36 +0330 Subject: [PATCH 18/23] fix(dispatcher): stop timer when the queue is empty --- dispatcher.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/dispatcher.go b/dispatcher.go index e5b9fca..6dcee44 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -85,8 +85,25 @@ func (d *Dispatcher) startTimerIfNeeded() { } } +func (d *Dispatcher) stopTimerIfEmpty() { + d.timerMu.Lock() + defer d.timerMu.Unlock() + + if d.timerStarted && d.queue.IsEmpty() { + d.ticker.Stop() + d.timerStarted = false + d.loggerAdapter.Debug("Timer stopped - queue is empty") + } +} + func (d *Dispatcher) Flush() { d.flushMutex.RunAtomic(func() error { + // Early return if queue is empty + if d.queue.IsEmpty() { + d.stopTimerIfEmpty() + return nil + } + d.loggerAdapter.Debug("Starting flush operation") for !d.queue.IsEmpty() { @@ -113,6 +130,9 @@ func (d *Dispatcher) Flush() { d.loggerAdapter.Debug("Successfully sent batch of %d events", len(batch)) } } + + // Stop timer if queue is now empty + d.stopTimerIfEmpty() return nil }) } From 88c8ddf4398771612607145f06b799c7b19f4d8a Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 18:59:46 +0330 Subject: [PATCH 19/23] chore: upgrade go version --- ONBOARDING.md | 16 ++++++++++++++++ dispatcher.go | 6 ++---- go.mod | 2 +- playground/go.mod | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/ONBOARDING.md b/ONBOARDING.md index 529e557..284b23f 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -8,6 +8,7 @@ ### Timer Behavior Enhancement - Timer now only starts when first new event is tracked, not during SDK initialization +- Timer automatically stops when queue becomes empty to save CPU cycles and reduce log noise - If persisted events exist, they remain in queue until a new event triggers the timer - Maintains same API while improving efficiency for apps with persisted events @@ -20,6 +21,11 @@ - Libraries should never panic as it crashes the entire application and can't be handled by users - Configuration validation errors are now properly returnable and handleable +### Go Version Upgrade +- Upgraded from Go 1.23 to Go 1.25 +- Replaced manual `wg.Add(1)` + `go func()` + `defer wg.Done()` with cleaner `wg.Go()` method +- Reduces boilerplate code and eliminates WaitGroup management errors + ## Project Overview Ripple Go is a high-performance, scalable, and fault-tolerant event tracking SDK implemented as a single Go package. It provides reliable event delivery, batching, retries, persistence, and graceful shutdown for server-side applications. @@ -727,9 +733,19 @@ The SDK follows a framework-agnostic design and API contract defined in the main ### Timer Behavior Enhancement - Timer now only starts when first new event is tracked, not during SDK initialization +- Timer automatically stops when queue becomes empty to save CPU cycles and reduce log noise - If persisted events exist, they remain in queue until a new event triggers the timer - Maintains same API while improving efficiency for apps with persisted events ### Graceful Shutdown Enhancement - Added `StopWithoutFlush()` and `DisposeWithoutFlush()` methods for graceful shutdown without flushing events - Fixed playground client exit behavior to persist events without sending to server + +### Error Handling Improvement +- Changed `NewClient()` to return `(*Client, error)` instead of panicking on invalid configuration +- Libraries should never panic as it crashes the entire application and can't be handled by users +- Configuration validation errors are now properly returnable and handleable +### Go Version Upgrade +- Upgraded from Go 1.23 to Go 1.25 +- Replaced manual `wg.Add(1)` + `go func()` + `defer wg.Done()` with cleaner `wg.Go()` method +- Reduces boilerplate code and eliminates WaitGroup management errors diff --git a/dispatcher.go b/dispatcher.go index 6dcee44..a5e2b46 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -70,9 +70,7 @@ func (d *Dispatcher) startTimerIfNeeded() { if !d.timerStarted { d.ticker = time.NewTicker(d.config.FlushInterval) d.timerStarted = true - d.wg.Add(1) - go func() { - defer d.wg.Done() + d.wg.Go(func() { for { select { case <-d.ticker.C: @@ -81,7 +79,7 @@ func (d *Dispatcher) startTimerIfNeeded() { return } } - }() + }) } } diff --git a/go.mod b/go.mod index ff843b3..6d230a8 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/Tap30/ripple-go -go 1.23 +go 1.25 diff --git a/playground/go.mod b/playground/go.mod index d711a69..c20e3fa 100644 --- a/playground/go.mod +++ b/playground/go.mod @@ -1,6 +1,6 @@ module playground -go 1.23 +go 1.25 require github.com/Tap30/ripple-go v0.0.0 From 5c77daf8472a92d7aabc15df82c34dab93b82d98 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 19:10:39 +0330 Subject: [PATCH 20/23] ci: add development workflow --- .github/workflows/development.yml | 90 +++++++++++++++++++++++++++++++ ONBOARDING.md | 32 +++++++++++ adapters/types.go | 10 ++-- 3 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/development.yml diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000..ae0d8a1 --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,90 @@ +name: Development + +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + contents: read + pull-requests: write + +jobs: + test: + name: "Unit Tests" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: "☁️ Checkout repository" + uses: actions/checkout@v4 + + - name: "🔧 Setup Go" + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: "📦 Download dependencies" + run: go mod download + + - name: "🔍 Run tests" + run: go test ./... + + - name: "📊 Run tests with coverage" + run: go test -cover ./... + + lint: + name: "Lint Code" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: "☁️ Checkout repository" + uses: actions/checkout@v4 + + - name: "🔧 Setup Go" + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: "📦 Download dependencies" + run: go mod download + + - name: "🔍 Run go vet" + run: go vet ./... + + - name: "🔍 Run go fmt check" + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "The following files are not formatted:" + gofmt -s -l . + exit 1 + fi + + build: + name: "Build Check" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: "☁️ Checkout repository" + uses: actions/checkout@v4 + + - name: "🔧 Setup Go" + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: "📦 Download dependencies" + run: go mod download + + - name: "🔨 Build all packages" + run: go build ./... + + - name: "🔨 Build playground" + run: | + cd playground + go build ./... diff --git a/ONBOARDING.md b/ONBOARDING.md index 284b23f..8da8a3a 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -525,6 +525,21 @@ func (r *RedisStorage) Clear() error { /* ... */ return ni ## Development Workflow +### CI/CD Pipeline + +The project uses GitHub Actions for continuous integration on all pull requests: + +**Workflow File**: `.github/workflows/development.yml` + +**Jobs**: +- **Unit Tests** - Runs `go test ./...` and `go test -cover ./...` +- **Lint Code** - Runs `go vet ./...` and `gofmt -s -l .` formatting check +- **Build Check** - Verifies `go build ./...` succeeds for all packages including playground + +**Triggers**: Pull request events (opened, edited, synchronize, reopened) + +**Requirements**: All jobs must pass before PR can be merged + ### Development Commands Use the root Makefile for common development tasks: @@ -582,6 +597,23 @@ See [playground/README.md](./playground/README.md) for E2E testing scenarios. * Benchmarks for high-volume event throughput * Linting via `golangci-lint` +### Contributing Guidelines + +**Pull Request Requirements**: +- All CI checks must pass (tests, linting, build) +- Code must be formatted with `gofmt` +- Tests must pass with coverage +- No `go vet` warnings allowed + +**Local Development**: +```bash +# Run the same checks as CI +make test # Run tests +make fmt # Format code +make lint # Run go vet +go build ./... # Verify build +``` + --- ## Design Principles diff --git a/adapters/types.go b/adapters/types.go index 228262e..eb214d6 100644 --- a/adapters/types.go +++ b/adapters/types.go @@ -2,13 +2,13 @@ package adapters // Event represents a tracked event. type Event struct { - Name string `json:"name"` + Name string `json:"name"` Payload map[string]any `json:"payload"` - Metadata *EventMetadata `json:"metadata"` - IssuedAt int64 `json:"issuedAt"` + Metadata *EventMetadata `json:"metadata"` + IssuedAt int64 `json:"issuedAt"` Context map[string]any `json:"context"` - SessionID *string `json:"sessionId"` - Platform *Platform `json:"platform"` + SessionID *string `json:"sessionId"` + Platform *Platform `json:"platform"` } // EventMetadata contains optional event metadata. From 70e3daa342eee4bbd5b718cc92e4032a80b9ef06 Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Sun, 21 Dec 2025 19:22:35 +0330 Subject: [PATCH 21/23] docs: add issue and pr templates --- .github/ISSUE_TEMPLATE/bug_report.md | 29 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 19 +++++++++++++++ .github/pull_request_template.md | 18 ++++++++++++++ ONBOARDING.md | 15 ++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e5ab496 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** A clear and concise description of what the bug is. + +**To Reproduce** Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** A clear and concise description of what you expected to +happen. + +**Screenshots** If applicable, add screenshots to help explain your problem. + +**Environment (please complete the following information):** + +- OS: [e.g. macOS, Linux, Windows] +- Go Version: [e.g. 1.25] +- SDK Version: [e.g. v1.0.0] + +**Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7c87193 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** A clear and +concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** A clear and concise description of what you +want to happen. + +**Describe alternatives you've considered** A clear and concise description of +any alternative solutions or features you've considered. + +**Additional context** Add any other context or screenshots about the feature +request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..3ddb647 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ + + +## Bug + +- [ ] Related issues linked using `fixes #number` +- [ ] Tests added + +## Feature + +- [ ] Implements an existing feature request or RFC. Make sure the feature + request has been accepted for implementation before opening a PR. +- [ ] Related issues linked using `fixes #number` +- [ ] Tests added +- [ ] Documentation added diff --git a/ONBOARDING.md b/ONBOARDING.md index 8da8a3a..8563be7 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -614,6 +614,21 @@ make lint # Run go vet go build ./... # Verify build ``` +### GitHub Templates + +The project includes GitHub templates to ensure consistent contributions: + +**Pull Request Template** (`.github/pull_request_template.md`): +- Provides checklists for Bug and Feature PRs +- Ensures proper issue linking with `fixes #number` +- Requires tests and documentation for new features + +**Issue Templates** (`.github/ISSUE_TEMPLATE/`): +- **Bug Report** (`bug_report.md`) - Structured template for reporting bugs with Go-specific environment details (OS, Go version, SDK version) +- **Feature Request** (`feature_request.md`) - Template for suggesting new features with problem description and proposed solutions + +These templates automatically appear when users create issues or pull requests, ensuring high-quality contributions and comprehensive bug reports. + --- ## Design Principles From 983760ea08c26beb2307a45f37e4ea6a9775801d Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Mon, 22 Dec 2025 15:14:00 +0330 Subject: [PATCH 22/23] ci: add goreleaser --- .github/workflows/development.yml | 27 ++------ .github/workflows/release.yml | 33 ++++++++++ .gitignore | 2 + .goreleaser.yaml | 104 ++++++++++++++++++++++++++++++ Makefile | 69 +++++++++++++++++--- ONBOARDING.md | 75 ++++++++++++++++++--- 6 files changed, 273 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index ae0d8a1..d7f8b1d 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -30,11 +30,8 @@ jobs: - name: "📦 Download dependencies" run: go mod download - - name: "🔍 Run tests" - run: go test ./... - - - name: "📊 Run tests with coverage" - run: go test -cover ./... + - name: "🔍 Run tests with coverage" + run: make test-cover lint: name: "Lint Code" @@ -53,16 +50,11 @@ jobs: - name: "📦 Download dependencies" run: go mod download - - name: "🔍 Run go vet" - run: go vet ./... + - name: "🔍 Check code formatting" + run: make fmt-check - - name: "🔍 Run go fmt check" - run: | - if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then - echo "The following files are not formatted:" - gofmt -s -l . - exit 1 - fi + - name: "🔍 Run linter" + run: make lint build: name: "Build Check" @@ -82,9 +74,4 @@ jobs: run: go mod download - name: "🔨 Build all packages" - run: go build ./... - - - name: "🔨 Build playground" - run: | - cd playground - go build ./... + run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ddc5929 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: "☁️ Checkout repository" + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: "🔧 Setup Go" + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: "🚀 Run GoReleaser" + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index add30f7..90e4d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ error_events.json # Playground binaries playground/client playground/server +# Added by goreleaser init: +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..a3a3aea --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,104 @@ +# GoReleaser configuration for Ripple Go SDK +# Documentation: https://goreleaser.com + +version: 2 + +project_name: ripple-go + +before: + hooks: + - go mod tidy + - go test ./... + - go vet ./... + +# Since this is a library, we don't need to build binaries +# Instead, we'll focus on source code releases +builds: + - skip: true + +archives: + - formats: ["tar.gz"] + format_overrides: + - goos: windows + formats: ["zip"] + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + files: + - README.md + - ONBOARDING.md + - LICENSE* + - CHANGELOG* + - "*.go" + - "go.mod" + - "go.sum" + - "adapters/**" + - "playground/**" + - "examples/**" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" + - "^style:" + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: Bug Fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Performance Improvements + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: Others + order: 999 + +release: + github: + owner: Tap30 + name: ripple-go + draft: false + prerelease: auto + mode: replace + header: | + ## Ripple Go SDK {{ .Tag }} + + A fast, resilient, and scalable event-tracking SDK built in Go. + + ### Installation + + ```bash + go get github.com/Tap30/ripple-go@{{ .Tag }} + ``` + footer: | + ## Full Changelog + + **Full Changelog**: https://github.com/Tap30/ripple-go/compare/{{ .PreviousTag }}...{{ .Tag }} + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser) 🚀 + +# Generate checksums for release assets +checksum: + name_template: 'checksums.txt' + +# Create source code archives +source: + enabled: true + name_template: '{{ .ProjectName }}_{{ .Version }}_source' + format: tar.gz + +# Announce releases +announce: + skip: '{{gt .Patch 0}}' diff --git a/Makefile b/Makefile index 11e9d46..0e4c225 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -GO := /usr/local/go/bin/go +GO := go -.PHONY: test fmt lint clean help +.PHONY: test test-cover fmt lint clean build check release-test release help +# Testing test: @echo "Running tests..." $(GO) test ./... @@ -10,24 +11,76 @@ test-cover: @echo "Running tests with coverage..." $(GO) test -cover ./... +# Code quality fmt: @echo "Formatting code..." - /usr/local/go/bin/gofmt -w . + gofmt -s -w . + +fmt-check: + @echo "Checking code formatting..." + @if [ "$$(gofmt -s -l . | wc -l)" -gt 0 ]; then \ + echo "The following files are not formatted:"; \ + gofmt -s -l .; \ + exit 1; \ + fi lint: @echo "Running linter..." $(GO) vet ./... +# Building +build: + @echo "Building all packages..." + $(GO) build ./... + +# CI checks (same as GitHub Actions) +check: fmt-check lint test build + @echo "All checks passed!" + +# Release management +release-test: + @echo "Testing release configuration..." + goreleaser check + goreleaser release --snapshot --clean + +release: + @echo "Creating release..." + goreleaser release --clean + +# Cleanup clean: @echo "Cleaning up..." $(GO) clean ./... rm -f coverage.out + rm -rf dist/ @echo "Done!" +# Development +dev-deps: + @echo "Installing development dependencies..." + go install github.com/goreleaser/goreleaser@latest + +# Help help: @echo "Available commands:" - @echo " make test - Run all tests" - @echo " make test-cover - Run tests with coverage" - @echo " make fmt - Format all Go files" - @echo " make lint - Run go vet linter" - @echo " make clean - Clean build artifacts" + @echo "" + @echo "Testing:" + @echo " make test - Run all tests" + @echo " make test-cover - Run tests with coverage" + @echo "" + @echo "Code Quality:" + @echo " make fmt - Format all Go files" + @echo " make fmt-check - Check if code is formatted" + @echo " make lint - Run go vet linter" + @echo "" + @echo "Building:" + @echo " make build - Build all packages" + @echo "" + @echo "CI/CD:" + @echo " make check - Run all CI checks (fmt, lint, test, build)" + @echo " make release-test - Test release configuration" + @echo " make release - Create actual release" + @echo "" + @echo "Development:" + @echo " make dev-deps - Install development dependencies" + @echo " make clean - Clean build artifacts" diff --git a/ONBOARDING.md b/ONBOARDING.md index 8563be7..be2dda3 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -532,26 +532,45 @@ The project uses GitHub Actions for continuous integration on all pull requests: **Workflow File**: `.github/workflows/development.yml` **Jobs**: -- **Unit Tests** - Runs `go test ./...` and `go test -cover ./...` -- **Lint Code** - Runs `go vet ./...` and `gofmt -s -l .` formatting check -- **Build Check** - Verifies `go build ./...` succeeds for all packages including playground +- **Unit Tests** - Runs `make test` and `make test-cover` +- **Lint Code** - Runs `make fmt-check` and `make lint` +- **Build Check** - Runs `make build` **Triggers**: Pull request events (opened, edited, synchronize, reopened) **Requirements**: All jobs must pass before PR can be merged +**Benefits**: Uses Makefile commands for consistency - modify behavior by updating only the Makefile + ### Development Commands Use the root Makefile for common development tasks: ```bash -make test # Run all tests -make test-cover # Run tests with coverage -make fmt # Format all Go files -make lint # Run go vet linter -make clean # Clean build artifacts +# Testing +make test # Run all tests +make test-cover # Run tests with coverage + +# Code Quality +make fmt # Format all Go files +make fmt-check # Check if code is formatted +make lint # Run go vet linter + +# Building +make build # Build all packages + +# CI/CD +make check # Run all CI checks (fmt, lint, test, build) +make release-test # Test release configuration +make release # Create actual release + +# Development +make dev-deps # Install development dependencies (goreleaser) +make clean # Clean build artifacts and release files ``` +The `make check` command runs the same validation as GitHub Actions CI, ensuring local development consistency. + ### Testing The project includes test files for every component: @@ -608,10 +627,11 @@ See [playground/README.md](./playground/README.md) for E2E testing scenarios. **Local Development**: ```bash # Run the same checks as CI +make check # All CI checks in one command make test # Run tests make fmt # Format code make lint # Run go vet -go build ./... # Verify build +make build # Verify build ``` ### GitHub Templates @@ -629,6 +649,43 @@ The project includes GitHub templates to ensure consistent contributions: These templates automatically appear when users create issues or pull requests, ensuring high-quality contributions and comprehensive bug reports. +### Release Process + +The project uses [GoReleaser](https://goreleaser.com) for automated releases: + +**Configuration**: `.goreleaser.yaml` +- **Library-focused**: Skips binary builds, focuses on source code releases +- **Multi-platform archives**: Creates tar.gz (Linux/macOS) and zip (Windows) archives +- **Comprehensive changelog**: Groups commits by type (Features, Bug Fixes, Performance) +- **Source archives**: Includes all source files, documentation, and examples + +**Release Workflow** (`.github/workflows/release.yml`): +- **Trigger**: Push tags matching `v*` pattern (e.g., `v1.0.0`) +- **Process**: Runs tests, builds archives, generates changelog, creates GitHub release +- **Assets**: Source archives, checksums, and release notes + +**Creating a Release**: +```bash +# Tag a new version +git tag v1.0.0 +git push origin v1.0.0 + +# GitHub Actions will automatically: +# 1. Run tests and validation +# 2. Create release archives +# 3. Generate changelog +# 4. Publish GitHub release +``` + +**Local Testing**: +```bash +# Test release configuration +goreleaser check + +# Create snapshot release (no publishing) +goreleaser release --snapshot --clean +``` + --- ## Design Principles From 324d75b62659352ac14a11b420ac0ce12d37b85f Mon Sep 17 00:00:00 2001 From: Amirhossein Alibakhshi Date: Mon, 22 Dec 2025 19:20:02 +0330 Subject: [PATCH 23/23] chore: resolve comments --- .github/workflows/release.yml | 33 +++++++++++++++++++++++++---- ONBOARDING.md | 26 +++++++++++++++-------- ripple_client.go | 2 +- scripts/extract-version.sh | 40 +++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 14 deletions(-) create mode 100755 scripts/extract-version.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddc5929..c0ab11d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,16 @@ name: Release on: - push: - tags: - - 'v*' + pull_request: + types: [closed] + branches: [main] permissions: contents: write jobs: - goreleaser: + auto-release: + if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - name: "☁️ Checkout repository" @@ -17,13 +18,37 @@ jobs: with: fetch-depth: 0 + - name: "🔍 Extract version from branch name" + id: extract-version + run: ./scripts/extract-version.sh "${{ github.event.pull_request.head.ref }}" + - name: "🔧 Setup Go" + if: steps.extract-version.outputs.should_release == 'true' uses: actions/setup-go@v5 with: go-version: '1.25' cache: true + - name: "📦 Download dependencies" + if: steps.extract-version.outputs.should_release == 'true' + run: go mod download + + - name: "🔍 Run tests before release" + if: steps.extract-version.outputs.should_release == 'true' + run: make test-cover + + - name: "🏷️ Create and push tag" + if: steps.extract-version.outputs.should_release == 'true' + run: | + VERSION="${{ steps.extract-version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + echo "Created and pushed tag: $VERSION" + - name: "🚀 Run GoReleaser" + if: steps.extract-version.outputs.should_release == 'true' uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser diff --git a/ONBOARDING.md b/ONBOARDING.md index be2dda3..e9e2663 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -660,21 +660,29 @@ The project uses [GoReleaser](https://goreleaser.com) for automated releases: - **Source archives**: Includes all source files, documentation, and examples **Release Workflow** (`.github/workflows/release.yml`): -- **Trigger**: Push tags matching `v*` pattern (e.g., `v1.0.0`) +- **Trigger**: Merge PR from branch matching `release/x.x.x` pattern (e.g., `release/1.0.0`) - **Process**: Runs tests, builds archives, generates changelog, creates GitHub release - **Assets**: Source archives, checksums, and release notes **Creating a Release**: ```bash -# Tag a new version -git tag v1.0.0 -git push origin v1.0.0 - +# Create release branch with version in name +git checkout -b release/0.0.1 # Stable release +# or +git checkout -b release/1.0.0-rc # Release candidate +# or +git checkout -b release/2.0.0-beta # Beta release + +# Make any final changes, update version references, etc. +git commit -m "Prepare release" +git push origin release/0.0.1 + +# Create and merge PR from release/x.x.x to main # GitHub Actions will automatically: -# 1. Run tests and validation -# 2. Create release archives -# 3. Generate changelog -# 4. Publish GitHub release +# 1. Extract version from branch name +# 2. Run tests and validation +# 3. Create and push tag (e.g., v0.0.1, v1.0.0-rc, v2.0.0-beta) +# 4. Generate changelog and publish GitHub release ``` **Local Testing**: diff --git a/ripple_client.go b/ripple_client.go index 7f65841..2fafa1b 100644 --- a/ripple_client.go +++ b/ripple_client.go @@ -35,7 +35,7 @@ func NewClient(config ClientConfig) (*Client, error) { if config.FlushInterval == 0 { config.FlushInterval = 5 * time.Second } - if config.MaxBatchSize == 0 { + if !(config.MaxBatchSize > 0) { config.MaxBatchSize = 10 } if config.MaxRetries == 0 { diff --git a/scripts/extract-version.sh b/scripts/extract-version.sh new file mode 100755 index 0000000..f5184ee --- /dev/null +++ b/scripts/extract-version.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Extract version from branch name for release automation +# Usage: ./scripts/extract-version.sh + +set -e + +BRANCH_NAME="$1" + +if [ -z "$BRANCH_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Branch Name: $BRANCH_NAME" + +# Check if branch matches release/x.x.x or release/x.x.x-suffix pattern +if [[ $BRANCH_NAME =~ ^release/([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + + # Set GitHub Actions outputs if running in CI + if [ -n "$GITHUB_OUTPUT" ]; then + echo "version=v$VERSION" >> $GITHUB_OUTPUT + echo "should_release=true" >> $GITHUB_OUTPUT + fi + + echo "✅ Match found!" + echo "Extracted version: v$VERSION" + echo "Should release: true" +else + # Set GitHub Actions outputs if running in CI + if [ -n "$GITHUB_OUTPUT" ]; then + echo "should_release=false" >> $GITHUB_OUTPUT + fi + + echo "❌ No match" + echo "Branch name does not match release pattern" + echo "Expected format: release/x.x.x or release/x.x.x-suffix" + echo "Should release: false" +fi