Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 217 additions & 123 deletions ONBOARDING.md

Large diffs are not rendered by default.

81 changes: 62 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
<div align="center">

<img width="64" height="64" alt="Ripple Logo" src="https://raw.githubusercontent.com/Tap30/ripple/refs/heads/main/ripple-logo.png" />

# Ripple | Go

</div>

<div align="center">

A fast, resilient, and scalable event-tracking SDK built in Go.
A high-performance, scalable, and fault-tolerant event tracking TypeScript SDK
for browsers.

</div>

Expand All @@ -16,8 +19,12 @@ A fast, resilient, and scalable event-tracking SDK built in Go.

- **Zero Dependencies** – Built entirely with Go standard library
- **Thread-Safe** – Concurrent event tracking with mutex protection
- **Automatic Batching** – Efficient event grouping for network optimization
- **Retry Logic** – Exponential backoff with jitter for failed requests
- **Automatic Batching** – Efficient event grouping with dynamic rebatching for optimal network usage
- **Smart Retry Logic** – Intelligent retry behavior based on HTTP status codes:
- **2xx (Success)**: Clear storage, no retry
- **4xx (Client Error)**: Drop events, no retry (prevents infinite loops)
- **5xx (Server Error)**: Retry with exponential backoff, re-queue on max retries
- **Network Errors**: Retry with exponential backoff, re-queue on max retries
- **Event Persistence** – Disk-backed storage for reliability
- **Graceful Shutdown** – Ensures all events are flushed and persisted
- **Pluggable Adapters** – Custom HTTP and storage implementations
Expand All @@ -30,12 +37,15 @@ go get github.com/Tap30/ripple-go

## Quick Start

### Basic Usage

```go
package main

import (
"time"
ripple "github.com/Tap30/ripple-go"
"github.com/Tap30/ripple-go/adapters"
)

func main() {
Expand All @@ -45,6 +55,14 @@ func main() {
HTTPAdapter: adapters.NewNetHTTPAdapter(),
StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"),
})

// Or use NoOpStorageAdapter if persistence is not needed
client, err := ripple.NewClient(ripple.ClientConfig{
APIKey: "your-api-key",
Endpoint: "https://api.example.com/events",
HTTPAdapter: adapters.NewNetHTTPAdapter(),
StorageAdapter: adapters.NewNoOpStorageAdapter(),
})
if err != nil {
panic(err)
}
Expand All @@ -54,21 +72,29 @@ func main() {
}
defer client.Dispose()

// Set global context
client.SetContext("userId", "123")
client.SetContext("appVersion", "1.0.0")
// Set global metadata
if err := client.SetMetadata("userId", "123"); err != nil {
panic(err)
}
if err := client.SetMetadata("appVersion", "1.0.0"); err != nil {
panic(err)
}

// Track events
client.Track("page_view", map[string]interface{}{
if err := client.Track("page_view", map[string]interface{}{
"page": "/home",
}, nil)
}); err != nil {
panic(err)
}

// Track with metadata
client.Track("user_action", map[string]interface{}{
if err := client.Track("user_action", map[string]interface{}{
"button": "submit",
}, &ripple.EventMetadata{
SchemaVersion: "1.0.0",
})
}, map[string]interface{}{
"schemaVersion": "1.0.0",
}); err != nil {
panic(err)
}

// Manually flush
client.Flush()
Expand All @@ -95,21 +121,38 @@ type ClientConfig struct {
### Client Methods

#### `Init() error`

Initializes the client and starts the dispatcher. Must be called before tracking events.

#### `Track(name string, payload map[string]interface{}, metadata *EventMetadata)`
Tracks an event with optional payload and metadata.
#### `Track(name string, args ...any) error`

Tracks an event with optional payload and metadata. Supports three usage patterns:


- `Track(name)` - Simple event tracking
- `Track(name, payload)` - Event with payload
- `Track(name, payload, metadata)` - Event with payload and metadata

Returns error if event name is empty, exceeds 255 characters, or if client is not initialized.

#### `SetContext(key string, value interface{})`
Sets a global context value that will be attached to all events.
#### `SetMetadata(key string, value interface{}) error`

#### `GetContext() map[string]interface{}`
Returns a copy of the current global context.
Sets a metadata value that will be attached to all subsequent events. Returns error if key is empty or exceeds 255 characters.

#### `GetMetadata() map[string]interface{}`

Returns a copy of all stored metadata. Returns empty map if no metadata is set.

#### `GetSessionId() *string`

Returns the current session ID or `nil` if not set. Always returns `nil` for server environments.

#### `Flush()`

Manually triggers a flush of all queued events.

#### `Dispose() error`

Gracefully shuts down the client, flushing and persisting all events.

## Advanced Usage
Expand Down Expand Up @@ -176,7 +219,7 @@ func (r *RedisStorage) Clear() error {
}

// Use custom adapter
client, err := ripple.NewClient(ripple.ClientConfig{
client, err := ripple.NewClient[map[string]any, map[string]any](ripple.ClientConfig{
APIKey: "your-api-key",
Endpoint: "https://api.example.com/events",
HTTPAdapter: adapters.NewNetHTTPAdapter(),
Expand Down
9 changes: 8 additions & 1 deletion adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,19 @@ type StorageAdapter interface {
}
```

**Default Implementation:** `DefaultStorageAdapter`
**Default Implementation:** `FileStorageAdapter`

- Stores events as JSON in a file
- Default file: `ripple_events.json`
- Suitable for server environments

**NoOp Implementation:** `NoOpStorageAdapter`

- Performs no storage operations
- Save and Clear do nothing
- Load returns empty array
- Useful when persistence is not required

## Custom Implementations

### Example: Custom HTTP Adapter
Expand Down
19 changes: 19 additions & 0 deletions adapters/file_storage_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,22 @@ func TestFileStorageAdapter_SaveMarshalError(t *testing.T) {
t.Fatal("expected error for unmarshalable data")
}
}

func TestFileStorageAdapter_LoadPermissionError(t *testing.T) {
// Create a file in a directory that doesn't exist
adapter := NewFileStorageAdapter("/nonexistent/directory/file.json")

// This should return empty array for nonexistent file/directory
events, err := adapter.Load()
if err != nil {
// If there's an error, it should be handled gracefully
if !os.IsNotExist(err) {
t.Errorf("unexpected error type: %v", err)
}
} else {
// Should return empty array
if len(events) != 0 {
t.Errorf("expected empty array, got %d events", len(events))
}
}
}
25 changes: 25 additions & 0 deletions adapters/noop_storage_adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package adapters

// NoOpStorageAdapter is a storage adapter that performs no operations.
// Useful for scenarios where event persistence is not required.
type NoOpStorageAdapter struct{}

// NewNoOpStorageAdapter creates a new NoOpStorageAdapter instance.
func NewNoOpStorageAdapter() *NoOpStorageAdapter {
return &NoOpStorageAdapter{}
}

// Save does nothing and always returns nil.
func (n *NoOpStorageAdapter) Save(events []Event) error {
return nil
}

// Load returns an empty slice and nil error.
func (n *NoOpStorageAdapter) Load() ([]Event, error) {
return []Event{}, nil
}

// Clear does nothing and always returns nil.
func (n *NoOpStorageAdapter) Clear() error {
return nil
}
48 changes: 48 additions & 0 deletions adapters/noop_storage_adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package adapters

import (
"testing"
)

func TestNoOpStorageAdapter_Save(t *testing.T) {
adapter := NewNoOpStorageAdapter()

events := []Event{
{Name: "test_event", Payload: map[string]any{"key": "value"}},
}

err := adapter.Save(events)
if err != nil {
t.Errorf("Save should always return nil, got: %v", err)
}
}

func TestNoOpStorageAdapter_Load(t *testing.T) {
adapter := NewNoOpStorageAdapter()

events, err := adapter.Load()
if err != nil {
t.Errorf("Load should return nil error, got: %v", err)
}

if events == nil {
t.Error("Load should return empty slice, not nil")
}

if len(events) != 0 {
t.Errorf("Load should return empty slice, got %d events", len(events))
}
}

func TestNoOpStorageAdapter_Clear(t *testing.T) {
adapter := NewNoOpStorageAdapter()

err := adapter.Clear()
if err != nil {
t.Errorf("Clear should always return nil, got: %v", err)
}
}

func TestNoOpStorageAdapter_Interface(t *testing.T) {
var _ StorageAdapter = (*NoOpStorageAdapter)(nil)
}
7 changes: 2 additions & 5 deletions adapters/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@ package adapters
type Event struct {
Name string `json:"name"`
Payload map[string]any `json:"payload"`
Metadata *EventMetadata `json:"metadata"`
Metadata map[string]any `json:"metadata"`
IssuedAt int64 `json:"issuedAt"`
Context map[string]any `json:"context"`
SessionID *string `json:"sessionId"`
Platform *Platform `json:"platform"`
}

// EventMetadata contains optional event metadata.
type EventMetadata struct {
SchemaVersion *string `json:"schemaVersion,omitempty"`
}
type EventMetadata = map[string]any

// Platform represents server platform information.
type Platform struct {
Expand Down
Loading
Loading