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/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000..d7f8b1d --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,77 @@ +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 with coverage" + run: make 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: "๐Ÿ” Check code formatting" + run: make fmt-check + + - name: "๐Ÿ” Run linter" + run: make lint + + 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: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c0ab11d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + auto-release: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: "โ˜๏ธ Checkout repository" + uses: actions/checkout@v4 + 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 + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90e4d2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# 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 +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/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..ba08086 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,233 @@ +# Contributing to Ripple | Go + +If you're reading this, you're definitely awesome!
The following is a set +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. + +## 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: + + ```sh + go mod download + ``` + +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 playground server to test the SDK: + +```sh +cd playground +make server +``` + +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 the project: + +```sh +go build ./... +``` + +### Testing + +Run all tests: + +```sh +go test ./... +``` + +Run tests with coverage: + +```sh +go test -cover ./... +``` + +Run tests in verbose mode: + +```sh +go test -v ./... +``` + +### Linting and Formatting + +Format code: + +```sh +gofmt -w . +``` + +Run static analysis: + +```sh +go vet ./... +``` + +If you have golangci-lint installed: + +```sh +golangci-lint run +``` + +### Cleaning + +Clean build artifacts: + +```sh +go clean ./... +``` + +Clean module cache: + +```sh +go clean -modcache +``` + +### Coding style + +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 + +- 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/Makefile b/Makefile new file mode 100644 index 0000000..0e4c225 --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +GO := go + +.PHONY: test test-cover fmt lint clean build check release-test release help + +# Testing +test: + @echo "Running tests..." + $(GO) test ./... + +test-cover: + @echo "Running tests with coverage..." + $(GO) test -cover ./... + +# Code quality +fmt: + @echo "Formatting code..." + 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 "" + @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 new file mode 100644 index 0000000..e9e2663 --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,863 @@ +# 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 +- 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 + +## 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. + +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 and event-specific metadata +* **Automatic Batching** โ€“ dispatch based on batch size +* **Scheduled Flushing** โ€“ time-based flush via goroutines +* **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, storage, and logger 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 + 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) +} +``` + +### 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/ +โ”œโ”€โ”€ 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 +โ”œโ”€โ”€ 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 +โ”œโ”€โ”€ Makefile # Build commands (test, fmt, lint, clean) +โ”œโ”€โ”€ README.md # Project documentation +โ”œโ”€โ”€ ONBOARDING.md # This file - complete implementation guide +โ”œโ”€โ”€ adapters/ +โ”‚ โ”œโ”€โ”€ 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 +โ””โ”€โ”€ playground/ + โ”œโ”€โ”€ cmd/ + โ”‚ โ”œโ”€โ”€ client/ + โ”‚ โ”‚ โ””โ”€โ”€ main.go # Interactive test client with comprehensive options + โ”‚ โ””โ”€โ”€ server/ + โ”‚ โ””โ”€โ”€ main.go # Test server with error simulation + โ”œโ”€โ”€ go.mod + โ””โ”€โ”€ Makefile # Build commands +``` + โ”œโ”€โ”€ Makefile + โ””โ”€โ”€ README.md +``` + +### Core Components + +#### Client + +Entry point for the SDK with enhanced initialization validation and metadata management. +Responsibilities: + +* 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 and MetadataManager. + +Key methods: + +* `Init()` - Initialize client and restore persisted events (must be called first) +* `Track(name, payload, metadata)` - Track event (throws error if not initialized) +* `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: + +* Atomic task execution +* Race condition prevention in Dispatcher flush operations +* Queue-based task scheduling with automatic lock release + +Key method: + +* `RunAtomic(task func() error)` - Execute task with exclusive lock + +#### Dispatcher + +Handles all operational concerns with enhanced logging and race condition prevention: + +* 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 with optional flush +* Comprehensive logging for debugging and monitoring + +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 + +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)` + +### 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`: + +```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) +* Configurable API key header name + +#### 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 +import ( + ripple "github.com/Tap30/ripple-go" + "github.com/Tap30/ripple-go/adapters" +) + +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 { + panic(err) +} +defer client.Dispose() + +// 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: stringPtr("2.0.0")}) + +// Manual flush +client.Flush() + +// Helper function for string pointers +func stringPtr(s string) *string { + return &s +} +``` + +**Important**: `Init()` must be called before `Track()`. Calling `Track()` before initialization will return an error to prevent data loss. + +### Unified Metadata System + +```go +// 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", + "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, err := 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 + HTTPAdapter: adapters.NewNetHTTPAdapter(), + StorageAdapter: adapters.NewFileStorageAdapter("ripple_events.json"), + LoggerAdapter: adapters.NewPrintLoggerAdapter(adapters.LogLevelDebug), +}) +if err != nil { + panic(err) +} +``` + +### Custom Adapters + +#### 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 HTTP logic (e.g., using different HTTP client) + return &adapters.HTTPResponse{OK: true, Status: 200}, nil +} + +// Usage +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 + +```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, 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 + +```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 + +### 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 `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 +# 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: + +* `ripple_client_test.go` +* `dispatcher_test.go` +* `queue_test.go` +* `storage_adapter_test.go` +* `http_adapter_test.go` + +### Manual Commands + +If you prefer to run commands directly: + +* `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/cmd/server/main.go` - HTTP server that receives and logs events +* `playground/cmd/client/main.go` - Interactive client with comprehensive testing options + +**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` + +### 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 check # All CI checks in one command +make test # Run tests +make fmt # Format code +make lint # Run go vet +make 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. + +### 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**: 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 +# 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. 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**: +```bash +# Test release configuration +goreleaser check + +# Create snapshot release (no publishing) +goreleaser release --snapshot --clean +``` + +--- + +## 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 +* Proper error handling (no panics in library code) + +### Simplicity + +* Single self-contained package +* No external dependencies +* Clean, predictable API +* Modern Go idioms (use `any` instead of `interface{}`) + +--- + +## 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 +* Use `any` instead of `interface{}` (Go 1.18+ best practice) + +### 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) + +--- + +## 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 + +### 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 +- 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 +- Flattened adapter configuration directly in `ClientConfig` +- 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 +- 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/README.md b/README.md index 881f773..7b0e3b3 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,15 @@ import ( ) func main() { - client := ripple.NewClient(ripple.ClientConfig{ - APIKey: "your-api-key", - Endpoint: "https://api.example.com/events", + 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) @@ -74,11 +79,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 +134,15 @@ func (a *MyHTTPAdapter) Send(endpoint string, events []adapters.Event, headers m } // Use custom adapter -client := ripple.NewClient(config) -client.SetHTTPAdapter(&MyHTTPAdapter{}) +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() ``` @@ -161,8 +176,15 @@ func (r *RedisStorage) Clear() error { } // Use custom adapter -client := ripple.NewClient(config) -client.SetStorageAdapter(&RedisStorage{}) +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() ``` @@ -181,7 +203,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 @@ -224,10 +246,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..d5d1483 --- /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](https://github.com/Tap30/ripple-go/issues/new?assignees=amir78729&labels=security&template=bug_report.md&title=fix(security):) diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 0000000..0656b6d --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,213 @@ +# 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..7995f46 --- /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]any{"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..488249d --- /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 any +} + +// 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/logger_adapter.go b/adapters/logger_adapter.go new file mode 100644 index 0000000..f6ff3bb --- /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 ...any) + // Info logs an info message + Info(message string, args ...any) + // Warn logs a warning message + Warn(message string, args ...any) + // Error logs an error message + Error(message string, args ...any) +} diff --git a/adapters/net_http_adapter.go b/adapters/net_http_adapter.go new file mode 100644 index 0000000..2b21d70 --- /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]any{ + "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..04f0b3c --- /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]any{"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/noop_logger_adapter.go b/adapters/noop_logger_adapter.go new file mode 100644 index 0000000..cb51e55 --- /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 ...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 new file mode 100644 index 0000000..add923d --- /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 ...any) { + if p.shouldLog(LogLevelDebug) { + log.Printf("[DEBUG] [Ripple] "+message, args...) + } +} + +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 ...any) { + if p.shouldLog(LogLevelWarn) { + log.Printf("[WARN] [Ripple] "+message, args...) + } +} + +func (p *PrintLoggerAdapter) Error(message string, args ...any) { + if p.shouldLog(LogLevelError) { + log.Printf("[ERROR] [Ripple] "+message, args...) + } +} 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..eb214d6 --- /dev/null +++ b/adapters/types.go @@ -0,0 +1,22 @@ +package adapters + +// Event represents a tracked event. +type Event struct { + Name string `json:"name"` + Payload map[string]any `json:"payload"` + Metadata *EventMetadata `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"` +} + +// Platform represents server platform information. +type Platform struct { + Type string `json:"type"` +} diff --git a/dispatcher.go b/dispatcher.go new file mode 100644 index 0000000..a5e2b46 --- /dev/null +++ b/dispatcher.go @@ -0,0 +1,205 @@ +package ripple + +import ( + "math/rand" + "sync" + "time" + + "github.com/Tap30/ripple-go/adapters" +) + +type Dispatcher struct { + config DispatcherConfig + queue *Queue + httpAdapter HTTPAdapter + storageAdapter StorageAdapter + loggerAdapter LoggerAdapter + headers map[string]string + ticker *time.Ticker + stopChan chan struct{} + flushMutex *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, + 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 { + 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.Go(func() { + for { + select { + case <-d.ticker.C: + d.Flush() + case <-d.stopChan: + return + } + } + }) + } +} + +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() { + 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 + } + + 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)) + } + } + + // Stop timer if queue is now empty + d.stopTimerIfEmpty() + 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(1< 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..6d230a8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Tap30/ripple-go + +go 1.25 diff --git a/metadata_manager.go b/metadata_manager.go new file mode 100644 index 0000000..270f7c8 --- /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]any + mu sync.RWMutex +} + +// NewMetadataManager creates a new metadata manager +func NewMetadataManager() *MetadataManager { + return &MetadataManager{ + metadata: make(map[string]any), + } +} + +// Set sets a metadata value +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) 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]any { + m.mu.RLock() + defer m.mu.RUnlock() + + if len(m.metadata) == 0 { + return nil + } + + result := make(map[string]any, 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]any) +} 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/playground/.gitignore b/playground/.gitignore new file mode 100644 index 0000000..c114685 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,9 @@ +# Binaries +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..4c8b620 --- /dev/null +++ b/playground/Makefile @@ -0,0 +1,29 @@ +GO := /usr/local/go/bin/go + +.PHONY: server client clean build + +server: + @echo "๐Ÿš€ Starting event tracking server..." + @$(GO) run cmd/server/main.go + +client: + @echo "๐ŸŽฏ Starting interactive client..." + @$(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 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 build - Build client and server binaries" + @echo " make clean - Remove persisted events file and binaries" diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..b2a0c2f --- /dev/null +++ b/playground/README.md @@ -0,0 +1,227 @@ +# Ripple Go Playground + +A testing environment for the Ripple Go SDK with a dummy HTTP server. + +## Structure + +- `cmd/server/main.go` - HTTP server that receives and logs events +- `cmd/client/main.go` - Interactive CLI client for manual testing + +## Usage + +### Start the Server + +```bash +cd playground +go run cmd/server/main.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 cmd/client/main.go +``` + +Or using Makefile: + +```bash +make client +``` + +The client provides a menu to: + +**๐Ÿ“Š 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: +``` +๐ŸŽฏ Ripple Interactive Client +Connected to: http://localhost:3000/events + +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐Ÿ“Š 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 + +๐Ÿ“Š Track Simple Event +โœ… Tracked: button_click + +Choose an option: 5 + +๐Ÿท๏ธ Set Shared Metadata +โœ… Shared metadata set: key_1 = value_1 +``` + +### Expected Output + +**Server:** + +```txt +๐Ÿš€ Event tracking server running at http://localhost:3000 +๐Ÿ“ Endpoint: http://localhost:3000/events +๐Ÿ”‘ API Key: test-api-key +๐Ÿ“Š Received events: +{ + "events": [ + { + "name": "button_click", + "payload": null, + "issuedAt": 1734622890, + "context": {}, + "metadata": {}, + "platform": { "type": "server" } + } + ] +} +``` + +**Client:** + +```txt +๐Ÿ“Š Track Simple Event +โœ… Tracked: button_click + +๐Ÿ”„ Flushing events... +โœ… Events flushed +``` + +## E2E Testing + +This playground is useful for: + +- 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 + +### 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 cmd/server/main.go + +# Terminal 2 +go run cmd/client/main.go +``` + +### 2. Test Retry Logic + +```bash +# Terminal 1 +go run cmd/server/main.go + +# Terminal 2 +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 + +# Or stop server (Ctrl+C) before flush +# Events should be persisted to ripple_events.json + +# Restart server +go run cmd/server/main.go + +# Run client again - persisted events should be sent +go run cmd/client/main.go +``` + +### 3. Test Batching + +```bash +# Use option 8 in the client menu to track 10 events +# Observe auto-flush at batch size 5 +``` + +### 4. Test Invalid Endpoint + +```bash +# Use option 11 in the client menu +# Creates a client with invalid endpoint to test error handling +``` diff --git a/playground/cmd/client/main.go b/playground/cmd/client/main.go new file mode 100644 index 0000000..075a3af --- /dev/null +++ b/playground/cmd/client/main.go @@ -0,0 +1,294 @@ +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) + + var err error + client, err = ripple.NewClient(ripple.ClientConfig{ + 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.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 + } + + 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]any{ + "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]any{ + "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]any{ + "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]any{"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, err := ripple.NewClient(ripple.ClientConfig{ + 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 != 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 + } + + errorClient.Track("error_test", map[string]any{"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]any{ + "action": fmt.Sprintf("action_%d", eventCounter), + "timestamp": time.Now().Unix(), + "data": map[string]any{ + "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]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]any{ + "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/server/main.go b/playground/cmd/server/main.go new file mode 100644 index 0000000..94f2f00 --- /dev/null +++ b/playground/cmd/server/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" +) + +const PORT = 3000 + +type EventsPayload struct { + Events []map[string]any `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)) + + // Check for error trigger in any event payload + for _, event := range payload.Events { + 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") + 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]any{ + "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/go.mod b/playground/go.mod new file mode 100644 index 0000000..c20e3fa --- /dev/null +++ b/playground/go.mod @@ -0,0 +1,7 @@ +module playground + +go 1.25 + +require github.com/Tap30/ripple-go v0.0.0 + +replace github.com/Tap30/ripple-go => .. 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/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/ripple_client.go b/ripple_client.go new file mode 100644 index 0000000..2fafa1b --- /dev/null +++ b/ripple_client.go @@ -0,0 +1,208 @@ +package ripple + +import ( + "errors" + "sync" + "time" + + "github.com/Tap30/ripple-go/adapters" +) + +type Client struct { + config ClientConfig + metadataManager *MetadataManager + dispatcher *Dispatcher + httpAdapter HTTPAdapter + storageAdapter StorageAdapter + loggerAdapter LoggerAdapter + initialized bool + mu sync.RWMutex +} + +func NewClient(config ClientConfig) (*Client, error) { + // Validate required fields + if config.APIKey == "" { + return nil, errors.New("apiKey must be provided in config") + } + if config.Endpoint == "" { + return nil, errors.New("endpoint must be provided in config") + } + if config.HTTPAdapter == nil || config.StorageAdapter == nil { + return nil, errors.New("both HTTPAdapter and StorageAdapter must be provided in config") + } + + // Set defaults + if config.FlushInterval == 0 { + config.FlushInterval = 5 * time.Second + } + if !(config.MaxBatchSize > 0) { + config.MaxBatchSize = 10 + } + if config.MaxRetries == 0 { + config.MaxRetries = 3 + } + + client := &Client{ + config: config, + metadataManager: NewMetadataManager(), + httpAdapter: config.HTTPAdapter, + storageAdapter: config.StorageAdapter, + } + + // Use provided logger or default + if config.LoggerAdapter != nil { + client.loggerAdapter = config.LoggerAdapter + } else { + client.loggerAdapter = adapters.NewPrintLoggerAdapter(adapters.LogLevelWarn) + } + + return client, nil +} + +// 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 { + 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 + } + + headers := map[string]string{ + apiKeyHeader: c.config.APIKey, + } + + dispatcherConfig := DispatcherConfig{ + APIKey: c.config.APIKey, + APIKeyHeader: apiKeyHeader, + 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) + 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) SetMetadata(key string, value any) { + c.metadataManager.Set(key, value) +} + +func (c *Client) GetMetadata(key string) any { + return c.metadataManager.Get(key) +} + +func (c *Client) GetAllMetadata() map[string]any { + return c.metadataManager.GetAll() +} + +func (c *Client) Track(name string, payload map[string]any, 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: finalMetadata, + IssuedAt: time.Now().UnixMilli(), + Context: sharedMetadata, // Use shared metadata as context + 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 { + 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 { + 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/ripple_client_test.go b/ripple_client_test.go new file mode 100644 index 0000000..139ecb0 --- /dev/null +++ b/ripple_client_test.go @@ -0,0 +1,349 @@ +package ripple + +import ( + "testing" + "time" +) + +func stringPtr(s string) *string { + return &s +} + +func createTestConfig() ClientConfig { + return ClientConfig{ + APIKey: "test-key", + Endpoint: "http://test.com", + HTTPAdapter: &mockHTTPAdapter{}, + StorageAdapter: &mockStorageAdapter{}, + } +} + +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 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 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 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 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 := createTestClient() + + 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 := createTestClient() + + 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 := createTestClient() + + 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 := createTestClient() + + 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 := createTestClient() + + // 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 := createTestClient() + + // 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 := createTestClient() + + 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 := createTestClient() + + 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_SetGetMetadata(t *testing.T) { + client := createTestClient() + + client.SetMetadata("userId", "123") + client.SetMetadata("appVersion", "1.0.0") + + 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 := createTestClient() + + 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.SetMetadata("userId", "123") + client.Track("page_view", map[string]any{"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 := createTestClient() + + 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: stringPtr("1.0.0")} + client.Track("user_signup", map[string]any{"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 := createTestClient() + + 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 := createTestClient() + + 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 := createTestClient() + + 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/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 diff --git a/types.go b/types.go new file mode 100644 index 0000000..2718e9d --- /dev/null +++ b/types.go @@ -0,0 +1,48 @@ +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 + LoggerAdapter = adapters.LoggerAdapter + LogLevel = adapters.LogLevel +) + +type HTTPError struct { + Status int +} + +func (e *HTTPError) Error() string { + return "HTTP request failed" +} + +type ClientConfig struct { + 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 { + APIKey string + APIKeyHeader string + Endpoint string + FlushInterval time.Duration + MaxBatchSize int + MaxRetries int +} 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") + } +}