Go SDK

The Go SDK provides feature flag evaluation, experiment variant assignment, and event tracking for Go applications. It includes an LRU cache with TTL support, consistent MD5-based hash bucketing, local evaluation mode, and full context-cancellation support.


Installation

go get github.com/amarkanday/experimentation-platform/sdk/go

Requires Go 1.19 or later.


Quick Start

import exp "github.com/amarkanday/experimentation-platform/sdk/go"

client, _ := exp.NewClient(
    exp.WithBaseURL("https://api.example.com"),
    exp.WithAPIKey("your-api-key"),
)
defer client.Close()

result, _ := client.EvaluateFlag(ctx, "my-flag", &exp.User{ID: "user-123"})
if result.Enabled {
    // show new feature
}

Building the Client

import exp "github.com/amarkanday/experimentation-platform/sdk/go"

client, err := exp.NewClient(
    exp.WithBaseURL("https://api.example.com"), // Required
    exp.WithAPIKey("your-api-key"),              // Required
)
if err != nil {
    log.Fatalf("failed to create client: %v", err)
}
defer client.Close()

Configuration Options

All options are functional options passed to NewClient.

client, err := exp.NewClient(
    exp.WithBaseURL("https://api.example.com"),
    exp.WithAPIKey("your-api-key"),
    exp.WithTimeout(2*time.Second),      // HTTP timeout (default: 5s)
    exp.WithCacheSize(1000),             // LRU cache capacity (default: 1000)
    exp.WithCacheTTL(60*time.Second),    // Cache entry TTL (default: 60s)
    exp.WithLocalEval(true),             // Use local hashing, skip network (default: false)
)
OptionTypeDefaultDescription
WithBaseURL(url string)string(required)Base URL of the platform API
WithAPIKey(key string)string(required)API key for SDK authentication
WithTimeout(d time.Duration)Duration5sHTTP request timeout
WithCacheSize(n int)int1000Maximum LRU cache entries
WithCacheTTL(d time.Duration)Duration60sTime before a cached result expires
WithLocalEval(enabled bool)boolfalseEvaluate flags locally using downloaded rules

Call client.Close() when shutting down to flush pending events and release the HTTP connection pool.


Core Concepts

Local Evaluation

When WithLocalEval(true) is set, the SDK periodically downloads experiment and flag configurations from the platform and evaluates them in-process without making a network call on every request. This is the recommended mode for high-traffic services.

client, err := exp.NewClient(
    exp.WithBaseURL("https://api.example.com"),
    exp.WithAPIKey("your-api-key"),
    exp.WithLocalEval(true),
    exp.WithCacheTTL(30*time.Second), // How often to refresh the rule set
)

The SDK falls back to the remote API if local rule data is unavailable.

Consistent Hashing

Variant assignment uses MD5-based consistent hashing on the string "{experimentKey}:{userID}". This guarantees that the same user always receives the same variant for a given experiment, across all SDK instances and application restarts — no sticky session infrastructure is required.

The hash algorithm is identical across all platform SDKs (Go, Java, Python, JavaScript, React), so a user bucketed server-side will be in the same bucket client-side.

Caching

The SDK maintains a thread-safe in-memory LRU cache. Every evaluation result is cached by (flagKey, userID). Subsequent calls for the same user bypass the network entirely until the TTL expires. Adjust WithCacheSize and WithCacheTTL based on your traffic volume and acceptable staleness.


Feature Flag Evaluation

EvaluateFlag

result, err := client.EvaluateFlag(ctx, "my-flag", &exp.User{ID: "user-123"})
if err != nil {
    // Errors are rare; the SDK returns a disabled result on network failures.
    log.Printf("flag evaluation error: %v", err)
}
if result.Enabled {
    showNewFeature()
}

Pass user attributes for targeting rules:

user := &exp.User{
    ID: "user-123",
    Attributes: map[string]interface{}{
        "plan":    "enterprise",
        "country": "US",
        "beta":    true,
    },
}
result, err := client.EvaluateFlag(ctx, "new-dashboard", user)

FlagResult Fields

FieldTypeDescription
EnabledboolWhether the flag is on for this user
FlagKeystringThe evaluated flag key
UserIDstringThe user ID used for evaluation
EvaluatedAttime.TimeTimestamp of the evaluation

Experiment Assignment

GetAssignment

Returns the full assignment including variant key, experiment metadata, and whether the user is in the control group.

assignment, err := client.GetAssignment(ctx, "checkout-experiment", user)
if err != nil {
    log.Printf("assignment error: %v", err)
    // Fall back to control
}
fmt.Println(assignment.VariantKey) // "control" or "treatment"

Branching on the variant:

switch assignment.VariantKey {
case "green-button":
    renderGreenButton(w)
case "red-button":
    renderRedButton(w)
default:
    renderDefaultButton(w)
}

Assignment Fields

FieldTypeDescription
VariantKeystringAssigned variant (e.g., "control", "treatment")
ExperimentKeystringThe experiment key
ExperimentIDstringUUID of the experiment
IsControlbooltrue if this is the control variant
AssignedAttime.TimeAssignment timestamp

Event Tracking

Track

Records a conversion or behavioral event. Call this after meaningful user actions (purchases, clicks, form submissions).

err := client.Track(ctx, &exp.TrackEvent{
    UserID:    "user-123",
    EventName: "purchase",
    Properties: map[string]interface{}{
        "amount":   99.99,
        "currency": "USD",
        "sku":      "pro-plan",
    },
})

For fire-and-forget tracking (does not block the request path):

client.TrackAsync(&exp.TrackEvent{
    UserID:    "user-123",
    EventName: "page_view",
})

TrackAsync enqueues the event in an internal buffered channel. Events are flushed to the platform in batches. Call client.Close() before process exit to ensure the buffer is drained.

TrackEvent Fields

FieldTypeRequiredDescription
UserIDstringYesThe user who performed the action
EventNamestringYesEvent identifier (e.g., "purchase", "signup")
Valuefloat64NoNumeric value for the event (revenue, duration, count)
Propertiesmap[string]interface{}NoArbitrary key-value metadata
Timestamptime.TimeNoEvent time (defaults to time.Now() if zero)

Advanced Usage

User Attributes for Targeting

User attributes are matched against targeting rules configured in the platform. Supported attribute value types are string, int, float64, and bool.

user := &exp.User{
    ID: "user-456",
    Attributes: map[string]interface{}{
        "plan":       "pro",
        "country":    "CA",
        "age":        34,
        "beta_tester": true,
    },
}

Attributes are evaluated server-side (or locally if WithLocalEval(true)). They are never persisted by the SDK.

Context Cancellation

All network operations respect context.Context. Use deadlines and cancellations to bound latency in request-handling code.

ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()

result, err := client.EvaluateFlag(ctx, "my-flag", user)
if errors.Is(err, context.DeadlineExceeded) {
    // Context timed out; serve the default (disabled) value
}

Goroutine Safety

ExperimentationClient is safe for concurrent use across goroutines. Create a single instance at application startup and share it for the process lifetime.

// main.go — create once
var expClient *exp.Client

func main() {
    var err error
    expClient, err = exp.NewClient(
        exp.WithBaseURL(os.Getenv("EXP_BASE_URL")),
        exp.WithAPIKey(os.Getenv("EXP_API_KEY")),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer expClient.Close()
    // ...
}

// handler.go — use from any goroutine
func handleRequest(w http.ResponseWriter, r *http.Request) {
    result, _ := expClient.EvaluateFlag(r.Context(), "feature-x", user)
    // ...
}

Error Handling

The SDK uses typed errors for predictable error handling.

result, err := client.EvaluateFlag(ctx, "my-flag", user)
if err != nil {
    var apiErr *exp.APIError
    if errors.As(err, &apiErr) {
        log.Printf("API error %d: %s", apiErr.StatusCode, apiErr.Message)
    }
    // EvaluateFlag always returns a usable result even on error (Enabled: false)
    // Safe to proceed with result.Enabled
}
Error TypeDescription
*exp.APIErrorNon-2xx HTTP response from the platform
*exp.NetworkErrorConnection failure or timeout
*exp.ValidationErrorInvalid input (empty user ID, empty flag key)

EvaluateFlag and GetAssignment always return a usable zero-value result on error so callers never need to nil-check before reading fields.


Testing

Use net/http/httptest to test code that calls the SDK without hitting the real API.

import (
    "net/http"
    "net/http/httptest"
    "testing"

    exp "github.com/amarkanday/experimentation-platform/sdk/go"
)

func TestCheckoutFeature(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"enabled": true, "flag_key": "new-checkout"}`))
    }))
    defer server.Close()

    client, err := exp.NewClient(
        exp.WithBaseURL(server.URL),
        exp.WithAPIKey("test-key"),
    )
    if err != nil {
        t.Fatal(err)
    }
    defer client.Close()

    user := &exp.User{ID: "test-user"}
    result, err := client.EvaluateFlag(t.Context(), "new-checkout", user)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !result.Enabled {
        t.Error("expected flag to be enabled")
    }
}

For unit tests that do not need HTTP at all, use exp.NewMockClient() which returns a client with a configurable in-memory stub:

mock := exp.NewMockClient()
mock.SetFlag("my-flag", true)
mock.SetVariant("checkout-experiment", "treatment")

result, _ := mock.EvaluateFlag(ctx, "my-flag", user)
// result.Enabled == true

Full Working Example

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    exp "github.com/amarkanday/experimentation-platform/sdk/go"
)

var expClient *exp.Client

func init() {
    var err error
    expClient, err = exp.NewClient(
        exp.WithBaseURL(os.Getenv("EXP_BASE_URL")),
        exp.WithAPIKey(os.Getenv("EXP_API_KEY")),
        exp.WithTimeout(2*time.Second),
        exp.WithCacheSize(5000),
        exp.WithCacheTTL(30*time.Second),
        exp.WithLocalEval(true),
    )
    if err != nil {
        log.Fatalf("failed to init experimentation client: %v", err)
    }
}

func checkoutHandler(w http.ResponseWriter, r *http.Request) {
    userID := r.Header.Get("X-User-ID")
    if userID == "" {
        userID = "anonymous"
    }

    ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
    defer cancel()

    user := &exp.User{
        ID: userID,
        Attributes: map[string]interface{}{
            "plan":    r.Header.Get("X-User-Plan"),
            "country": r.Header.Get("X-User-Country"),
        },
    }

    // Feature flag gate
    flagResult, _ := expClient.EvaluateFlag(ctx, "new-checkout-flow", user)

    // Experiment variant
    assignment, _ := expClient.GetAssignment(ctx, "checkout-cta-copy", user)

    fmt.Fprintf(w, "newFlow=%v cta=%s", flagResult.Enabled, assignment.VariantKey)

    // Track the page view (fire and forget)
    expClient.TrackAsync(&exp.TrackEvent{
        UserID:    userID,
        EventName: "checkout_page_view",
    })
}

func main() {
    defer expClient.Close()
    http.HandleFunc("/checkout", checkoutHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

SDK Compatibility

Platform SDKHash AlgorithmAssignment Parity
GoMD5Yes
JavaMD5Yes
PythonMD5Yes
JavaScriptMD5Yes
ReactMD5Yes

All SDKs produce identical variant assignments for the same (experimentKey, userID) pair.