diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 74295ca..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Inspired by https://discuss.circleci.com/t/how-to-test-multiple-versions-by-triggering-jobs-with-a-shell-function/11305/15. -version: 2 -jobs: - build-golatest: - docker: - - image: circleci/golang - working_directory: /go/src/github.com/segmentio/analytics-go - steps: - - checkout - - run: make ci - build-go113: - docker: - - image: circleci/golang:1.13 - working_directory: /go/src/github.com/segmentio/analytics-go - steps: - - checkout - - run: make ci - build-go112: - docker: - - image: circleci/golang:1.12 - working_directory: /go/src/github.com/segmentio/analytics-go - steps: - - checkout - - run: make ci - build-go111: - docker: - - image: circleci/golang:1.11 - working_directory: /go/src/github.com/segmentio/analytics-go - steps: - - checkout - - run: make ci - -workflows: - version: 2 - build: - jobs: - - build-golatest - - build-go113 - - build-go112 - - build-go111 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5854c87 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [v3.0] + pull_request: + branches: [v3.0] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ['1.21', '1.22', '1.23'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Download dependencies + run: go get -v -t ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=cover.out . + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-go${{ matrix.go-version }} + path: cover.out + retention-days: 7 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..d65d77b --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,61 @@ +name: E2E Tests + +on: + push: + branches: [v3.0] + pull_request: + branches: [v3.0] + workflow_dispatch: + inputs: + e2e_tests_ref: + description: 'Branch or ref of sdk-e2e-tests to use' + required: false + default: 'main' + +jobs: + e2e-tests: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + with: + path: sdk + + - name: Checkout sdk-e2e-tests + uses: actions/checkout@v4 + with: + repository: segmentio/sdk-e2e-tests + ref: ${{ inputs.e2e_tests_ref || 'main' }} + token: ${{ secrets.E2E_TESTS_TOKEN }} + path: sdk-e2e-tests + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: sdk/go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build e2e-cli + working-directory: sdk/e2e-cli + run: go build -o e2e-cli-bin ./... + + - name: Run E2E tests + working-directory: sdk-e2e-tests + run: | + ./scripts/run-tests.sh \ + --sdk-dir "${{ github.workspace }}/sdk/e2e-cli" \ + --cli "${{ github.workspace }}/sdk/e2e-cli/e2e-cli-bin" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-results + path: sdk-e2e-tests/test-results/ + if-no-files-found: ignore diff --git a/.github/workflows/publish-e2e-cli.yml b/.github/workflows/publish-e2e-cli.yml new file mode 100644 index 0000000..9e9e590 --- /dev/null +++ b/.github/workflows/publish-e2e-cli.yml @@ -0,0 +1,36 @@ +name: Publish E2E CLI + +on: + push: + branches: [v3.0] + paths: + - 'e2e-cli/**' + - '*.go' + - 'go.mod' + - 'go.sum' + schedule: + - cron: '0 0 1 * *' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build e2e-cli + working-directory: e2e-cli + run: go build -o e2e-cli-bin ./... + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: e2e-cli-go + path: e2e-cli/e2e-cli-bin + retention-days: 90 diff --git a/e2e-cli/.gitignore b/e2e-cli/.gitignore new file mode 100644 index 0000000..c3b1497 --- /dev/null +++ b/e2e-cli/.gitignore @@ -0,0 +1 @@ +e2e-cli-bin diff --git a/e2e-cli/README.md b/e2e-cli/README.md new file mode 100644 index 0000000..eeee2e8 --- /dev/null +++ b/e2e-cli/README.md @@ -0,0 +1,154 @@ +# analytics-go e2e-cli + +A small CLI tool used for end-to-end testing of the `analytics-go` SDK. It accepts a JSON description of event sequences, sends those events through the real SDK, and reports the result. + +## Prerequisites + +- Go 1.17+ +- (For `run-e2e.sh`) Node.js 18+ and the `sdk-e2e-tests` repo checked out alongside this repo + +## Building + +```bash +cd e2e-cli +go build -o e2e-cli-bin ./... +``` + +## Usage + +```bash +./e2e-cli-bin --input '' +``` + +The CLI writes debug/log information to **stderr** and the JSON result to **stdout**. + +Exit code is `0` on success and `1` on failure. + +## Input JSON format + +```json +{ + "writeKey": "YOUR_WRITE_KEY", + "apiHost": "https://api.segment.io", + "sequences": [ + { + "delayMs": 0, + "events": [ + { + "type": "track", + "event": "Button Clicked", + "userId": "user-123", + "properties": { "plan": "pro" } + }, + { + "type": "identify", + "userId": "user-123", + "traits": { "email": "user@example.com" } + }, + { + "type": "page", + "userId": "user-123", + "name": "Home" + }, + { + "type": "screen", + "userId": "user-123", + "name": "Main Screen" + }, + { + "type": "alias", + "userId": "new-id", + "previousId": "old-id" + }, + { + "type": "group", + "userId": "user-123", + "groupId": "group-456", + "traits": { "name": "Acme Corp" } + } + ] + } + ], + "config": { + "flushAt": 15, + "flushInterval": 1000, + "maxRetries": 3, + "timeout": 10 + } +} +``` + +### Top-level fields + +| Field | Type | Description | +|-------------|--------|--------------------------------------------------| +| `writeKey` | string | Segment write key used to authenticate requests | +| `apiHost` | string | Full API endpoint URL (e.g. `https://api.segment.io`) | +| `sequences` | array | List of event sequences (run in order) | +| `config` | object | SDK configuration overrides | + +### Sequence fields + +| Field | Type | Description | +|-----------|-------|---------------------------------------------------------------------| +| `delayMs` | int | Milliseconds to wait before sending this sequence's events | +| `events` | array | List of events to enqueue | + +### Event fields + +| Field | Type | Description | +|---------------|--------|---------------------------------------------------------------------| +| `type` | string | Event type: `track`, `identify`, `page`, `screen`, `alias`, `group` | +| `event` | string | Event name (required for `track`) | +| `userId` | string | User ID | +| `anonymousId` | string | Anonymous ID (used when `userId` is absent) | +| `messageId` | string | Optional explicit message ID | +| `previousId` | string | Previous user ID (required for `alias`) | +| `groupId` | string | Group ID (required for `group`) | +| `name` | string | Page or screen name | +| `timestamp` | string | ISO 8601 timestamp (RFC3339), e.g. `2024-01-15T10:30:00Z` | +| `properties` | object | Event properties (for `track`, `page`, `screen`) | +| `traits` | object | User or group traits (for `identify`, `group`) | +| `integrations`| object | Integration-specific settings | + +### Config fields + +| Field | Type | Description | +|-----------------|------|------------------------------------------| +| `flushAt` | int | Max events per batch (default: 250) | +| `flushInterval` | int | Flush interval in milliseconds | +| `maxRetries` | int | (informational, not directly used by SDK)| +| `timeout` | int | Timeout in seconds (informational) | + +## Output JSON format + +On success: + +```json +{"success": true, "sentBatches": 1} +``` + +On failure: + +```json +{"success": false, "sentBatches": 0, "error": "description of error"} +``` + +## Running E2E tests + +Use the provided shell script to build the CLI and run the shared `sdk-e2e-tests` suite: + +```bash +# From the e2e-cli directory, with sdk-e2e-tests checked out as a sibling of analytics-go: +./run-e2e.sh + +# Override the e2e test directory: +E2E_TESTS_DIR=/path/to/sdk-e2e-tests ./run-e2e.sh + +# Pass additional arguments to run-tests.sh: +./run-e2e.sh --suite basic +``` + +The script: +1. Builds the `e2e-cli-bin` binary using `go build` +2. Invokes `sdk-e2e-tests/scripts/run-tests.sh` with the appropriate `--sdk-dir` and `--cli` flags diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json new file mode 100644 index 0000000..d7deb43 --- /dev/null +++ b/e2e-cli/e2e-config.json @@ -0,0 +1,7 @@ +{ + "sdk": "go", + "test_suites": "basic", + "auto_settings": false, + "patch": null, + "env": {} +} diff --git a/e2e-cli/go.mod b/e2e-cli/go.mod new file mode 100644 index 0000000..e49e5b2 --- /dev/null +++ b/e2e-cli/go.mod @@ -0,0 +1,14 @@ +module github.com/segmentio/analytics-go/v3/e2e-cli + +go 1.17 + +require github.com/segmentio/analytics-go/v3 v3.0.0 + +require ( + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/segmentio/backo-go v1.0.0 // indirect +) + +replace github.com/segmentio/analytics-go/v3 => ../ diff --git a/e2e-cli/go.sum b/e2e-cli/go.sum new file mode 100644 index 0000000..9c34257 --- /dev/null +++ b/e2e-cli/go.sum @@ -0,0 +1,21 @@ +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/segmentio/backo-go v1.0.0 h1:kbOAtGJY2DqOR0jfRkYEorx/b18RgtepGtY3+Cpe6qA= +github.com/segmentio/backo-go v1.0.0/go.mod h1:kJ9mm9YmoWSkk+oQ+5Cj8DEoRCX2JT6As4kEtIIOp1M= +github.com/segmentio/conf v1.2.0/go.mod h1:Y3B9O/PqqWqjyxyWWseyj/quPEtMu1zDp/kVbSWWaB0= +github.com/segmentio/go-snakecase v1.1.0/go.mod h1:jk1miR5MS7Na32PZUykG89Arm+1BUSYhuGR6b7+hJto= +github.com/segmentio/objconv v1.0.1/go.mod h1:auayaH5k3137Cl4SoXTgrzQcuQDmvuVtZgS0fb1Ahys= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/mold.v2 v2.2.0/go.mod h1:XMyyRsGtakkDPbxXbrA5VODo6bUXyvoDjLd5l3T0XoA= +gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/e2e-cli/main.go b/e2e-cli/main.go new file mode 100644 index 0000000..eb35f89 --- /dev/null +++ b/e2e-cli/main.go @@ -0,0 +1,323 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "sync" + "time" + + analytics "github.com/segmentio/analytics-go/v3" +) + +// Input JSON structures + +type InputConfig struct { + FlushAt int `json:"flushAt"` + FlushInterval int `json:"flushInterval"` // milliseconds + MaxRetries int `json:"maxRetries"` + Timeout int `json:"timeout"` // seconds +} + +type InputEvent struct { + Type string `json:"type"` + Event string `json:"event"` + UserId string `json:"userId"` + AnonymousId string `json:"anonymousId"` + MessageId string `json:"messageId"` + PreviousId string `json:"previousId"` + GroupId string `json:"groupId"` + Name string `json:"name"` + Timestamp string `json:"timestamp"` + Properties map[string]interface{} `json:"properties"` + Traits map[string]interface{} `json:"traits"` + Integrations map[string]interface{} `json:"integrations"` +} + +type InputSequence struct { + DelayMs int `json:"delayMs"` + Events []InputEvent `json:"events"` +} + +type Input struct { + WriteKey string `json:"writeKey"` + ApiHost string `json:"apiHost"` + Sequences []InputSequence `json:"sequences"` + Config InputConfig `json:"config"` +} + +// Output JSON structure + +type Output struct { + Success bool `json:"success"` + SentBatches int `json:"sentBatches"` + Error string `json:"error,omitempty"` +} + +// Callback implementation + +type myCallback struct { + mu sync.Mutex + failures []string + successCount int +} + +func (cb *myCallback) Success(msg analytics.Message) { + cb.mu.Lock() + defer cb.mu.Unlock() + cb.successCount++ + fmt.Fprintf(os.Stderr, "[callback] success: %T\n", msg) +} + +func (cb *myCallback) Failure(msg analytics.Message, err error) { + cb.mu.Lock() + defer cb.mu.Unlock() + errMsg := fmt.Sprintf("message %T failed: %v", msg, err) + cb.failures = append(cb.failures, errMsg) + fmt.Fprintf(os.Stderr, "[callback] failure: %s\n", errMsg) +} + +// stderr logger that satisfies analytics.Logger + +type stderrLogger struct { + logger *log.Logger +} + +func (l *stderrLogger) Logf(format string, args ...interface{}) { + l.logger.Printf("INFO: "+format, args...) +} + +func (l *stderrLogger) Errorf(format string, args ...interface{}) { + l.logger.Printf("ERROR: "+format, args...) +} + +func parseTimestamp(ts string) time.Time { + if ts == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + fmt.Fprintf(os.Stderr, "[warn] could not parse timestamp %q: %v\n", ts, err) + return time.Time{} + } + return t +} + +func enqueueEvent(client analytics.Client, ev InputEvent) error { + ts := parseTimestamp(ev.Timestamp) + integrations := analytics.Integrations(ev.Integrations) + + switch ev.Type { + case "track": + msg := analytics.Track{ + UserId: ev.UserId, + AnonymousId: ev.AnonymousId, + MessageId: ev.MessageId, + Event: ev.Event, + Properties: analytics.Properties(ev.Properties), + Integrations: integrations, + } + if !ts.IsZero() { + msg.Timestamp = ts + } + return client.Enqueue(msg) + + case "identify": + msg := analytics.Identify{ + UserId: ev.UserId, + AnonymousId: ev.AnonymousId, + MessageId: ev.MessageId, + Traits: analytics.Traits(ev.Traits), + Integrations: integrations, + } + if !ts.IsZero() { + msg.Timestamp = ts + } + return client.Enqueue(msg) + + case "page": + msg := analytics.Page{ + UserId: ev.UserId, + AnonymousId: ev.AnonymousId, + MessageId: ev.MessageId, + Name: ev.Name, + Properties: analytics.Properties(ev.Properties), + Integrations: integrations, + } + if !ts.IsZero() { + msg.Timestamp = ts + } + return client.Enqueue(msg) + + case "screen": + msg := analytics.Screen{ + UserId: ev.UserId, + AnonymousId: ev.AnonymousId, + MessageId: ev.MessageId, + Name: ev.Name, + Properties: analytics.Properties(ev.Properties), + Integrations: integrations, + } + if !ts.IsZero() { + msg.Timestamp = ts + } + return client.Enqueue(msg) + + case "alias": + msg := analytics.Alias{ + UserId: ev.UserId, + PreviousId: ev.PreviousId, + MessageId: ev.MessageId, + Integrations: integrations, + } + if !ts.IsZero() { + msg.Timestamp = ts + } + return client.Enqueue(msg) + + case "group": + msg := analytics.Group{ + UserId: ev.UserId, + AnonymousId: ev.AnonymousId, + MessageId: ev.MessageId, + GroupId: ev.GroupId, + Traits: analytics.Traits(ev.Traits), + Integrations: integrations, + } + if !ts.IsZero() { + msg.Timestamp = ts + } + return client.Enqueue(msg) + + default: + return fmt.Errorf("unknown event type: %q", ev.Type) + } +} + +func run(input Input) Output { + cb := &myCallback{} + + cfg := analytics.Config{ + Callback: cb, + Logger: &stderrLogger{ + logger: log.New(os.Stderr, "[analytics] ", log.LstdFlags), + }, + Verbose: true, + } + + if input.ApiHost != "" { + cfg.Endpoint = input.ApiHost + } + + if input.Config.FlushInterval > 0 { + cfg.Interval = time.Duration(input.Config.FlushInterval) * time.Millisecond + } + + if input.Config.FlushAt > 0 { + cfg.BatchSize = input.Config.FlushAt + } + + client, err := analytics.NewWithConfig(input.WriteKey, cfg) + if err != nil { + return Output{ + Success: false, + Error: fmt.Sprintf("failed to create analytics client: %v", err), + } + } + + var enqueueErrors []string + + for i, seq := range input.Sequences { + if seq.DelayMs > 0 { + fmt.Fprintf(os.Stderr, "[info] sequence %d: delaying %dms\n", i, seq.DelayMs) + time.Sleep(time.Duration(seq.DelayMs) * time.Millisecond) + } + + for j, ev := range seq.Events { + fmt.Fprintf(os.Stderr, "[info] sequence %d, event %d: enqueuing %s\n", i, j, ev.Type) + if err := enqueueEvent(client, ev); err != nil { + msg := fmt.Sprintf("sequence %d, event %d (%s): enqueue error: %v", i, j, ev.Type, err) + fmt.Fprintf(os.Stderr, "[error] %s\n", msg) + enqueueErrors = append(enqueueErrors, msg) + } + } + } + + fmt.Fprintf(os.Stderr, "[info] closing client (flushing)...\n") + if err := client.Close(); err != nil { + enqueueErrors = append(enqueueErrors, fmt.Sprintf("close error: %v", err)) + } + + cb.mu.Lock() + failures := cb.failures + successCount := cb.successCount + cb.mu.Unlock() + + allErrors := append(enqueueErrors, failures...) + + if len(allErrors) > 0 { + return Output{ + Success: false, + SentBatches: countBatches(successCount, input.Config.FlushAt), + Error: fmt.Sprintf("%v", allErrors), + } + } + + return Output{ + Success: true, + SentBatches: countBatches(successCount, input.Config.FlushAt), + } +} + +// countBatches estimates the number of batches sent based on total successful +// messages and batch size. Returns at least 1 if any messages succeeded. +func countBatches(successCount, batchSize int) int { + if successCount == 0 { + return 0 + } + if batchSize <= 0 { + batchSize = analytics.DefaultBatchSize + } + batches := successCount / batchSize + if successCount%batchSize != 0 { + batches++ + } + return batches +} + +func main() { + inputFlag := flag.String("input", "", "JSON input describing the events to send") + flag.Parse() + + if *inputFlag == "" { + fmt.Fprintf(os.Stderr, "error: --input flag is required\n") + writeOutput(Output{Success: false, Error: "--input flag is required"}) + os.Exit(1) + } + + var input Input + if err := json.Unmarshal([]byte(*inputFlag), &input); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to parse --input JSON: %v\n", err) + writeOutput(Output{Success: false, Error: fmt.Sprintf("failed to parse input JSON: %v", err)}) + os.Exit(1) + } + + result := run(input) + writeOutput(result) + + if !result.Success { + os.Exit(1) + } +} + +func writeOutput(out Output) { + b, err := json.Marshal(out) + if err != nil { + // Fallback if marshalling fails + fmt.Printf(`{"success":false,"sentBatches":0,"error":"failed to marshal output: %v"}`, err) + return + } + fmt.Println(string(b)) +} diff --git a/e2e-cli/run-e2e.sh b/e2e-cli/run-e2e.sh new file mode 100755 index 0000000..de5a662 --- /dev/null +++ b/e2e-cli/run-e2e.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# +# Run E2E tests for analytics-go +# +# Prerequisites: Node.js 18+ and Go 1.17+ +# +# Usage: +# ./run-e2e.sh [extra args passed to run-tests.sh] +# +# Override sdk-e2e-tests location: +# E2E_TESTS_DIR=../my-e2e-tests ./run-e2e.sh +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_ROOT="$SCRIPT_DIR/.." +E2E_DIR="${E2E_TESTS_DIR:-$SDK_ROOT/../sdk-e2e-tests}" + +echo "=== Building analytics-go e2e-cli ===" +echo "Using Go: $(go version)" + +# Build the CLI binary +cd "$SCRIPT_DIR" +go build -o e2e-cli-bin ./... + +echo "" + +# Run tests +cd "$E2E_DIR" +./scripts/run-tests.sh \ + --sdk-dir "$SCRIPT_DIR" \ + --cli "$SCRIPT_DIR/e2e-cli-bin" \ + "$@"