mirror of
https://github.com/hanzoai/insights-go.git
synced 2026-09-30 22:46:12 +00:00
feat: refactor internal event submission pipeline and test suites (#151)
* intitial refactor: - Add Context-based graceful shutdown and drain phase for enqueued requests - Use buffered channels vs. mutex for concurrency mgmt. - Use atomic.Pointer vs. locks for FF mgmt - Use Callback ifc vs. lossy internal tracking for unit test result validation - Add new APIs mirroring current that accept caller's Context to control operation timeouts * expose batch submission timeout with default equiv to old hardcoded timeout * restructure event submission and batching pipeline * overhaul tests; implement new stress tests and address coverage gaps * parallelize tests where safe to do so (3x CI time savings!) * introduce faster pure Go JSON seralization library * more refactors: unit tests & benchmarks * DRY up shutdown req drain and prepareForSend serializer * use go-json in CLI utils too; remove redundant helpers * move error type to proper pkg * DRY up loop() and fix deadlock in old test case * normalize CI and Makefile test and benchmark target behavviors * more realistic feature flags benchmark * replace leaky time.After pattern with idiomatic safer Timers * internal queue size minimums decoupled from batch and worker configs * remove dead comments * fix test bug * update default http.Transport cfg to avoid conn pool as bottleneck; update batch enqueue limiting strategy and tests/benches * adjust stress test defaults to match prod * update flaky tests and rm stale file * make graceful shutdown timeout opt-in for now * fix tests that reverted to hold helper utils during rebase * fix flaky test due to overhead from -race detector now default in test flags * add more graceful shutdown test cases
This commit is contained in:
@@ -21,4 +21,4 @@ jobs:
|
||||
run: go build .
|
||||
|
||||
- name: Test
|
||||
run: go test -timeout 30s -v ./...
|
||||
run: go test -v -race -count=1 -timeout=5m -coverprofile=coverage.out ./...
|
||||
@@ -10,13 +10,21 @@ vet:
|
||||
|
||||
test: vet
|
||||
@mkdir -p ${CIRCLE_ARTIFACTS}
|
||||
@go test -race -coverprofile=${CIRCLE_ARTIFACTS}/cover.out .
|
||||
@go test -v -race -count=1 -timeout=5m -coverprofile=${CIRCLE_ARTIFACTS}/cover.out ./...
|
||||
@go tool cover -func ${CIRCLE_ARTIFACTS}/cover.out -o ${CIRCLE_ARTIFACTS}/cover.txt
|
||||
@go tool cover -html ${CIRCLE_ARTIFACTS}/cover.out -o ${CIRCLE_ARTIFACTS}/cover.html
|
||||
|
||||
# Run benchmarks (no race - it skews performance measurements)
|
||||
bench: vet
|
||||
@go test -v -bench=. -benchmem -run=^$$ -timeout=30m .
|
||||
|
||||
# Quick benchmark run (single iteration, no memory stats)
|
||||
bench-quick: vet
|
||||
@go test -bench=. -run=^$$ .
|
||||
|
||||
build: test
|
||||
@go build ./...
|
||||
|
||||
ci: dependencies test
|
||||
|
||||
.PHONY: dependencies vet test ci
|
||||
.PHONY: dependencies vet test bench bench-quick ci
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package posthog
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ Message = (*Alias)(nil)
|
||||
|
||||
@@ -79,3 +81,4 @@ func (msg Alias) APIfy() APIMessage {
|
||||
|
||||
return apified
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -66,10 +66,12 @@ func (b *Backoff) NewTicker() *Ticker {
|
||||
|
||||
go func() {
|
||||
for i := 0; ; i++ {
|
||||
timer := time.NewTimer(b.Duration(i))
|
||||
select {
|
||||
case t := <-time.After(b.Duration(i)):
|
||||
case t := <-timer.C:
|
||||
c <- t
|
||||
case <-ticker.done:
|
||||
timer.Stop()
|
||||
close(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBatching_SmallEventsBatchTogether verifies that small events are batched together
|
||||
func TestBatching_SmallEventsBatchTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var batchCount atomic.Int64
|
||||
var totalMessages atomic.Int64
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
batchCount.Add(1)
|
||||
totalMessages.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Small events (~100 props, ~5KB each)
|
||||
// With 500KB batch limit, should fit ~100 events per batch
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 250, // Use default
|
||||
Interval: 5 * time.Second, // Long interval - rely on Close() to flush
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send 50 small events - should all fit in one batch
|
||||
pool := NewEventPoolWithCardinality(50, CardinalityLow)
|
||||
for i := 0; i < 50; i++ {
|
||||
err := client.Enqueue(pool.Next())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
client.Close()
|
||||
|
||||
require.Equal(t, int64(50), totalMessages.Load(), "All 50 events should be delivered")
|
||||
// Small events should batch together efficiently
|
||||
require.LessOrEqual(t, batchCount.Load(), int64(3), "50 small events should fit in 3 or fewer batches")
|
||||
}
|
||||
|
||||
// TestBatching_LargeEventsTriggerFlush verifies that large events trigger batch flushes
|
||||
func TestBatching_LargeEventsTriggerFlush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var batchCount atomic.Int64
|
||||
var batchSizes []int
|
||||
var mu sync.Mutex
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
batchCount.Add(1)
|
||||
mu.Lock()
|
||||
batchSizes = append(batchSizes, len(b.Messages))
|
||||
mu.Unlock()
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Medium-high cardinality events (~2000 props, ~100KB each)
|
||||
// With 500KB batch limit, should fit ~5 events per batch
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 250, // Use default - byte limit should trigger before count
|
||||
Interval: 50 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send 10 medium-high cardinality events
|
||||
pool := NewEventPoolWithCardinality(10, CardinalityMedium)
|
||||
for i := 0; i < 10; i++ {
|
||||
err := client.Enqueue(pool.Next())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
client.Close()
|
||||
|
||||
// Medium cardinality events should trigger multiple batches
|
||||
require.GreaterOrEqual(t, batchCount.Load(), int64(1), "Should have at least 1 batch")
|
||||
t.Logf("Batch count: %d, sizes: %v", batchCount.Load(), batchSizes)
|
||||
}
|
||||
|
||||
// TestBatching_OversizedEventRejected verifies that events >500KB are rejected
|
||||
func TestBatching_OversizedEventRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var received atomic.Int64
|
||||
var failureCount atomic.Int64
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
callback := &testCallbackCounter{
|
||||
onFailure: func() { failureCount.Add(1) },
|
||||
}
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
Callback: callback,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an event with MANY properties to exceed 500KB
|
||||
// Each property is ~50 bytes, need ~10000 properties to hit 500KB
|
||||
oversizedProps := make(Properties, 15000)
|
||||
for i := 0; i < 15000; i++ {
|
||||
oversizedProps[generateDistinctId(i)] = generateDistinctId(i + 100000)
|
||||
}
|
||||
|
||||
err = client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "oversized_event",
|
||||
Properties: oversizedProps,
|
||||
})
|
||||
require.NoError(t, err) // Enqueue itself doesn't fail
|
||||
|
||||
client.Close()
|
||||
|
||||
// The oversized event should be rejected via callback
|
||||
require.Equal(t, int64(0), received.Load(), "Oversized event should not be delivered")
|
||||
require.Equal(t, int64(1), failureCount.Load(), "Should have 1 failure callback for oversized event")
|
||||
}
|
||||
|
||||
// TestBatching_MixedCardinalityBatching verifies correct batching with mixed event sizes
|
||||
func TestBatching_MixedCardinalityBatching(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var batchCount atomic.Int64
|
||||
var totalMessages atomic.Int64
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
batchCount.Add(1)
|
||||
totalMessages.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 250,
|
||||
Interval: 50 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send mix of small and medium events
|
||||
smallPool := NewEventPoolWithCardinality(30, CardinalityLow)
|
||||
mediumPool := NewEventPoolWithCardinality(20, CardinalityMedium)
|
||||
|
||||
// Interleave small and medium events
|
||||
for i := 0; i < 30; i++ {
|
||||
err := client.Enqueue(smallPool.Next())
|
||||
require.NoError(t, err)
|
||||
if i < 20 {
|
||||
err := client.Enqueue(mediumPool.Next())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
client.Close()
|
||||
|
||||
require.Equal(t, int64(50), totalMessages.Load(), "All 50 events should be delivered")
|
||||
t.Logf("Batch count for mixed cardinality: %d", batchCount.Load())
|
||||
}
|
||||
|
||||
// TestBatching_BatchCountLimit verifies BatchSize config is respected
|
||||
func TestBatching_BatchCountLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var batchSizes []int
|
||||
var mu sync.Mutex
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
mu.Lock()
|
||||
batchSizes = append(batchSizes, len(b.Messages))
|
||||
mu.Unlock()
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
batchSize := 10
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: batchSize,
|
||||
Interval: 10 * time.Millisecond, // Short interval so batch count limit triggers
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send 25 small events with BatchSize=10 and short interval
|
||||
// The batch count limit should trigger before interval flush
|
||||
pool := NewEventPoolWithCardinality(25, CardinalityLow)
|
||||
for i := 0; i < 25; i++ {
|
||||
err := client.Enqueue(pool.Next())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for batches to be flushed before closing
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
client.Close()
|
||||
|
||||
// Check that no batch exceeds BatchSize
|
||||
for i, size := range batchSizes {
|
||||
require.LessOrEqual(t, size, batchSize, "Batch %d has size %d which exceeds BatchSize %d", i, size, batchSize)
|
||||
}
|
||||
t.Logf("Batch sizes: %v", batchSizes)
|
||||
}
|
||||
|
||||
// testCallbackCounter is a simple callback for counting successes and failures
|
||||
type testCallbackCounter struct {
|
||||
onSuccess func()
|
||||
onFailure func()
|
||||
}
|
||||
|
||||
func (c *testCallbackCounter) Success(msg APIMessage) {
|
||||
if c.onSuccess != nil {
|
||||
c.onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testCallbackCounter) Failure(msg APIMessage, err error) {
|
||||
if c.onFailure != nil {
|
||||
c.onFailure()
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigBatchSubmitTimeout verifies the default and custom BatchSubmitTimeout config
|
||||
func TestConfigBatchSubmitTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test default value
|
||||
cfg := makeConfig(Config{})
|
||||
require.Equal(t, DefaultBatchSubmitTimeout, cfg.BatchSubmitTimeout, "default BatchSubmitTimeout should be %v", DefaultBatchSubmitTimeout)
|
||||
|
||||
// Test custom value
|
||||
customTimeout := 200 * time.Millisecond
|
||||
cfg = makeConfig(Config{BatchSubmitTimeout: customTimeout})
|
||||
require.Equal(t, customTimeout, cfg.BatchSubmitTimeout, "custom BatchSubmitTimeout should be preserved")
|
||||
|
||||
// Test negative value (non-blocking mode)
|
||||
cfg = makeConfig(Config{BatchSubmitTimeout: -1})
|
||||
require.Equal(t, time.Duration(-1), cfg.BatchSubmitTimeout, "negative BatchSubmitTimeout should be preserved")
|
||||
}
|
||||
|
||||
// TestBatchSubmitTimeout_WaitsForWorkers verifies that batch submission waits
|
||||
// when queue is full, giving workers time to complete during latency spikes.
|
||||
// Note: params are tuned for reliability under -race (adds ~10x overhead).
|
||||
func TestBatchSubmitTimeout_WaitsForWorkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var batchCount int
|
||||
|
||||
// Create a handler with moderate latency
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
mu.Lock()
|
||||
batchCount++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Config: queue buffer = MaxEnqueuedRequests = 10
|
||||
// With 20 events, queue will fill and submissions must wait for goroutines
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // 1 event per batch
|
||||
Interval: 1 * time.Millisecond, // Flush immediately
|
||||
MaxEnqueuedRequests: 10, // Queue buffer = 10
|
||||
BatchSubmitTimeout: 500 * time.Millisecond, // Ample time for race mode
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send 20 events - will exceed queue buffer (10), so some must wait
|
||||
for i := 0; i < 20; i++ {
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: "test-user",
|
||||
Event: "test-event",
|
||||
})
|
||||
}
|
||||
|
||||
client.Close()
|
||||
|
||||
mu.Lock()
|
||||
finalBatchCount := batchCount
|
||||
mu.Unlock()
|
||||
|
||||
// With 500ms timeout and 20ms latency, 10 workers can process ~250 batches
|
||||
// All 20 events should be delivered
|
||||
require.GreaterOrEqual(t, finalBatchCount, 18, "With BatchSubmitTimeout, nearly all events should be delivered")
|
||||
}
|
||||
|
||||
// TestBatchSubmitTimeout_NonBlocking verifies that negative timeout gives non-blocking behavior
|
||||
func TestBatchSubmitTimeout_NonBlocking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var successCount int
|
||||
var failureCount int
|
||||
|
||||
// Create a very slow handler to saturate workers
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond) // Very slow backend
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
callback := &testCallbackCounter{
|
||||
onSuccess: func() {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
},
|
||||
onFailure: func() {
|
||||
mu.Lock()
|
||||
failureCount++
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
|
||||
// Use non-blocking mode with negative timeout
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // 1 event per batch
|
||||
Interval: 1 * time.Millisecond, // Flush immediately
|
||||
MaxEnqueuedRequests: 1, // Only 1 batch can be queued
|
||||
BatchSubmitTimeout: -1, // Non-blocking (immediate drop)
|
||||
Callback: callback,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Blast events as fast as possible - no sleep between enqueues.
|
||||
// This ensures the channel fills faster than processBatch goroutines can drain it,
|
||||
// causing the non-blocking send to drop events when the queue is full.
|
||||
for i := 0; i < 100; i++ {
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: "test-user",
|
||||
Event: "test-event",
|
||||
})
|
||||
}
|
||||
|
||||
client.Close()
|
||||
|
||||
mu.Lock()
|
||||
finalSuccess := successCount
|
||||
finalFailure := failureCount
|
||||
mu.Unlock()
|
||||
|
||||
t.Logf("Non-blocking mode: %d succeeded, %d failed", finalSuccess, finalFailure)
|
||||
|
||||
// In non-blocking mode with slow backend, some events should be dropped
|
||||
require.Greater(t, finalFailure, 0, "In non-blocking mode with slow backend, some events should be dropped")
|
||||
}
|
||||
|
||||
// TestShutdownTimeout_DefaultWaitsForCompletion verifies that with default config
|
||||
// (ShutdownTimeout=0), Close() waits indefinitely for in-flight batches to complete.
|
||||
func TestShutdownTimeout_DefaultWaitsForCompletion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var received atomic.Int64
|
||||
|
||||
// Create a slow server (200ms per request)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Default config - ShutdownTimeout is zero (wait indefinitely)
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 5,
|
||||
Interval: 10 * time.Millisecond,
|
||||
// ShutdownTimeout is intentionally not set (zero value = wait indefinitely)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send events
|
||||
for i := 0; i < 10; i++ {
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "test-user",
|
||||
Event: "test-event",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Close should wait for all events to be delivered despite slow server
|
||||
start := time.Now()
|
||||
err = client.Close()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.NoError(t, err, "Close() should succeed without error when waiting indefinitely")
|
||||
require.Equal(t, int64(10), received.Load(), "All events should be delivered")
|
||||
require.GreaterOrEqual(t, elapsed, 200*time.Millisecond, "Close() should have waited for slow server")
|
||||
t.Logf("Close() waited %v for slow server to complete", elapsed)
|
||||
}
|
||||
|
||||
// TestShutdownTimeout_AbortsAfterTimeout verifies that when ShutdownTimeout is set,
|
||||
// Close() aborts in-flight requests after the timeout and returns an error.
|
||||
func TestShutdownTimeout_AbortsAfterTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var received atomic.Int64
|
||||
var mu sync.Mutex
|
||||
var failureCount int
|
||||
|
||||
// Create a very slow server (2s per request) - will exceed our timeout
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
callback := &testCallbackCounter{
|
||||
onFailure: func() {
|
||||
mu.Lock()
|
||||
failureCount++
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
|
||||
// Set a short shutdown timeout (100ms)
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 5,
|
||||
Interval: 10 * time.Millisecond,
|
||||
ShutdownTimeout: 100 * time.Millisecond, // Short timeout - will abort
|
||||
Callback: callback,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send events
|
||||
for i := 0; i < 10; i++ {
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "test-user",
|
||||
Event: "test-event",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Close should abort after timeout
|
||||
start := time.Now()
|
||||
err = client.Close()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should return a timeout error
|
||||
require.Error(t, err, "Close() should return error when timeout is exceeded")
|
||||
require.Contains(t, err.Error(), "shutdown timeout", "Error should mention shutdown timeout")
|
||||
|
||||
// Should have aborted relatively quickly (not waited 2s for server)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "Close() should abort quickly, not wait for slow server")
|
||||
|
||||
// Some events may have been dropped
|
||||
mu.Lock()
|
||||
failures := failureCount
|
||||
mu.Unlock()
|
||||
t.Logf("Close() aborted after %v, received=%d, failures=%d", elapsed, received.Load(), failures)
|
||||
}
|
||||
|
||||
// TestCloseWithContext_RespectsDeadline verifies that CloseWithContext honors
|
||||
// the provided context's deadline for shutdown.
|
||||
func TestCloseWithContext_RespectsDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var received atomic.Int64
|
||||
|
||||
// Create a slow server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// No ShutdownTimeout configured - will use context deadline instead
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 5,
|
||||
Interval: 10 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send events
|
||||
for i := 0; i < 10; i++ {
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "test-user",
|
||||
Event: "test-event",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Use CloseWithContext with a short deadline
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err = client.CloseWithContext(ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should return a timeout error
|
||||
require.Error(t, err, "CloseWithContext should return error when context deadline exceeded")
|
||||
require.Contains(t, err.Error(), "shutdown timeout", "Error should mention shutdown timeout")
|
||||
|
||||
// Should have aborted at context deadline
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "CloseWithContext should respect context deadline")
|
||||
t.Logf("CloseWithContext aborted after %v", elapsed)
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BenchmarkCallback is a lightweight callback for benchmarks
|
||||
// Only tracks failures to avoid overhead on the success path
|
||||
type BenchmarkCallback struct {
|
||||
failureCount atomic.Int64
|
||||
}
|
||||
|
||||
func (c *BenchmarkCallback) Success(msg APIMessage) {
|
||||
// No-op - tracking successes would add overhead to every message
|
||||
}
|
||||
|
||||
func (c *BenchmarkCallback) Failure(msg APIMessage, err error) {
|
||||
c.failureCount.Add(1)
|
||||
}
|
||||
|
||||
func (c *BenchmarkCallback) FailureCount() int64 {
|
||||
return c.failureCount.Load()
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentEnqueue measures throughput at different concurrency levels
|
||||
// Tests: 1, 10, 100, 500, 1000 concurrent goroutines
|
||||
// Uses NoOpTransport to measure pure enqueue throughput without HTTP overhead
|
||||
// Note: Under extreme load, some backpressure (delivery failures) is expected
|
||||
func BenchmarkConcurrentEnqueue(b *testing.B) {
|
||||
concurrencyLevels := []int{1, 10, 100, 500, 1000}
|
||||
|
||||
for _, concurrency := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("goroutines_%d", concurrency), func(b *testing.B) {
|
||||
// Pre-generate events BEFORE benchmark
|
||||
pool := NewEventPool(b.N + 1000) // Extra events to avoid index issues
|
||||
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
Callback: callback,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
b.SetParallelism(concurrency)
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
if err := client.Enqueue(pool.Next()); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.StopTimer()
|
||||
|
||||
// Enqueue errors indicate channel full - fail immediately
|
||||
if enqueueErrors.Load() > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors (msgs channel full)", enqueueErrors.Load())
|
||||
}
|
||||
// Delivery failures indicate batches channel backpressure - report but don't fail
|
||||
if failures := callback.FailureCount(); failures > 0 {
|
||||
b.Logf("Note: %d delivery failures (backpressure at %d goroutines)", failures, concurrency)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentEnqueueWithCardinality measures throughput with different property cardinalities
|
||||
// Uses NoOpTransport to measure pure enqueue throughput without HTTP overhead
|
||||
// Note: Under extreme load, some backpressure (delivery failures) is expected
|
||||
func BenchmarkConcurrentEnqueueWithCardinality(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low_10-100_props", CardinalityLow},
|
||||
{"medium_500-2000_props", CardinalityMedium},
|
||||
{"high_2000-10000_props", CardinalityHigh},
|
||||
}
|
||||
concurrency := 100
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
// Use cardinality-appropriate pool size with pre-cloned unique maps
|
||||
pool := NewEventPoolWithDefaultSize(tc.cardinality)
|
||||
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
Callback: callback,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
b.SetParallelism(concurrency)
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
if err := client.Enqueue(pool.Next()); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.StopTimer()
|
||||
|
||||
// Enqueue errors indicate channel full - fail immediately
|
||||
if enqueueErrors.Load() > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors (msgs channel full)", enqueueErrors.Load())
|
||||
}
|
||||
// Delivery failures indicate batches channel backpressure - report but don't fail
|
||||
if failures := callback.FailureCount(); failures > 0 {
|
||||
b.Logf("Note: %d delivery failures (backpressure at %d goroutines)", failures, concurrency)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEnqueueThroughput measures raw enqueue speed (no HTTP)
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
func BenchmarkEnqueueThroughput(b *testing.B) {
|
||||
pool := NewEventPool(b.N + 1000)
|
||||
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
// Use a transport that returns immediately (no actual HTTP)
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
Callback: callback,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := client.Enqueue(pool.Next()); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
|
||||
enqueueErrors.Load(), failures)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEnqueueThroughputWithCardinality measures enqueue speed at different cardinalities
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
func BenchmarkEnqueueThroughputWithCardinality(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low", CardinalityLow},
|
||||
{"medium", CardinalityMedium},
|
||||
{"high", CardinalityHigh},
|
||||
}
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
// Use cardinality-appropriate pool size with pre-cloned unique maps
|
||||
pool := NewEventPoolWithDefaultSize(tc.cardinality)
|
||||
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
Callback: callback,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := client.Enqueue(pool.Next()); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
|
||||
enqueueErrors.Load(), failures)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFeatureFlagLocalEvaluation measures flag evaluation performance
|
||||
func BenchmarkFeatureFlagLocalEvaluation(b *testing.B) {
|
||||
// Setup server that returns actual flag definitions
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"):
|
||||
w.Write([]byte(fixture("test-api-feature-flag.json")))
|
||||
case strings.HasPrefix(r.URL.Path, "/batch"):
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(200)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
PersonalApiKey: "test",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Pre-warm cache - wait for flags to load
|
||||
for i := 0; i < 10; i++ {
|
||||
result, _ := client.GetFeatureFlag(FeatureFlagPayload{Key: "simpleFlag", DistinctId: "warmup"})
|
||||
if result != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
distinctIds := make([]string, 1000)
|
||||
for i := range distinctIds {
|
||||
distinctIds[i] = fmt.Sprintf("user_%d", i)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "simpleFlag",
|
||||
DistinctId: distinctIds[i%len(distinctIds)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBatchSizes measures throughput at various batch sizes
|
||||
// Note: BatchSize=1 is excluded as it's pathological - creates one batch per event,
|
||||
// overwhelming the worker pool with backpressure. Production min should be 10+.
|
||||
func BenchmarkBatchSizes(b *testing.B) {
|
||||
batchSizes := []int{10, 50, 100, 250, 500}
|
||||
|
||||
for _, batchSize := range batchSizes {
|
||||
b.Run(fmt.Sprintf("batch_%d", batchSize), func(b *testing.B) {
|
||||
pool := NewEventPool(b.N + 1000)
|
||||
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
Callback: callback,
|
||||
BatchSize: batchSize,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := client.Enqueue(pool.Next()); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
|
||||
enqueueErrors.Load(), failures)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEndToEndWithServer measures end-to-end throughput including HTTP
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
func BenchmarkEndToEndWithServer(b *testing.B) {
|
||||
pool := NewEventPool(b.N + 1000)
|
||||
|
||||
server := httptest.NewServer(NoOpHandler())
|
||||
defer server.Close()
|
||||
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
Callback: callback,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := client.Enqueue(pool.Next()); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
|
||||
enqueueErrors.Load(), failures)
|
||||
}
|
||||
}
|
||||
|
||||
// NoOpHandler returns an HTTP handler that discards the body and returns 200
|
||||
func NoOpHandler() noOpHandler {
|
||||
return noOpHandler{}
|
||||
}
|
||||
|
||||
type noOpHandler struct{}
|
||||
|
||||
func (h noOpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(200)
|
||||
}
|
||||
|
||||
// BenchmarkEventPoolGeneration measures event pool creation time
|
||||
func BenchmarkEventPoolGeneration(b *testing.B) {
|
||||
sizes := []int{100, 1000, 10000}
|
||||
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("size_%d", size), func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
NewEventPool(size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEventPoolGenerationWithCardinality measures pool creation with cardinality
|
||||
func BenchmarkEventPoolGenerationWithCardinality(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low", CardinalityLow},
|
||||
{"medium", CardinalityMedium},
|
||||
{"high", CardinalityHigh},
|
||||
}
|
||||
|
||||
size := 100 // Keep small for high cardinality
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
NewEventPoolWithCardinality(size, tc.cardinality)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkValidation measures message validation overhead
|
||||
func BenchmarkValidation(b *testing.B) {
|
||||
capture := Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
Properties: generateVariedProperties(42),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
capture.Validate()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkAPIfy measures APIfy conversion overhead
|
||||
func BenchmarkAPIfy(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low", CardinalityLow},
|
||||
{"medium", CardinalityMedium},
|
||||
{"high", CardinalityHigh},
|
||||
}
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
capture := Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(42, tc.cardinality),
|
||||
Groups: generateGroupsWithCardinality(42, tc.cardinality),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
capture.APIfy()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMessageEnqueueOverhead measures the overhead of the Enqueue path
|
||||
func BenchmarkMessageEnqueueOverhead(b *testing.B) {
|
||||
callback := &BenchmarkCallback{}
|
||||
var enqueueErrors atomic.Int64
|
||||
|
||||
// Create a client with a very fast transport
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}, nil
|
||||
}),
|
||||
Callback: callback,
|
||||
BatchSize: 10000, // Large batch to avoid flushing during benchmark
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Pre-generate captures to avoid map race conditions
|
||||
// Each iteration needs its own Properties map since APIfy iterates over it
|
||||
captures := make([]Capture, b.N)
|
||||
for i := range captures {
|
||||
captures[i] = Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
Properties: generateVariedProperties(i),
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := client.Enqueue(captures[i]); err != nil {
|
||||
enqueueErrors.Add(1)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
|
||||
if failures := callback.FailureCount(); enqueueErrors.Load() > 0 || failures > 0 {
|
||||
b.Fatalf("Benchmark invalid: %d enqueue errors, %d delivery failures",
|
||||
enqueueErrors.Load(), failures)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,8 @@
|
||||
package posthog
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendFeatureFlagsValue defines the interface for feature flag configuration
|
||||
type SendFeatureFlagsValue interface {
|
||||
@@ -147,3 +149,4 @@ func (msg Capture) APIfy() APIMessage {
|
||||
|
||||
return apified
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,11 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/posthog/posthog-go"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConcurrentEnqueue tests thread-safety of the client under concurrent load
|
||||
func TestConcurrentEnqueue(t *testing.T) {
|
||||
t.Parallel()
|
||||
goroutines := 20
|
||||
eventsPerGoroutine := 50
|
||||
totalEvents := goroutines * eventsPerGoroutine
|
||||
|
||||
pool := NewEventPool(totalEvents)
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < eventsPerGoroutine; i++ {
|
||||
idx := goroutineID*eventsPerGoroutine + i
|
||||
err := client.Enqueue(pool.Get(idx))
|
||||
if err != nil {
|
||||
t.Errorf("Enqueue failed: %v", err)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
require.Equal(t, int64(totalEvents), received.Load(),
|
||||
"All events should be delivered")
|
||||
}
|
||||
|
||||
// TestConcurrentEnqueueDifferentMessageTypes tests concurrent enqueueing of different message types
|
||||
func TestConcurrentEnqueueDifferentMessageTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
goroutines := 10
|
||||
messagesPerGoroutine := 20
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < messagesPerGoroutine; i++ {
|
||||
msgType := i % 4
|
||||
switch msgType {
|
||||
case 0:
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
||||
Event: fmt.Sprintf("event_%d", i),
|
||||
})
|
||||
case 1:
|
||||
client.Enqueue(Identify{
|
||||
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
||||
Properties: Properties{"name": fmt.Sprintf("User %d", goroutineID)},
|
||||
})
|
||||
case 2:
|
||||
client.Enqueue(Alias{
|
||||
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
||||
Alias: fmt.Sprintf("alias_%d", goroutineID),
|
||||
})
|
||||
case 3:
|
||||
client.Enqueue(GroupIdentify{
|
||||
Type: "company",
|
||||
Key: fmt.Sprintf("company_%d", goroutineID),
|
||||
Properties: Properties{
|
||||
"name": fmt.Sprintf("Company %d", goroutineID),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
expectedTotal := int64(goroutines * messagesPerGoroutine)
|
||||
require.Equal(t, expectedTotal, received.Load(),
|
||||
"All messages should be delivered")
|
||||
}
|
||||
|
||||
// TestConcurrentFeatureFlagEvaluation tests concurrent flag evaluation with cache updates
|
||||
func TestConcurrentFeatureFlagEvaluation(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Setup mock server with feature flags
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
// Return proper format for both local evaluation and flags endpoints
|
||||
if r.URL.Path == "/api/feature_flag/local_evaluation" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"flags": []map[string]interface{}{
|
||||
{
|
||||
"id": 1,
|
||||
"key": "test-flag",
|
||||
"active": true,
|
||||
"filters": map[string]interface{}{
|
||||
"groups": []map[string]interface{}{
|
||||
{
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"group_type_mapping": map[string]string{},
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"featureFlags": map[string]interface{}{
|
||||
"test-flag": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
PersonalApiKey: "test-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
// Wait for initial flag fetch to complete
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
goroutines := 100
|
||||
evaluationsPerGoroutine := 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorCount := atomic.Int64{}
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < evaluationsPerGoroutine; i++ {
|
||||
distinctId := fmt.Sprintf("user_%d_%d", goroutineID, i)
|
||||
_, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "test-flag",
|
||||
DistinctId: distinctId,
|
||||
})
|
||||
if err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// With httptest.Server, there are no network issues - all evaluations should succeed
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"All feature flag evaluations should succeed - httptest.Server has no network issues")
|
||||
}
|
||||
|
||||
// TestConcurrentClientOperations tests mixing Enqueue with Close operations
|
||||
func TestConcurrentClientOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This tests that Close() properly waits for all pending operations
|
||||
|
||||
for iteration := 0; iteration < 5; iteration++ {
|
||||
t.Run(fmt.Sprintf("iteration_%d", iteration), func(t *testing.T) {
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 50,
|
||||
// Uses production defaults for MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Start enqueueing
|
||||
enqueueCount := 50
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < enqueueCount; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", idx),
|
||||
Event: "test_event",
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all enqueues to complete
|
||||
wg.Wait()
|
||||
|
||||
// Close should wait for all events to be sent
|
||||
err = client.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// All events should have been delivered by the time Close returns
|
||||
require.Equal(t, int64(enqueueCount), received.Load(),
|
||||
"All events should be delivered before Close returns")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentEnqueueWithSlowServer tests behavior with a slow server
|
||||
func TestConcurrentEnqueueWithSlowServer(t *testing.T) {
|
||||
goroutines := 20
|
||||
eventsPerGoroutine := 10
|
||||
totalEvents := goroutines * eventsPerGoroutine
|
||||
|
||||
pool := NewEventPool(totalEvents)
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate slow server
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < eventsPerGoroutine; i++ {
|
||||
idx := goroutineID*eventsPerGoroutine + i
|
||||
client.Enqueue(pool.Get(idx))
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("Slow server test: %d events in %v", totalEvents, elapsed)
|
||||
|
||||
require.Equal(t, int64(totalEvents), received.Load(),
|
||||
"All events should be delivered even with slow server")
|
||||
}
|
||||
|
||||
// TestConcurrentPrepareForSend tests that prepareForSend is thread-safe
|
||||
func TestConcurrentPrepareForSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "test_user",
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(42, CardinalityMedium),
|
||||
Groups: generateGroupsWithCardinality(42, CardinalityMedium),
|
||||
}
|
||||
|
||||
goroutines := 100
|
||||
callsPerGoroutine := 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]int, goroutines)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
var total int
|
||||
for i := 0; i < callsPerGoroutine; i++ {
|
||||
data, _, err := prepareForSend(capture)
|
||||
if err != nil {
|
||||
t.Errorf("prepareForSend error: %v", err)
|
||||
return
|
||||
}
|
||||
total += len(data)
|
||||
}
|
||||
results[goroutineID] = total / callsPerGoroutine
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// All goroutines should get the same average result
|
||||
first := results[0]
|
||||
for i, result := range results {
|
||||
if result != first {
|
||||
t.Errorf("Inconsistent prepareForSend size: goroutine 0 got %d, goroutine %d got %d",
|
||||
first, i, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentEventPoolAccess tests thread-safe access to EventPool
|
||||
func TestConcurrentEventPoolAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
poolSize := 1000
|
||||
pool := NewEventPool(poolSize)
|
||||
|
||||
goroutines := 50
|
||||
accessesPerGoroutine := 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorCount := atomic.Int64{}
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < accessesPerGoroutine; i++ {
|
||||
event := pool.Next()
|
||||
if event.DistinctId == "" || event.Event == "" {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"No errors should occur during concurrent pool access")
|
||||
}
|
||||
|
||||
// TestConcurrentCallbackExecution tests that callbacks are executed thread-safely
|
||||
func TestConcurrentCallbackExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
callback := NewUnifiedCallback(t)
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 50,
|
||||
Callback: callback,
|
||||
// Uses production defaults for MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
goroutines := 10
|
||||
eventsPerGoroutine := 20
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < eventsPerGoroutine; i++ {
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", goroutineID),
|
||||
Event: fmt.Sprintf("event_%d", i),
|
||||
})
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
successCount, failureCount := callback.GetCounts()
|
||||
t.Logf("Callback counts: success=%d, failure=%d", successCount, failureCount)
|
||||
|
||||
// All events should have been delivered with zero failures
|
||||
totalEvents := int64(goroutines * eventsPerGoroutine)
|
||||
require.Equal(t, totalEvents, received.Load(),
|
||||
"All events should be delivered")
|
||||
require.Equal(t, 0, failureCount,
|
||||
"No callback failures should occur")
|
||||
}
|
||||
|
||||
// TestRaceConditionDetection is specifically designed to trigger race detection
|
||||
// Run with: go test -race -run TestRaceConditionDetection
|
||||
func TestRaceConditionDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("concurrent_enqueue_and_close", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent enqueues
|
||||
for j := 0; j < 20; j++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", idx),
|
||||
Event: "test",
|
||||
})
|
||||
}(j)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent_pool_access", func(t *testing.T) {
|
||||
pool := NewEventPool(100)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
_ = pool.Next()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type Config struct {
|
||||
|
||||
// The flushing interval of the client. Messages will be sent when they've
|
||||
// been queued up to the maximum batch size or when the flushing interval
|
||||
// timer triggers.
|
||||
// timer triggers. If zero, defaults to 5 seconds.
|
||||
Interval time.Duration
|
||||
|
||||
// Interval at which to fetch new feature flag definitions, 5min by default
|
||||
@@ -69,7 +69,7 @@ type Config struct {
|
||||
|
||||
// The maximum number of messages that will be sent in one API call.
|
||||
// Messages will be sent when they've been queued up to the maximum batch
|
||||
// size or when the flushing interval timer triggers.
|
||||
// size or when the flushing interval timer triggers. If zero, defaults to 250.
|
||||
// Note that the API will still enforce a 500KB limit on each HTTP request
|
||||
// which is independent from the number of embedded messages.
|
||||
BatchSize int
|
||||
@@ -85,19 +85,37 @@ type Config struct {
|
||||
// If not set the client will fallback to use a default retry policy.
|
||||
RetryAfter func(int) time.Duration
|
||||
|
||||
// MaxRetries is a maximum number of a request is retried on failure.
|
||||
// MaxRetries is the maximum number of times a request is retried on failure.
|
||||
// Must be in range [0,9]. If nil, defaults to 9 (10 total attempts).
|
||||
MaxRetries *int
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for in-flight messages
|
||||
// to be sent during Close(). If zero or negative, waits indefinitely
|
||||
// (preserving backward-compatible behavior). Set to a positive duration
|
||||
// to enable timeout-based shutdown.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// BatchUploadTimeout is the timeout for uploading batched events to the
|
||||
// /batch/ endpoint. If zero, defaults to 10 seconds.
|
||||
BatchUploadTimeout time.Duration
|
||||
|
||||
// BatchSubmitTimeout is the maximum time to wait when submitting a batch
|
||||
// to the worker pool if the queue is full. This provides backpressure
|
||||
// smoothing during transient backend latency spikes, reducing data loss.
|
||||
// If zero, defaults to 100ms. Set to a negative value for non-blocking
|
||||
// behavior (immediate drop when queue is full).
|
||||
BatchSubmitTimeout time.Duration
|
||||
|
||||
// MaxEnqueuedRequests is the maximum number of batches that can be queued
|
||||
// for sending. When the queue is full, new batches are dropped and the
|
||||
// failure callback is invoked. If zero, defaults to 1000.
|
||||
MaxEnqueuedRequests int
|
||||
|
||||
// A function called by the client to get the current time, `time.Now` is
|
||||
// used by default.
|
||||
// This field is not exported and only exposed internally to control concurrency.
|
||||
now func() time.Time
|
||||
|
||||
// The maximum number of goroutines that will be spawned by a client to send
|
||||
// requests to the backend API.
|
||||
// This field is not exported and only exposed internally to control concurrency.
|
||||
maxConcurrentRequests int
|
||||
|
||||
// maxAttempts is a maximum numbers we try to send data to capture endpoint, must be in range [1,10].
|
||||
maxAttempts int
|
||||
}
|
||||
@@ -128,6 +146,19 @@ const (
|
||||
// DefaultBatchSize sets the default batch size used by client instances if none
|
||||
// was explicitly set.
|
||||
DefaultBatchSize = 250
|
||||
|
||||
// DefaultBatchUploadTimeout is the default timeout for uploading batched
|
||||
// events to the /batch/ endpoint.
|
||||
DefaultBatchUploadTimeout = 10 * time.Second
|
||||
|
||||
// DefaultBatchSubmitTimeout is the default timeout for submitting batches
|
||||
// to the worker pool when the queue is full. This allows workers time to
|
||||
// complete during transient latency spikes, reducing unnecessary data loss.
|
||||
DefaultBatchSubmitTimeout = 100 * time.Millisecond
|
||||
|
||||
// DefaultMaxEnqueuedRequests is the default maximum number of batches that
|
||||
// can be queued for sending.
|
||||
DefaultMaxEnqueuedRequests = 1000
|
||||
)
|
||||
|
||||
// Validate verifies that fields that don't have zero-values are set to valid values,
|
||||
@@ -157,7 +188,7 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.MaxRetries != nil && (*c.MaxRetries < 0 || 9 < *c.MaxRetries) {
|
||||
if c.MaxRetries != nil && (*c.MaxRetries < 0 || *c.MaxRetries > 9) {
|
||||
return ConfigError{
|
||||
Reason: "max retries out of range [0,9]",
|
||||
Field: "MaxRetries",
|
||||
@@ -187,9 +218,8 @@ func makeConfig(c Config) Config {
|
||||
c.FeatureFlagRequestTimeout = DefaultFeatureFlagRequestTimeout
|
||||
}
|
||||
|
||||
if c.Transport == nil {
|
||||
c.Transport = http.DefaultTransport
|
||||
}
|
||||
// Note: c.Transport == nil is handled by makeHttpClient() which clones
|
||||
// DefaultTransport with tuned connection pool settings
|
||||
|
||||
if c.Logger == nil {
|
||||
c.Logger = newDefaultLogger(c.Verbose)
|
||||
@@ -213,8 +243,19 @@ func makeConfig(c Config) Config {
|
||||
c.maxAttempts = 10
|
||||
}
|
||||
|
||||
if c.maxConcurrentRequests == 0 {
|
||||
c.maxConcurrentRequests = 1000
|
||||
// Note: ShutdownTimeout == 0 means wait indefinitely (backward compatible).
|
||||
// Users opt-in to timeout by setting a positive duration.
|
||||
|
||||
if c.BatchUploadTimeout == 0 {
|
||||
c.BatchUploadTimeout = DefaultBatchUploadTimeout
|
||||
}
|
||||
|
||||
if c.BatchSubmitTimeout == 0 {
|
||||
c.BatchSubmitTimeout = DefaultBatchSubmitTimeout
|
||||
}
|
||||
|
||||
if c.MaxEnqueuedRequests == 0 {
|
||||
c.MaxEnqueuedRequests = DefaultMaxEnqueuedRequests
|
||||
}
|
||||
|
||||
if c.GetDisableGeoIP() {
|
||||
|
||||
@@ -57,4 +57,8 @@ var (
|
||||
// failed because the JSON representation of a message exceeded the upper
|
||||
// limit.
|
||||
ErrMessageTooBig = errors.New("the message exceeds the maximum allowed size")
|
||||
|
||||
// ErrNoPersonalAPIKey is returned when one tries to use feature flags
|
||||
// without specifying a PersonalAPIKey.
|
||||
ErrNoPersonalAPIKey = errors.New("no PersonalAPIKey provided")
|
||||
)
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
package posthog
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ Message = (*Exception)(nil)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/posthog/posthog-go"
|
||||
)
|
||||
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
package posthog
|
||||
|
||||
import "sync"
|
||||
|
||||
type executor struct {
|
||||
m sync.Mutex
|
||||
available int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newExecutor(cap int) *executor {
|
||||
e := &executor{
|
||||
available: cap,
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *executor) do(task func()) bool {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
|
||||
if e.closed || e.available <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
e.available--
|
||||
go func() {
|
||||
defer func() {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
e.available++
|
||||
}()
|
||||
task()
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *executor) close() {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
e.closed = true
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExecutorClose(t *testing.T) {
|
||||
// Simply make sure that nothing raises a panic nor blocks.
|
||||
ex := newExecutor(1)
|
||||
ex.close()
|
||||
}
|
||||
|
||||
func TestExecutorSimple(t *testing.T) {
|
||||
wg := &sync.WaitGroup{}
|
||||
ex := newExecutor(1)
|
||||
defer ex.close()
|
||||
|
||||
wg.Add(1)
|
||||
|
||||
if !ex.do(wg.Done) {
|
||||
t.Error("failed pushing a task to an executor with a capacity of 1")
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure wg.Done gets called, this shouldn't block indefinitely.
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestExecutorMulti(t *testing.T) {
|
||||
wg := &sync.WaitGroup{}
|
||||
ex := newExecutor(3)
|
||||
defer ex.close()
|
||||
|
||||
// Schedule a couple of tasks to fill the executor.
|
||||
for i := 0; i != 3; i++ {
|
||||
wg.Add(1)
|
||||
|
||||
if !ex.do(func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
wg.Done()
|
||||
}) {
|
||||
t.Error("failed pushing a task to an executor with a capacity of 1")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the executor refuses more tasks.
|
||||
if ex.do(func() {}) {
|
||||
t.Error("the executor should have been full and refused to run more tasks")
|
||||
}
|
||||
|
||||
// Make sure wg.Done gets called, this shouldn't block indefinitely.
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComplexCohortsLocally(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(fixture("feature_flag/test-complex-cohorts-locally.json"))) // Don't return anything for local eval
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
PersonalApiKey: "some very secret key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := FeatureFlagPayload{
|
||||
Key: "beta-feature",
|
||||
DistinctId: "some-distinct-id",
|
||||
PersonProperties: NewProperties().Set("region", "UK"),
|
||||
}
|
||||
|
||||
isMatch, err := client.IsFeatureEnabled(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isMatch != false {
|
||||
t.Error("Should not match")
|
||||
}
|
||||
|
||||
payload.PersonProperties = NewProperties().Set("region", "USA").Set("other", "thing")
|
||||
isMatch, _ = client.IsFeatureEnabled(payload)
|
||||
if isMatch != true {
|
||||
t.Error("Should match")
|
||||
}
|
||||
|
||||
// even though 'other' property is not present, the cohort should still match since it's an OR condition
|
||||
payload.PersonProperties = NewProperties().Set("region", "USA").Set("nation", "UK")
|
||||
isMatch, _ = client.IsFeatureEnabled(payload)
|
||||
if isMatch != true {
|
||||
t.Error("Should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplexCohortsWithNegationLocally(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(fixture("feature_flag/test-complex-cohorts-negation-locally.json"))) // Don't return anything for local eval
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
PersonalApiKey: "some very secret key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := FeatureFlagPayload{
|
||||
Key: "beta-feature",
|
||||
DistinctId: "some-distinct-id",
|
||||
PersonProperties: NewProperties().Set("region", "UK"),
|
||||
}
|
||||
|
||||
isMatch, err := client.IsFeatureEnabled(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isMatch != false {
|
||||
t.Error("Should not match")
|
||||
}
|
||||
|
||||
// even though 'other' property is not present, the cohort should still match since it's an OR condition
|
||||
payload.PersonProperties = NewProperties().Set("region", "USA").Set("nation", "UK")
|
||||
isMatch, _ = client.IsFeatureEnabled(payload)
|
||||
if isMatch != true {
|
||||
t.Error("Should match")
|
||||
}
|
||||
|
||||
// # since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
|
||||
payload.PersonProperties = NewProperties().Set("region", "USA").Set("other", "thing")
|
||||
_, err = client.IsFeatureEnabled(payload)
|
||||
if err != nil {
|
||||
t.Error("Expected to fail")
|
||||
}
|
||||
|
||||
payload.PersonProperties = NewProperties().Set("region", "USA").Set("other", "thing2")
|
||||
isMatch, _ = client.IsFeatureEnabled(payload)
|
||||
if isMatch != true {
|
||||
t.Error("Should match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFlagDependenciesSimpleChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Flag A",
|
||||
"key": "flag-a",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "email",
|
||||
"operator": "icontains",
|
||||
"value": "@example.com",
|
||||
"type": "person"
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Flag B",
|
||||
"key": "flag-b",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "flag-a",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": ["flag-a"]
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Test when dependency is satisfied
|
||||
result, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-b",
|
||||
DistinctId: "test-user",
|
||||
PersonProperties: NewProperties().Set("email", "[email protected]"),
|
||||
OnlyEvaluateLocally: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, result)
|
||||
|
||||
// Test flag-a directly with email that doesn't match - should be false
|
||||
flagAResult, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-a",
|
||||
DistinctId: "test-user-2",
|
||||
PersonProperties: NewProperties().Set("email", "[email protected]"),
|
||||
OnlyEvaluateLocally: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, flagAResult, "flag-a should be false for email that doesn't contain @example.com")
|
||||
|
||||
// Test when dependency is not satisfied
|
||||
result, err = client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-b",
|
||||
DistinctId: "test-user-2",
|
||||
PersonProperties: NewProperties().Set("email", "[email protected]"),
|
||||
OnlyEvaluateLocally: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, result)
|
||||
}
|
||||
|
||||
func TestFlagDependenciesCircularDependency(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Flag A",
|
||||
"key": "flag-a",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "flag-b",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": []
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Flag B",
|
||||
"key": "flag-b",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "flag-a",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": []
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/") {
|
||||
w.Write([]byte(`{"featureFlags": {}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Both flags should fall back to remote evaluation due to circular dependency
|
||||
result, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-a",
|
||||
DistinctId: "test-user",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, result)
|
||||
|
||||
result, err = client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-b",
|
||||
DistinctId: "test-user",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, result)
|
||||
}
|
||||
|
||||
func TestFlagDependenciesMissingFlag(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Flag A",
|
||||
"key": "flag-a",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "non-existent-flag",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": ["non-existent-flag"]
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/") {
|
||||
w.Write([]byte(`{"featureFlags": {}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Should fall back to remote evaluation because dependency doesn't exist
|
||||
result, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-a",
|
||||
DistinctId: "test-user",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, result)
|
||||
}
|
||||
|
||||
func TestFlagDependenciesComplexChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Flag A",
|
||||
"key": "flag-a",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Flag B",
|
||||
"key": "flag-b",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Flag C",
|
||||
"key": "flag-c",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "flag-a",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": ["flag-a"]
|
||||
},
|
||||
{
|
||||
"key": "flag-b",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": ["flag-b"]
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Flag D",
|
||||
"key": "flag-d",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "flag-c",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": ["flag-a", "flag-b", "flag-c"]
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// All dependencies satisfied - should return true
|
||||
result, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "flag-d",
|
||||
DistinctId: "test-user",
|
||||
OnlyEvaluateLocally: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, result)
|
||||
}
|
||||
|
||||
func TestFlagDependenciesMixedConditions(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base Flag",
|
||||
"key": "base-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Mixed Flag",
|
||||
"key": "mixed-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "base-flag",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag",
|
||||
"dependency_chain": ["base-flag"]
|
||||
},
|
||||
{
|
||||
"key": "email",
|
||||
"operator": "icontains",
|
||||
"value": "@example.com",
|
||||
"type": "person"
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Both flag dependency and email condition satisfied
|
||||
result, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "mixed-flag",
|
||||
DistinctId: "test-user",
|
||||
PersonProperties: NewProperties().Set("email", "[email protected]"),
|
||||
OnlyEvaluateLocally: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, result)
|
||||
|
||||
// Flag dependency satisfied but email condition not satisfied
|
||||
result, err = client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "mixed-flag",
|
||||
DistinctId: "test-user-2",
|
||||
PersonProperties: NewProperties().Set("email", "[email protected]"),
|
||||
OnlyEvaluateLocally: true,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, result)
|
||||
}
|
||||
|
||||
func TestFlagDependenciesMalformedChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base Flag",
|
||||
"key": "base-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Missing Chain Flag",
|
||||
"key": "missing-chain-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "base-flag",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": true,
|
||||
"type": "flag"
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/") {
|
||||
w.Write([]byte(`{"featureFlags": {}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Should fall back to remote evaluation when dependency_chain is missing
|
||||
result, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "missing-chain-flag",
|
||||
DistinctId: "test-user",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, result)
|
||||
}
|
||||
|
||||
// TestMultiLevelMultivariateDependencyChain tests multi-level dependency chains with multivariate flags
|
||||
// This test is equivalent to the EvaluatesMultiLevelMultivariateDependencyChain test in the .NET SDK
|
||||
func TestMultiLevelMultivariateDependencyChain(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
|
||||
w.Write([]byte(`{
|
||||
"flags": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leaf Flag",
|
||||
"key": "leaf-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "email",
|
||||
"operator": "icontains",
|
||||
"value": "control",
|
||||
"type": "person"
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "control"
|
||||
},
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "email",
|
||||
"operator": "icontains",
|
||||
"value": "test",
|
||||
"type": "person"
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "test"
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "control", "name": "Control", "rollout_percentage": 50},
|
||||
{"key": "test", "name": "Test", "rollout_percentage": 50}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Intermediate Flag",
|
||||
"key": "intermediate-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "leaf-flag",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": "control",
|
||||
"type": "flag",
|
||||
"dependency_chain": ["leaf-flag"]
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
"variant": "blue"
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "blue", "name": "Blue", "rollout_percentage": 60},
|
||||
{"key": "green", "name": "Green", "rollout_percentage": 40}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Dependent Flag",
|
||||
"key": "dependent-flag",
|
||||
"active": true,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "intermediate-flag",
|
||||
"operator": "flag_evaluates_to",
|
||||
"value": "blue",
|
||||
"type": "flag",
|
||||
"dependency_chain": ["leaf-flag", "intermediate-flag"]
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {}
|
||||
}`))
|
||||
} else if strings.HasPrefix(r.URL.Path, "/flags/") {
|
||||
w.Write([]byte(`{"featureFlags": {}}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{
|
||||
PersonalApiKey: "test-personal-api-key",
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Test leaf flag evaluates to "control" variant for [email protected]
|
||||
result, err := client.GetFeatureFlag(
|
||||
FeatureFlagPayload{
|
||||
Key: "leaf-flag",
|
||||
DistinctId: "user-control",
|
||||
PersonProperties: NewProperties().
|
||||
Set("email", "[email protected]"),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "control", result)
|
||||
|
||||
// Test leaf flag evaluates to "test" variant for [email protected]
|
||||
result, err = client.GetFeatureFlag(
|
||||
FeatureFlagPayload{
|
||||
Key: "leaf-flag",
|
||||
DistinctId: "user-test",
|
||||
PersonProperties: NewProperties().
|
||||
Set("email", "[email protected]"),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", result)
|
||||
|
||||
// Test intermediate flag evaluates to "blue" when leaf-flag is "control"
|
||||
result, err = client.GetFeatureFlag(
|
||||
FeatureFlagPayload{
|
||||
Key: "intermediate-flag",
|
||||
DistinctId: "user-control",
|
||||
PersonProperties: NewProperties().
|
||||
Set("email", "[email protected]"),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "blue", result)
|
||||
|
||||
// Test intermediate flag evaluates to false when leaf-flag is "test" (dependency not met)
|
||||
resultBool, err := client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "intermediate-flag",
|
||||
DistinctId: "user-test",
|
||||
PersonProperties: NewProperties().
|
||||
Set("email", "[email protected]"),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, resultBool)
|
||||
|
||||
// Test dependent flag is true when leaf-flag="control" and intermediate-flag="blue"
|
||||
resultBool, err = client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "dependent-flag",
|
||||
DistinctId: "user-control",
|
||||
PersonProperties: NewProperties().
|
||||
Set("email", "[email protected]"),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, resultBool)
|
||||
|
||||
// Test dependent flag is false when leaf-flag="test" (breaks dependency chain)
|
||||
resultBool, err = client.IsFeatureEnabled(
|
||||
FeatureFlagPayload{
|
||||
Key: "dependent-flag",
|
||||
DistinctId: "user-test",
|
||||
PersonProperties: NewProperties().
|
||||
Set("email", "[email protected]"),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, resultBool)
|
||||
}
|
||||
|
||||
+107
-41
@@ -4,32 +4,69 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"testing"
|
||||
)
|
||||
|
||||
// eventCapture is a test helper that captures events via the Callback interface
|
||||
type eventCapture struct {
|
||||
mu sync.Mutex
|
||||
events []CaptureInApi
|
||||
}
|
||||
|
||||
func (e *eventCapture) Success(m APIMessage) {
|
||||
if capture, ok := m.(CaptureInApi); ok {
|
||||
e.mu.Lock()
|
||||
e.events = append(e.events, capture)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *eventCapture) Failure(m APIMessage, err error) {}
|
||||
|
||||
func (e *eventCapture) getLastEvent() *CaptureInApi {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if len(e.events) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &e.events[len(e.events)-1]
|
||||
}
|
||||
|
||||
func (e *eventCapture) waitForEvent(timeout time.Duration) *CaptureInApi {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if event := e.getLastEvent(); event != nil {
|
||||
return event
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
Tests of the feature flag api against the flags endpoint, no local evaluation.
|
||||
This is primarily here to ensure we handle the different versions of the flags
|
||||
endpoint correctly.
|
||||
*/
|
||||
func TestFlags(t *testing.T) {
|
||||
validateCapturedEvent := func(t *testing.T, client Client) {
|
||||
lastEvent := client.GetLastCapturedEvent()
|
||||
|
||||
if lastEvent == nil {
|
||||
t.Parallel()
|
||||
validateCapturedEvent := func(t *testing.T, event *CaptureInApi) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
if lastEvent.Event != "$feature_flag_called" {
|
||||
t.Errorf("Expected a $feature_flag_called event, got: %v", lastEvent)
|
||||
if event.Event != "$feature_flag_called" {
|
||||
t.Errorf("Expected a $feature_flag_called event, got: %v", event.Event)
|
||||
}
|
||||
|
||||
if lastEvent.Properties["$feature_flag_request_id"] != "42853c54-1431-4861-996e-3a548989fa2c" {
|
||||
t.Errorf("Expected $feature_flag_request_id property to be 42853c54-1431-4861-996e-3a548989fa2c, got: %v", lastEvent.Properties["$feature_flag_request_id"])
|
||||
if event.Properties["$feature_flag_request_id"] != "42853c54-1431-4861-996e-3a548989fa2c" {
|
||||
t.Errorf("Expected $feature_flag_request_id property to be 42853c54-1431-4861-996e-3a548989fa2c, got: %v", event.Properties["$feature_flag_request_id"])
|
||||
}
|
||||
|
||||
if lastEvent.Properties["$feature_flag_evaluated_at"] != int64(1737312368000) {
|
||||
t.Errorf("Expected $feature_flag_evaluated_at property to be 1737312368000, got: %v", lastEvent.Properties["$feature_flag_evaluated_at"])
|
||||
if event.Properties["$feature_flag_evaluated_at"] != int64(1737312368000) {
|
||||
t.Errorf("Expected $feature_flag_evaluated_at property to be 1737312368000, got: %v", event.Properties["$feature_flag_evaluated_at"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +79,7 @@ func TestFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test // Capture loop variable for Go 1.21 compatibility
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
w.Write([]byte(fixture(test.fixture)))
|
||||
@@ -62,8 +100,11 @@ func TestFlags(t *testing.T) {
|
||||
|
||||
for _, subTest := range subTests {
|
||||
t.Run(subTest.name+" "+subTest.flagKey, func(t *testing.T) {
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Endpoint: server.URL,
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // Send immediately
|
||||
Callback: capture,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
@@ -79,7 +120,8 @@ func TestFlags(t *testing.T) {
|
||||
t.Errorf("Expected IsFeatureEnabled to return %v but was %v", subTest.expected, isMatch)
|
||||
}
|
||||
|
||||
validateCapturedEvent(t, client)
|
||||
event := capture.waitForEvent(time.Second)
|
||||
validateCapturedEvent(t, event)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -98,8 +140,11 @@ func TestFlags(t *testing.T) {
|
||||
|
||||
for _, subTest := range subTests {
|
||||
t.Run(subTest.name+" "+subTest.flagKey, func(t *testing.T) {
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Endpoint: server.URL,
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // Send immediately
|
||||
Callback: capture,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
@@ -115,7 +160,8 @@ func TestFlags(t *testing.T) {
|
||||
t.Errorf("Expected GetFeatureFlag to return %v but was %v", subTest.expected, flag)
|
||||
}
|
||||
|
||||
validateCapturedEvent(t, client)
|
||||
event := capture.waitForEvent(time.Second)
|
||||
validateCapturedEvent(t, event)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -134,8 +180,11 @@ func TestFlags(t *testing.T) {
|
||||
|
||||
for _, subTest := range subTests {
|
||||
t.Run(subTest.name+" "+subTest.flagKey, func(t *testing.T) {
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Endpoint: server.URL,
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // Send immediately
|
||||
Callback: capture,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
@@ -151,7 +200,8 @@ func TestFlags(t *testing.T) {
|
||||
t.Errorf("Expected GetFeatureFlagPayload to return %v but was %v", subTest.expected, payload)
|
||||
}
|
||||
|
||||
validateCapturedEvent(t, client)
|
||||
event := capture.waitForEvent(time.Second)
|
||||
validateCapturedEvent(t, event)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -171,7 +221,8 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -179,7 +230,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
DistinctId: "user-123",
|
||||
})
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -200,7 +251,8 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -208,7 +260,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
DistinctId: "user-123",
|
||||
})
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -228,7 +280,8 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -236,7 +289,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
DistinctId: "user-123",
|
||||
})
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -257,7 +310,8 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -265,7 +319,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
DistinctId: "user-123",
|
||||
})
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -277,12 +331,16 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
|
||||
t.Run("API error sets $feature_flag_error with status code", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "Internal Server Error"}`))
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "Internal Server Error"}`))
|
||||
}
|
||||
// /batch/ returns 200 OK (default) to allow event capture
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -290,7 +348,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
DistinctId: "user-123",
|
||||
})
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -310,7 +368,8 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, _ = client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -318,7 +377,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
DistinctId: "user-123",
|
||||
})
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -337,11 +396,15 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
|
||||
t.Run("$feature_flag_errored is true when errors exist", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
if strings.HasPrefix(r.URL.Path, "/flags") {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
// /batch/ returns 200 OK (default) to allow event capture
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL})
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("test-api-key", Config{Endpoint: server.URL, Callback: capture, BatchSize: 1})
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
@@ -353,7 +416,7 @@ func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
|
||||
t.Error("Expected an error from GetFeatureFlag")
|
||||
}
|
||||
|
||||
event := client.GetLastCapturedEvent()
|
||||
event := capture.waitForEvent(time.Second)
|
||||
if event == nil {
|
||||
t.Fatal("Expected a captured event")
|
||||
}
|
||||
@@ -371,11 +434,6 @@ func TestFlagsV4(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flagKey string
|
||||
@@ -390,7 +448,15 @@ func TestFlagsV4(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Run(test.name+" "+test.flagKey, func(t *testing.T) {
|
||||
capture := &eventCapture{}
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // Send immediately
|
||||
Callback: capture,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
flag, _ := client.GetFeatureFlag(
|
||||
FeatureFlagPayload{
|
||||
Key: test.flagKey,
|
||||
@@ -402,9 +468,9 @@ func TestFlagsV4(t *testing.T) {
|
||||
t.Errorf("Expected GetFeatureFlag(%s) to return %v but was %v", test.flagKey, test.expected, flag)
|
||||
}
|
||||
|
||||
capturedEvent := client.GetLastCapturedEvent()
|
||||
capturedEvent := capture.waitForEvent(time.Second)
|
||||
if capturedEvent == nil {
|
||||
t.Errorf("Expected a $feature_flag_called for %s event, got: %v", test.flagKey, capturedEvent)
|
||||
t.Fatalf("Expected a $feature_flag_called for %s event, got nil", test.flagKey)
|
||||
}
|
||||
|
||||
if capturedEvent.Properties["$feature_flag"] != test.flagKey {
|
||||
|
||||
+18
-1323
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatchPropertyValue(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "Browser",
|
||||
Value: "Chrome",
|
||||
Operator: "exact",
|
||||
}
|
||||
|
||||
properties := NewProperties().Set("Browser", "Chrome")
|
||||
|
||||
isMatch, err := matchProperty(property, properties)
|
||||
|
||||
if err != nil || !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMatchPropertyInvalidOperator(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "Browser",
|
||||
Value: "Chrome",
|
||||
Operator: "is_unknown",
|
||||
}
|
||||
|
||||
properties := NewProperties().Set("Browser", "Chrome")
|
||||
|
||||
isMatch, err := matchProperty(property, properties)
|
||||
|
||||
if isMatch == true {
|
||||
t.Error("Should not match")
|
||||
}
|
||||
|
||||
var inconclusiveErr *InconclusiveMatchError
|
||||
if !errors.As(err, &inconclusiveErr) {
|
||||
t.Error("Error type is not a match")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMatchPropertySlice(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "Browser",
|
||||
Value: []interface{}{"Chrome", "Firefox"},
|
||||
Operator: "exact",
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
properties Properties
|
||||
expected bool
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "match with Chrome",
|
||||
properties: NewProperties().Set("Browser", "Chrome"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "match with Firefox",
|
||||
properties: NewProperties().Set("Browser", "Firefox"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match with Explorer",
|
||||
properties: NewProperties().Set("Browser", "Explorer"),
|
||||
},
|
||||
{
|
||||
name: "no match with unknown key",
|
||||
properties: NewProperties().Set("Car", "Chrome"),
|
||||
err: errors.New(""),
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, tt.properties)
|
||||
if tt.err != nil {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tt.expected, isMatch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertySliceExact(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "Browser",
|
||||
Value: []interface{}{"Chrome", "Firefox"},
|
||||
Operator: "exact",
|
||||
}
|
||||
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("Browser", "Chrome"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("Browser", "Firefox"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("Browser", "Explorer"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("Car", "Fiat"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
}
|
||||
|
||||
func TestMatchPropertyNumber(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "Number",
|
||||
Value: 5,
|
||||
Operator: "gt",
|
||||
}
|
||||
|
||||
properties := NewProperties().Set("Number", 7)
|
||||
|
||||
isMatch, err := matchProperty(property, properties)
|
||||
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
|
||||
property = FlagProperty{
|
||||
Key: "Number",
|
||||
Value: 5,
|
||||
Operator: "lt",
|
||||
}
|
||||
|
||||
properties = NewProperties().Set("Number", 4)
|
||||
|
||||
isMatch, err = matchProperty(property, properties)
|
||||
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
|
||||
property = FlagProperty{
|
||||
Key: "Number",
|
||||
Value: 5,
|
||||
Operator: "gte",
|
||||
}
|
||||
|
||||
properties = NewProperties().Set("Number", 5)
|
||||
|
||||
isMatch, err = matchProperty(property, properties)
|
||||
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
|
||||
property = FlagProperty{
|
||||
Key: "Number",
|
||||
Value: 5,
|
||||
Operator: "lte",
|
||||
}
|
||||
|
||||
properties = NewProperties().Set("Number", 4)
|
||||
|
||||
isMatch, err = matchProperty(property, properties)
|
||||
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertyRegex(t *testing.T) {
|
||||
|
||||
shouldMatch := []interface{}{"value.com", "value2.com"}
|
||||
|
||||
property := FlagProperty{
|
||||
Key: "key",
|
||||
Value: "\\.com$",
|
||||
Operator: "regex",
|
||||
}
|
||||
|
||||
for _, val := range shouldMatch {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
|
||||
shouldNotMatch := []interface{}{".com343tfvalue5", "Alakazam", 123}
|
||||
|
||||
for _, val := range shouldNotMatch {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
|
||||
// invalid regex
|
||||
property = FlagProperty{
|
||||
Key: "key",
|
||||
Value: "?*",
|
||||
Operator: "regex",
|
||||
}
|
||||
|
||||
shouldNotMatch = []interface{}{"value", "valu2"}
|
||||
for _, val := range shouldNotMatch {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
|
||||
// non string value
|
||||
|
||||
property = FlagProperty{
|
||||
Key: "key",
|
||||
Value: 4,
|
||||
Operator: "regex",
|
||||
}
|
||||
|
||||
shouldMatch = []interface{}{"4", 4}
|
||||
for _, val := range shouldMatch {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertyContains(t *testing.T) {
|
||||
shouldMatch := []interface{}{"value", "value2", "value3", "value4", "343tfvalue5"}
|
||||
|
||||
property := FlagProperty{
|
||||
Key: "key",
|
||||
Value: "valUe",
|
||||
Operator: "icontains",
|
||||
}
|
||||
|
||||
for _, val := range shouldMatch {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
|
||||
shouldNotMatch := []interface{}{"Alakazam", 123}
|
||||
|
||||
for _, val := range shouldNotMatch {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
t.Error("Value is not a match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPropertyDateComparison(t *testing.T) {
|
||||
t.Run("RFC3339 dates", func(t *testing.T) {
|
||||
// Test is_date_before
|
||||
property := FlagProperty{
|
||||
Key: "created_at",
|
||||
Value: "2024-12-31T23:59:59Z",
|
||||
Operator: "is_date_before",
|
||||
}
|
||||
|
||||
// Should match: 2024-06-15 is before 2024-12-31
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:00:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: 2025-01-01 is after 2024-12-31
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test is_date_after
|
||||
property.Operator = "is_date_after"
|
||||
|
||||
// Should match: 2025-01-01 is after 2024-12-31
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: 2024-06-15 is before 2024-12-31
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:00:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("ISO 8601 date formats", func(t *testing.T) {
|
||||
// Test date-only format (YYYY-MM-DD)
|
||||
property := FlagProperty{
|
||||
Key: "created_at",
|
||||
Value: "2024-12-31",
|
||||
Operator: "is_date_before",
|
||||
}
|
||||
|
||||
// Should match: 2024-06-15 is before 2024-12-31
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("created_at", "2024-06-15"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: 2025-01-01 is after 2024-12-31
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test datetime without timezone (YYYY-MM-DDTHH:MM:SS)
|
||||
property.Value = "2024-12-31T23:59:59"
|
||||
|
||||
// Should match: 2024-06-15T10:00:00 is before 2024-12-31T23:59:59
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:00:00"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: 2025-01-01T00:00:00 is after 2024-12-31T23:59:59
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test is_date_after with date-only format
|
||||
property.Operator = "is_date_after"
|
||||
property.Value = "2024-06-15"
|
||||
|
||||
// Should match: 2025-01-01 is after 2024-06-15
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: 2024-01-01 is before 2024-06-15
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-01-01"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test mixing formats (RFC3339 value with date-only property)
|
||||
property.Value = "2024-06-15"
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Test mixing formats (date-only value with datetime property)
|
||||
property.Value = "2024-06-15T12:00:00"
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("ISO 8601 fractional seconds and timezone offsets", func(t *testing.T) {
|
||||
// Test fractional seconds with Z timezone
|
||||
property := FlagProperty{
|
||||
Key: "created_at",
|
||||
Value: "2024-12-31T23:59:59.999Z",
|
||||
Operator: "is_date_before",
|
||||
}
|
||||
|
||||
// Should match: earlier fractional time
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:30:00.123Z"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: later time
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00.001Z"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test timezone offsets (positive)
|
||||
property.Value = "2024-06-15T10:00:00+05:30"
|
||||
|
||||
// Should match: earlier time with different timezone
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T03:00:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: later time
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T12:00:00+05:30"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test timezone offsets (negative)
|
||||
property.Value = "2024-06-15T10:00:00-08:00"
|
||||
|
||||
// Should match: earlier time
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T08:00:00-08:00"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Test combined fractional seconds + timezone offset
|
||||
property.Value = "2024-12-31T23:59:59.999+01:00"
|
||||
|
||||
// Should match: earlier fractional time with timezone
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-12-31T22:59:59.998+01:00"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: later time
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00.000+01:00"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test various fractional second precisions
|
||||
testCases := []struct {
|
||||
name string
|
||||
dateStr string
|
||||
expected bool
|
||||
}{
|
||||
{"1 digit fractional", "2024-01-15T10:30:00.1Z", true},
|
||||
{"2 digit fractional", "2024-01-15T10:30:00.12Z", true},
|
||||
{"3 digit fractional", "2024-01-15T10:30:00.123Z", true},
|
||||
{"6 digit fractional", "2024-01-15T10:30:00.123456Z", true},
|
||||
{"9 digit fractional", "2024-01-15T10:30:00.123456789Z", true},
|
||||
{"fractional with +00:00", "2024-01-15T10:30:00.123+00:00", true},
|
||||
}
|
||||
|
||||
property.Operator = "is_date_after"
|
||||
property.Value = "2024-01-01T00:00:00Z"
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("created_at", tc.dateStr))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, isMatch, "Failed for date: %s", tc.dateStr)
|
||||
})
|
||||
}
|
||||
|
||||
// Test mixing fractional seconds with non-fractional
|
||||
property.Value = "2024-06-15T10:30:00Z"
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:30:00.999Z"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch) // .999 is after :00 (operator is still is_date_after)
|
||||
|
||||
// Test +00:00 equivalent to Z
|
||||
property.Value = "2024-06-15T10:30:00+00:00"
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:30:00Z"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch) // Equal times, not after
|
||||
})
|
||||
|
||||
t.Run("Relative dates", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
// Test is_date_after with relative date
|
||||
property := FlagProperty{
|
||||
Key: "created_at",
|
||||
Value: "-7d", // 7 days ago
|
||||
Operator: "is_date_after",
|
||||
}
|
||||
|
||||
// Should match: 3 days ago is after 7 days ago
|
||||
threeDaysAgo := now.AddDate(0, 0, -3).Format(time.RFC3339)
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("created_at", threeDaysAgo))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
|
||||
// Should not match: 10 days ago is before 7 days ago
|
||||
tenDaysAgo := now.AddDate(0, 0, -10).Format(time.RFC3339)
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", tenDaysAgo))
|
||||
require.NoError(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test is_date_before with relative date
|
||||
property.Operator = "is_date_before"
|
||||
|
||||
// Should match: 10 days ago is before 7 days ago
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", tenDaysAgo))
|
||||
require.NoError(t, err)
|
||||
require.True(t, isMatch)
|
||||
})
|
||||
|
||||
t.Run("Various relative date formats", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
relativeDate string
|
||||
shouldParse bool
|
||||
}{
|
||||
{"1 hour", "1h", true},
|
||||
{"7 days", "7d", true},
|
||||
{"2 weeks", "2w", true},
|
||||
{"3 months", "3m", true},
|
||||
{"1 year", "1y", true},
|
||||
{"large number rejected", "10000d", false},
|
||||
{"invalid format", "invalid", false},
|
||||
{"no number", "d", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseRelativeDate(tc.relativeDate)
|
||||
if tc.shouldParse {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Error handling", func(t *testing.T) {
|
||||
property := FlagProperty{
|
||||
Key: "created_at",
|
||||
Value: "2024-12-31T23:59:59Z",
|
||||
Operator: "is_date_before",
|
||||
}
|
||||
|
||||
// Test with non-string value
|
||||
isMatch, err := matchProperty(property, NewProperties().Set("created_at", 12345))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
|
||||
// Test with invalid date format
|
||||
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "invalid-date"))
|
||||
require.Error(t, err)
|
||||
require.False(t, isMatch)
|
||||
})
|
||||
}
|
||||
|
||||
+93
-48
@@ -5,16 +5,17 @@ import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,27 +23,35 @@ const LONG_SCALE = 0xfffffffffffffff
|
||||
|
||||
var relativeDateRegex = regexp.MustCompile(`^-?([0-9]+)([hdwmy])$`)
|
||||
|
||||
// flagsState holds the feature flag data that is atomically swapped during updates.
|
||||
// This provides lock-free reads for the common path (flag evaluation).
|
||||
type flagsState struct {
|
||||
featureFlags []FeatureFlag
|
||||
cohorts map[string]PropertyGroup
|
||||
groups map[string]string
|
||||
flagsEtag string
|
||||
}
|
||||
|
||||
type FeatureFlagsPoller struct {
|
||||
// firstFeatureFlagRequestFinished is used to log feature flag usage before the first feature flag request is done.
|
||||
// After the request the channel get closed.
|
||||
firstFeatureFlagRequestFinished chan bool
|
||||
shutdown chan bool
|
||||
forceReload chan bool
|
||||
featureFlags []FeatureFlag
|
||||
cohorts map[string]PropertyGroup
|
||||
groups map[string]string
|
||||
personalApiKey string
|
||||
projectApiKey string
|
||||
localEvalUrl *url.URL
|
||||
Logger Logger
|
||||
Endpoint string
|
||||
http http.Client
|
||||
mutex sync.RWMutex
|
||||
nextPollTick func() time.Duration
|
||||
flagTimeout time.Duration
|
||||
decider decider
|
||||
disableGeoIP bool
|
||||
flagsEtag string // ETag for conditional requests to reduce bandwidth
|
||||
|
||||
// state holds all flag-related data using atomic pointer for lock-free reads
|
||||
state atomic.Pointer[flagsState]
|
||||
|
||||
personalApiKey string
|
||||
projectApiKey string
|
||||
localEvalUrl *url.URL
|
||||
Logger Logger
|
||||
Endpoint string
|
||||
http http.Client
|
||||
nextPollTick func() time.Duration
|
||||
flagTimeout time.Duration
|
||||
decider decider
|
||||
disableGeoIP bool
|
||||
}
|
||||
|
||||
type FeatureFlag struct {
|
||||
@@ -291,7 +300,6 @@ func newFeatureFlagsPoller(
|
||||
Logger: logger,
|
||||
Endpoint: endpoint,
|
||||
http: httpClient,
|
||||
mutex: sync.RWMutex{},
|
||||
nextPollTick: nextPollTick,
|
||||
flagTimeout: flagTimeout,
|
||||
decider: decider,
|
||||
@@ -329,10 +337,12 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {
|
||||
personalApiKey := poller.personalApiKey
|
||||
headers := http.Header{"Authorization": []string{"Bearer " + personalApiKey}}
|
||||
|
||||
// Read current ETag under lock
|
||||
poller.mutex.RLock()
|
||||
currentEtag := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
// Read current ETag from state (lock-free)
|
||||
currentState := poller.state.Load()
|
||||
currentEtag := ""
|
||||
if currentState != nil {
|
||||
currentEtag = currentState.flagsEtag
|
||||
}
|
||||
|
||||
res, cancel, err := poller.localEvaluationFlags(headers, currentEtag)
|
||||
if err != nil {
|
||||
@@ -346,23 +356,28 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {
|
||||
if res.StatusCode == http.StatusNotModified {
|
||||
poller.Logger.Debugf("[FEATURE FLAGS] Flags not modified (304), using cached data")
|
||||
// Update ETag if server returned one (preserve existing if not)
|
||||
if newEtag := res.Header.Get("ETag"); newEtag != "" {
|
||||
poller.mutex.Lock()
|
||||
poller.flagsEtag = newEtag
|
||||
poller.mutex.Unlock()
|
||||
if newEtag := res.Header.Get("ETag"); newEtag != "" && currentState != nil {
|
||||
// Atomically swap with updated ETag
|
||||
newState := &flagsState{
|
||||
featureFlags: currentState.featureFlags,
|
||||
cohorts: currentState.cohorts,
|
||||
groups: currentState.groups,
|
||||
flagsEtag: newEtag,
|
||||
}
|
||||
poller.state.Store(newState)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle quota limit response (HTTP 402)
|
||||
if res.StatusCode == http.StatusPaymentRequired {
|
||||
// Clear existing flags when quota limited
|
||||
poller.mutex.Lock()
|
||||
poller.featureFlags = []FeatureFlag{}
|
||||
poller.cohorts = map[string]PropertyGroup{}
|
||||
poller.groups = map[string]string{}
|
||||
poller.flagsEtag = "" // Clear ETag on quota limit
|
||||
poller.mutex.Unlock()
|
||||
// Clear existing flags when quota limited - atomic swap
|
||||
poller.state.Store(&flagsState{
|
||||
featureFlags: []FeatureFlag{},
|
||||
cohorts: map[string]PropertyGroup{},
|
||||
groups: map[string]string{},
|
||||
flagsEtag: "",
|
||||
})
|
||||
poller.Logger.Warnf("[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts")
|
||||
return
|
||||
}
|
||||
@@ -387,14 +402,19 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {
|
||||
// Store new ETag from response (clear if server stops sending)
|
||||
newEtag := res.Header.Get("ETag")
|
||||
|
||||
poller.mutex.Lock()
|
||||
defer poller.mutex.Unlock()
|
||||
poller.featureFlags = newFlags
|
||||
poller.cohorts = featureFlagsResponse.Cohorts
|
||||
poller.flagsEtag = newEtag
|
||||
// Build new groups map
|
||||
groups := map[string]string{}
|
||||
if featureFlagsResponse.GroupTypeMapping != nil {
|
||||
poller.groups = *featureFlagsResponse.GroupTypeMapping
|
||||
groups = *featureFlagsResponse.GroupTypeMapping
|
||||
}
|
||||
|
||||
// Atomic swap of entire state
|
||||
poller.state.Store(&flagsState{
|
||||
featureFlags: newFlags,
|
||||
cohorts: featureFlagsResponse.Cohorts,
|
||||
groups: groups,
|
||||
flagsEtag: newEtag,
|
||||
})
|
||||
}
|
||||
|
||||
func (poller *FeatureFlagsPoller) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, error) {
|
||||
@@ -409,7 +429,7 @@ func (poller *FeatureFlagsPoller) GetFeatureFlag(flagConfig FeatureFlagPayload)
|
||||
flagConfig.Groups,
|
||||
flagConfig.PersonProperties,
|
||||
flagConfig.GroupProperties,
|
||||
poller.cohorts,
|
||||
poller.getCohorts(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,7 +459,7 @@ func (poller *FeatureFlagsPoller) GetFeatureFlagPayload(flagConfig FeatureFlagPa
|
||||
flagConfig.Groups,
|
||||
flagConfig.PersonProperties,
|
||||
flagConfig.GroupProperties,
|
||||
poller.cohorts,
|
||||
poller.getCohorts(),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -488,7 +508,7 @@ func (poller *FeatureFlagsPoller) GetAllFlags(flagConfig FeatureFlagPayloadNoKey
|
||||
return nil, err
|
||||
}
|
||||
fallbackToDecide := false
|
||||
cohorts := poller.cohorts
|
||||
cohorts := poller.getCohorts()
|
||||
|
||||
if len(featureFlags) == 0 {
|
||||
fallbackToDecide = true
|
||||
@@ -561,7 +581,7 @@ func (poller *FeatureFlagsPoller) computeFlagLocally(
|
||||
}
|
||||
|
||||
if flag.Filters.AggregationGroupTypeIndex != nil {
|
||||
groupType, exists := poller.groups[fmt.Sprintf("%d", *flag.Filters.AggregationGroupTypeIndex)]
|
||||
groupType, exists := poller.getGroups()[fmt.Sprintf("%d", *flag.Filters.AggregationGroupTypeIndex)]
|
||||
|
||||
if !exists {
|
||||
errMessage := "flag has unknown group type index"
|
||||
@@ -1149,14 +1169,39 @@ func calculateHash(prefix, distinctId, salt string) float64 {
|
||||
func (poller *FeatureFlagsPoller) GetFeatureFlags() ([]FeatureFlag, error) {
|
||||
// When channel is open this will block. When channel is closed it will immediately exit.
|
||||
<-poller.firstFeatureFlagRequestFinished
|
||||
poller.mutex.RLock()
|
||||
defer poller.mutex.RUnlock()
|
||||
if poller.featureFlags == nil {
|
||||
|
||||
// Lock-free read of state
|
||||
state := poller.state.Load()
|
||||
if state == nil || state.featureFlags == nil {
|
||||
// There was an error with initial flag fetching
|
||||
return nil, errors.New("flags were not successfully fetched yet")
|
||||
}
|
||||
|
||||
return poller.featureFlags, nil
|
||||
return state.featureFlags, nil
|
||||
}
|
||||
|
||||
// getState returns the current flags state or nil if not initialized.
|
||||
// This is a lock-free read.
|
||||
func (poller *FeatureFlagsPoller) getState() *flagsState {
|
||||
return poller.state.Load()
|
||||
}
|
||||
|
||||
// getCohorts returns the current cohorts map or an empty map if not initialized.
|
||||
func (poller *FeatureFlagsPoller) getCohorts() map[string]PropertyGroup {
|
||||
state := poller.state.Load()
|
||||
if state == nil || state.cohorts == nil {
|
||||
return map[string]PropertyGroup{}
|
||||
}
|
||||
return state.cohorts
|
||||
}
|
||||
|
||||
// getGroups returns the current groups map or an empty map if not initialized.
|
||||
func (poller *FeatureFlagsPoller) getGroups() map[string]string {
|
||||
state := poller.state.Load()
|
||||
if state == nil || state.groups == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return state.groups
|
||||
}
|
||||
|
||||
func (poller *FeatureFlagsPoller) localEvaluationFlags(headers http.Header, etag string) (*http.Response, context.CancelFunc, error) {
|
||||
@@ -1225,7 +1270,7 @@ func (poller *FeatureFlagsPoller) getFeatureFlagVariantsLocalOnly(distinctId str
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
cohorts := poller.cohorts
|
||||
cohorts := poller.getCohorts()
|
||||
|
||||
for _, flag := range flags {
|
||||
flagValue, err := poller.computeFlagLocally(
|
||||
|
||||
+57
-44
@@ -5,7 +5,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -27,7 +26,6 @@ func newTestPoller(t *testing.T, serverURL string) *FeatureFlagsPoller {
|
||||
Logger: newDefaultLogger(false),
|
||||
Endpoint: serverURL,
|
||||
http: http.Client{},
|
||||
mutex: sync.RWMutex{},
|
||||
flagTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -49,9 +47,11 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
poller := newTestPoller(t, server.URL)
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
state := poller.state.Load()
|
||||
etag := ""
|
||||
if state != nil {
|
||||
etag = state.flagsEtag
|
||||
}
|
||||
|
||||
if etag != `"abc123"` {
|
||||
t.Errorf("Expected ETag to be \"abc123\", got %q", etag)
|
||||
@@ -128,24 +128,18 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
// First request - get flags
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
flags := poller.featureFlags
|
||||
poller.mutex.RUnlock()
|
||||
|
||||
if len(flags) != 1 || flags[0].Key != "cached-flag" {
|
||||
t.Errorf("Expected initial flag 'cached-flag', got %+v", flags)
|
||||
state := poller.state.Load()
|
||||
if state == nil || len(state.featureFlags) != 1 || state.featureFlags[0].Key != "cached-flag" {
|
||||
t.Errorf("Expected initial flag 'cached-flag', got %+v", state)
|
||||
}
|
||||
|
||||
// Second request - 304
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
// Flags should still be available after 304
|
||||
poller.mutex.RLock()
|
||||
flags = poller.featureFlags
|
||||
poller.mutex.RUnlock()
|
||||
|
||||
if len(flags) != 1 || flags[0].Key != "cached-flag" {
|
||||
t.Errorf("Expected flags to remain 'cached-flag' after 304, got %+v", flags)
|
||||
state = poller.state.Load()
|
||||
if state == nil || len(state.featureFlags) != 1 || state.featureFlags[0].Key != "cached-flag" {
|
||||
t.Errorf("Expected flags to remain 'cached-flag' after 304, got %+v", state)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -179,9 +173,11 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag1 := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
state := poller.state.Load()
|
||||
etag1 := ""
|
||||
if state != nil {
|
||||
etag1 = state.flagsEtag
|
||||
}
|
||||
|
||||
if etag1 != `"etag-v1"` {
|
||||
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
||||
@@ -190,10 +186,13 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
// Second request - new ETag
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag2 := poller.flagsEtag
|
||||
flags := poller.featureFlags
|
||||
poller.mutex.RUnlock()
|
||||
state = poller.state.Load()
|
||||
etag2 := ""
|
||||
var flags []FeatureFlag
|
||||
if state != nil {
|
||||
etag2 = state.flagsEtag
|
||||
flags = state.featureFlags
|
||||
}
|
||||
|
||||
if etag2 != `"etag-v2"` {
|
||||
t.Errorf("Expected updated ETag to be \"etag-v2\", got %q", etag2)
|
||||
@@ -233,9 +232,11 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag1 := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
state := poller.state.Load()
|
||||
etag1 := ""
|
||||
if state != nil {
|
||||
etag1 = state.flagsEtag
|
||||
}
|
||||
|
||||
if etag1 != `"etag-v1"` {
|
||||
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
||||
@@ -244,9 +245,11 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
// Second request - no ETag
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag2 := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
state = poller.state.Load()
|
||||
etag2 := ""
|
||||
if state != nil {
|
||||
etag2 = state.flagsEtag
|
||||
}
|
||||
|
||||
if etag2 != "" {
|
||||
t.Errorf("Expected ETag to be cleared when server stops sending it, got %q", etag2)
|
||||
@@ -279,9 +282,11 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag1 := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
state := poller.state.Load()
|
||||
etag1 := ""
|
||||
if state != nil {
|
||||
etag1 = state.flagsEtag
|
||||
}
|
||||
|
||||
if etag1 != `"etag-v1"` {
|
||||
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
||||
@@ -290,10 +295,13 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
// Second request - 304 without ETag
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag2 := poller.flagsEtag
|
||||
flags := poller.featureFlags
|
||||
poller.mutex.RUnlock()
|
||||
state = poller.state.Load()
|
||||
etag2 := ""
|
||||
var flags []FeatureFlag
|
||||
if state != nil {
|
||||
etag2 = state.flagsEtag
|
||||
flags = state.featureFlags
|
||||
}
|
||||
|
||||
if etag2 != `"etag-v1"` {
|
||||
t.Errorf("Expected ETag to be preserved when 304 has no ETag header, got %q", etag2)
|
||||
@@ -332,9 +340,11 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag1 := poller.flagsEtag
|
||||
poller.mutex.RUnlock()
|
||||
state := poller.state.Load()
|
||||
etag1 := ""
|
||||
if state != nil {
|
||||
etag1 = state.flagsEtag
|
||||
}
|
||||
|
||||
if etag1 != `"etag-v1"` {
|
||||
t.Errorf("Expected initial ETag to be \"etag-v1\", got %q", etag1)
|
||||
@@ -343,10 +353,13 @@ func TestETagSupportForLocalEvaluation(t *testing.T) {
|
||||
// Second request - quota limited
|
||||
poller.fetchNewFeatureFlags()
|
||||
|
||||
poller.mutex.RLock()
|
||||
etag2 := poller.flagsEtag
|
||||
flags := poller.featureFlags
|
||||
poller.mutex.RUnlock()
|
||||
state = poller.state.Load()
|
||||
etag2 := ""
|
||||
var flags []FeatureFlag
|
||||
if state != nil {
|
||||
etag2 = state.flagsEtag
|
||||
flags = state.featureFlags
|
||||
}
|
||||
|
||||
if etag2 != "" {
|
||||
t.Errorf("Expected ETag to be cleared on quota limit, got %q", etag2)
|
||||
|
||||
@@ -3,12 +3,13 @@ package posthog
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
type FlagsRequestData struct {
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
|
||||
@@ -74,3 +74,4 @@ func (msg GroupIdentify) APIfy() APIMessage {
|
||||
|
||||
return apified
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,8 @@
|
||||
package posthog
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var _ Message = (*Identify)(nil)
|
||||
|
||||
@@ -64,3 +66,4 @@ func (msg Identify) APIfy() APIMessage {
|
||||
|
||||
return apified
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// BenchmarkJSONMarshalBatch benchmarks JSON serialization at various batch sizes
|
||||
// Uses pre-serialized messages to match production behavior
|
||||
func BenchmarkJSONMarshalBatch(b *testing.B) {
|
||||
sizes := []int{1, 10, 50, 100, 250}
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("batch_%d", size), func(b *testing.B) {
|
||||
// Pre-generate and pre-serialize messages (as in production)
|
||||
msgs := make([]json.RawMessage, size)
|
||||
for i := range msgs {
|
||||
data, _ := json.Marshal(generateVariedCapture(i).APIfy())
|
||||
msgs[i] = json.RawMessage(data)
|
||||
}
|
||||
payload := batch{ApiKey: "test", Messages: msgs}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
json.Marshal(payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJSONMarshalBatchWithCardinality benchmarks JSON serialization with various property cardinalities
|
||||
// Uses pre-serialized messages to match production behavior
|
||||
func BenchmarkJSONMarshalBatchWithCardinality(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low_10-100_props", CardinalityLow},
|
||||
{"medium_500-2000_props", CardinalityMedium},
|
||||
{"high_2000-10000_props", CardinalityHigh},
|
||||
}
|
||||
|
||||
batchSize := 10 // Use smaller batch for high cardinality to avoid excessive memory
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
// Pre-generate and pre-serialize messages with specific cardinality
|
||||
msgs := make([]json.RawMessage, batchSize)
|
||||
for i := range msgs {
|
||||
capture := Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", i),
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(i, tc.cardinality),
|
||||
Groups: generateGroupsWithCardinality(i, tc.cardinality),
|
||||
}
|
||||
data, _ := json.Marshal(capture.APIfy())
|
||||
msgs[i] = json.RawMessage(data)
|
||||
}
|
||||
payload := batch{ApiKey: "test", Messages: msgs}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
json.Marshal(payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPrepareForSend_Cardinality benchmarks prepareForSend at various property cardinalities
|
||||
// This validates that the combined APIfy + serialization isn't a bottleneck
|
||||
func BenchmarkPrepareForSend_Cardinality(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low_10-100_props", CardinalityLow},
|
||||
{"medium_500-2000_props", CardinalityMedium},
|
||||
{"high_2000-10000_props", CardinalityHigh},
|
||||
}
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
// Pre-generate captures with this cardinality
|
||||
captures := make([]Capture, 100)
|
||||
for i := range captures {
|
||||
captures[i] = Capture{
|
||||
Type: "capture",
|
||||
DistinctId: fmt.Sprintf("user_%d", i),
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(i, tc.cardinality),
|
||||
Groups: generateGroupsWithCardinality(i, tc.cardinality),
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
prepareForSend(captures[i%100])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHTTPUpload benchmarks HTTP upload isolated from serialization
|
||||
func BenchmarkHTTPUpload(b *testing.B) {
|
||||
// Pre-serialize payloads
|
||||
payloadSizes := []int{1024, 10240, 102400} // 1KB, 10KB, 100KB
|
||||
for _, size := range payloadSizes {
|
||||
b.Run(fmt.Sprintf("payload_%dKB", size/1024), func(b *testing.B) {
|
||||
payload := make([]byte, size)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req, _ := http.NewRequest("POST", server.URL, bytes.NewReader(payload))
|
||||
resp, _ := client.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkHTTPUploadWithRealPayload benchmarks HTTP upload with realistic JSON payloads
|
||||
func BenchmarkHTTPUploadWithRealPayload(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low_cardinality", CardinalityLow},
|
||||
{"medium_cardinality", CardinalityMedium},
|
||||
}
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
// Pre-generate and pre-serialize messages (as in production)
|
||||
msgs := make([]json.RawMessage, 10)
|
||||
for i := range msgs {
|
||||
capture := Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", i),
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(i, tc.cardinality),
|
||||
}
|
||||
data, _ := json.Marshal(capture.APIfy())
|
||||
msgs[i] = json.RawMessage(data)
|
||||
}
|
||||
payload, _ := json.Marshal(batch{ApiKey: "test", Messages: msgs})
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
b.ResetTimer()
|
||||
b.SetBytes(int64(len(payload)))
|
||||
for i := 0; i < b.N; i++ {
|
||||
req, _ := http.NewRequest("POST", server.URL, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := client.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPrepareVsMarshaling compares the cost of prepareForSend vs actual marshaling
|
||||
func BenchmarkPrepareVsMarshaling(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low", CardinalityLow},
|
||||
{"medium", CardinalityMedium},
|
||||
{"high", CardinalityHigh},
|
||||
}
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
// Pre-generate capture
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(42, tc.cardinality),
|
||||
Groups: generateGroupsWithCardinality(42, tc.cardinality),
|
||||
}
|
||||
apiMsg := capture.APIfy()
|
||||
|
||||
b.Run(tc.name+"_prepare", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
prepareForSend(capture)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run(tc.name+"_marshal", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
json.Marshal(apiMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPropertyIteration benchmarks the cost of iterating over properties
|
||||
func BenchmarkPropertyIteration(b *testing.B) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low", CardinalityLow},
|
||||
{"medium", CardinalityMedium},
|
||||
{"high", CardinalityHigh},
|
||||
}
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
props := generatePropertiesWithCardinality(42, tc.cardinality)
|
||||
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
total := 0
|
||||
for k, v := range props {
|
||||
total += len(k)
|
||||
_ = v // access value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// legacyBatch represents the old batch structure with []APIMessage for comparison benchmarks.
|
||||
// In the old flow, messages were serialized together with the batch wrapper in a single Marshal call.
|
||||
type legacyBatch struct {
|
||||
ApiKey string `json:"api_key"`
|
||||
HistoricalMigration bool `json:"historical_migration,omitempty"`
|
||||
Messages []APIMessage `json:"batch"`
|
||||
}
|
||||
|
||||
// BenchmarkOldVsNewSerializationFlow compares serialization approaches:
|
||||
// - Old: APIfy all messages, then marshal entire batch (single Marshal call)
|
||||
// - New: prepareForSend each message (marshal individually), then marshal batch wrapper
|
||||
func BenchmarkOldVsNewSerializationFlow(b *testing.B) {
|
||||
batchSizes := []int{10, 50, 100, 250}
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low", CardinalityLow},
|
||||
{"medium", CardinalityMedium},
|
||||
}
|
||||
|
||||
for _, size := range batchSizes {
|
||||
for _, tc := range cardinalities {
|
||||
// Pre-generate captures
|
||||
captures := make([]Capture, size)
|
||||
for i := range captures {
|
||||
captures[i] = Capture{
|
||||
Type: "capture",
|
||||
DistinctId: fmt.Sprintf("user_%d", i),
|
||||
Event: "test_event",
|
||||
Properties: generatePropertiesWithCardinality(i, tc.cardinality),
|
||||
Groups: generateGroupsWithCardinality(i, tc.cardinality),
|
||||
}
|
||||
}
|
||||
|
||||
// Old flow: APIfy then marshal entire batch
|
||||
b.Run(fmt.Sprintf("old_batch_%d_%s", size, tc.name), func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
apiMsgs := make([]APIMessage, size)
|
||||
for j, c := range captures {
|
||||
apiMsgs[j] = c.APIfy()
|
||||
}
|
||||
json.Marshal(legacyBatch{ApiKey: "test", Messages: apiMsgs})
|
||||
}
|
||||
})
|
||||
|
||||
// New flow: prepareForSend (marshal individually) then marshal batch wrapper
|
||||
b.Run(fmt.Sprintf("new_batch_%d_%s", size, tc.name), func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rawMsgs := make([]json.RawMessage, size)
|
||||
for j, c := range captures {
|
||||
data, _, _ := prepareForSend(c)
|
||||
rawMsgs[j] = data
|
||||
}
|
||||
json.Marshal(batch{ApiKey: "test", Messages: rawMsgs})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Imitate what what the JSON package would do when serializing a struct value,
|
||||
// the only difference is we we don't serialize zero-value struct fields as well.
|
||||
// Note that this function doesn't recursively convert structures to maps, only
|
||||
// the value passed as argument is transformed.
|
||||
func structToMap(v reflect.Value, m map[string]interface{}) map[string]interface{} {
|
||||
t := v.Type()
|
||||
n := t.NumField()
|
||||
|
||||
if m == nil {
|
||||
m = make(map[string]interface{}, n)
|
||||
}
|
||||
|
||||
for i := 0; i != n; i++ {
|
||||
field := t.Field(i)
|
||||
value := v.Field(i)
|
||||
name, omitempty := parseJsonTag(field.Tag.Get("json"), field.Name)
|
||||
|
||||
if name != "-" && !(omitempty && isZeroValue(value)) {
|
||||
m[name] = value.Interface()
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Parses a JSON tag the way the json package would do it, returing the expected
|
||||
// name of the field once serialized and if empty values should be omitted.
|
||||
func parseJsonTag(tag string, defName string) (name string, omitempty bool) {
|
||||
args := strings.Split(tag, ",")
|
||||
|
||||
if len(args) == 0 || len(args[0]) == 0 {
|
||||
name = defName
|
||||
} else {
|
||||
name = args[0]
|
||||
}
|
||||
|
||||
if len(args) > 1 && args[1] == "omitempty" {
|
||||
omitempty = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Checks if the value given as argument is a zero-value, it is based on the
|
||||
// isEmptyValue function in https://golang.org/src/encoding/json/encode.go
|
||||
// but also checks struct types recursively.
|
||||
func isZeroValue(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
|
||||
return v.Len() == 0
|
||||
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return v.Uint() == 0
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
|
||||
case reflect.Interface, reflect.Ptr:
|
||||
return v.IsNil()
|
||||
|
||||
case reflect.Struct:
|
||||
for i, n := 0, v.NumField(); i != n; i++ {
|
||||
if !isZeroValue(v.Field(i)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
case reflect.Invalid:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJsonTagEmpty(t *testing.T) {
|
||||
name, omitempty := parseJsonTag("", "default")
|
||||
|
||||
if name != "default" {
|
||||
t.Error("invalid field name found in empty tag:", name)
|
||||
}
|
||||
|
||||
if omitempty {
|
||||
t.Error("unexpected 'omitempty' state found in empty tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJsonTagName(t *testing.T) {
|
||||
name, omitempty := parseJsonTag("name", "default")
|
||||
|
||||
if name != "name" {
|
||||
t.Error("invalid field name found in json tag:", name)
|
||||
}
|
||||
|
||||
if omitempty {
|
||||
t.Error("unexpected 'omitempty' state found in json tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJsonTagOmitempty(t *testing.T) {
|
||||
name, omitempty := parseJsonTag(",omitempty", "default")
|
||||
|
||||
if name != "default" {
|
||||
t.Error("invalid field name found in omitempty tag:", name)
|
||||
}
|
||||
|
||||
if !omitempty {
|
||||
t.Error("expected 'omitempty' state not found in json tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJsonTagNameOmitempty(t *testing.T) {
|
||||
name, omitempty := parseJsonTag("name,omitempty", "default")
|
||||
|
||||
if name != "name" {
|
||||
t.Error("invalid field name found in json tag:", name)
|
||||
}
|
||||
|
||||
if !omitempty {
|
||||
t.Error("expected 'omitempty' state not found in json tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructToMap(t *testing.T) {
|
||||
type X struct {
|
||||
A bool
|
||||
B int `json:"b"`
|
||||
C string `json:"c,omitempty"`
|
||||
}
|
||||
|
||||
if m := structToMap(reflect.ValueOf(X{}), nil); !reflect.DeepEqual(m, map[string]interface{}{
|
||||
"A": false,
|
||||
"b": 0,
|
||||
}) {
|
||||
t.Error("invalid JSON object representation of a struct:", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueStringTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf("")) {
|
||||
t.Error("empty string should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueStringFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf("A")) {
|
||||
t.Error("non-empty string should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueSliceTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf([]int{})) {
|
||||
t.Error("empty slice should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueSliceFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf([]int{0})) {
|
||||
t.Error("non-empty slice should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueMapTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf(map[string]string{})) {
|
||||
t.Error("empty map should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueMapFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(map[string]string{
|
||||
"A": "a",
|
||||
})) {
|
||||
t.Error("non-empty map should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueBoolTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf(false)) {
|
||||
t.Error("`false` should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueBoolFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(true)) {
|
||||
t.Error("`true` should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueIntTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf(0)) {
|
||||
t.Error("`0` should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueIntFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(1)) {
|
||||
t.Error("`1` should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueUintTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf(uint(0))) {
|
||||
t.Error("`0` should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueUintFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(uint(1))) {
|
||||
t.Error("`1` should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueFloatTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf(0.0)) {
|
||||
t.Error("`0.0` should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueFloatFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(1.0)) {
|
||||
t.Error("`1.0` should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValuePointerTrue(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf((*int)(nil))) {
|
||||
t.Error("nil pointer should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValuePointerFalse(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(t)) {
|
||||
t.Error("non-nil pointer should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueStructTrue(t *testing.T) {
|
||||
type T struct {
|
||||
A bool
|
||||
B int
|
||||
C string
|
||||
}
|
||||
|
||||
if !isZeroValue(reflect.ValueOf(T{})) {
|
||||
t.Error("empty struct should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueStructFalse(t *testing.T) {
|
||||
type T struct {
|
||||
A bool
|
||||
B int
|
||||
C string
|
||||
}
|
||||
|
||||
if isZeroValue(reflect.ValueOf(T{A: true})) {
|
||||
t.Error("non-empty struct should not be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueNil(t *testing.T) {
|
||||
if !isZeroValue(reflect.ValueOf(nil)) {
|
||||
t.Error("nil should be a zero-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsZeroValueFunc(t *testing.T) {
|
||||
if isZeroValue(reflect.ValueOf(func() {})) {
|
||||
t.Error("functions should be be zero-value")
|
||||
}
|
||||
}
|
||||
+18
-60
@@ -1,8 +1,9 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// Values implementing this interface are used by posthog clients to notify
|
||||
@@ -51,71 +52,28 @@ func makeTimestamp(t time.Time, def time.Time) time.Time {
|
||||
return t
|
||||
}
|
||||
|
||||
// This structure represents objects sent to the /batch/ endpoint. We don't
|
||||
// export this type because it's only meant to be used internally to send groups
|
||||
// of messages in one API call.
|
||||
// batch represents objects sent to the /batch/ endpoint with pre-serialized messages.
|
||||
// Messages are pre-serialized as json.RawMessage for efficient batch building -
|
||||
// json.Marshal embeds them directly without re-encoding.
|
||||
type batch struct {
|
||||
ApiKey string `json:"api_key"`
|
||||
HistoricalMigration bool `json:"historical_migration,omitempty"`
|
||||
Messages []message `json:"batch"`
|
||||
ApiKey string `json:"api_key"`
|
||||
HistoricalMigration bool `json:"historical_migration,omitempty"`
|
||||
Messages []json.RawMessage `json:"batch"`
|
||||
}
|
||||
|
||||
type APIMessage interface{}
|
||||
|
||||
type message struct {
|
||||
msg APIMessage
|
||||
json []byte
|
||||
}
|
||||
|
||||
func makeMessage(m APIMessage, maxBytes int) (msg message, err error) {
|
||||
if msg.json, err = json.Marshal(m); err == nil {
|
||||
if len(msg.json) > maxBytes {
|
||||
err = ErrMessageTooBig
|
||||
} else {
|
||||
msg.msg = m
|
||||
}
|
||||
// prepareForSend creates the API message and serializes it to JSON.
|
||||
// Returns pre-serialized JSON for efficient batch building, the original
|
||||
// APIMessage for callbacks, and any serialization error.
|
||||
// Size is derived from len(json.RawMessage) when needed - O(1) operation.
|
||||
func prepareForSend(msg Message) (json.RawMessage, APIMessage, error) {
|
||||
apiMsg := msg.APIfy()
|
||||
data, err := json.Marshal(apiMsg)
|
||||
if err != nil {
|
||||
return nil, apiMsg, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m message) MarshalJSON() ([]byte, error) {
|
||||
return m.json, nil
|
||||
}
|
||||
|
||||
func (m message) size() int {
|
||||
// The `+ 1` is for the comma that sits between each items of a JSON array.
|
||||
return len(m.json) + 1
|
||||
}
|
||||
|
||||
type messageQueue struct {
|
||||
pending []message
|
||||
bytes int
|
||||
maxBatchSize int
|
||||
maxBatchBytes int
|
||||
}
|
||||
|
||||
func (q *messageQueue) push(m message) (b []message) {
|
||||
if (q.bytes + m.size()) > q.maxBatchBytes {
|
||||
b = q.flush()
|
||||
}
|
||||
|
||||
if q.pending == nil {
|
||||
q.pending = make([]message, 0, q.maxBatchSize)
|
||||
}
|
||||
|
||||
q.pending = append(q.pending, m)
|
||||
q.bytes += len(m.json)
|
||||
|
||||
if b == nil && len(q.pending) == q.maxBatchSize {
|
||||
b = q.flush()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (q *messageQueue) flush() (msgs []message) {
|
||||
msgs, q.pending, q.bytes = q.pending, nil, 0
|
||||
return
|
||||
return json.RawMessage(data), apiMsg, nil
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageQueuePushMaxBatchSize(t *testing.T) {
|
||||
m0, _ := makeMessage(Capture{
|
||||
DistinctId: "1",
|
||||
Event: "A",
|
||||
}, maxMessageBytes)
|
||||
|
||||
m1, _ := makeMessage(Capture{
|
||||
DistinctId: "2",
|
||||
Event: "A",
|
||||
}, maxMessageBytes)
|
||||
|
||||
q := messageQueue{
|
||||
maxBatchSize: 2,
|
||||
maxBatchBytes: maxBatchBytes,
|
||||
}
|
||||
|
||||
if msgs := q.push(m0); msgs != nil {
|
||||
t.Error("unexpected message batch returned after pushing only one message")
|
||||
}
|
||||
|
||||
if msgs := q.push(m1); !reflect.DeepEqual(msgs, []message{m0, m1}) {
|
||||
t.Error("invalid message batch returned after pushing two messages:", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageQueuePushMaxBatchBytes(t *testing.T) {
|
||||
m0, _ := makeMessage(Capture{
|
||||
DistinctId: "1",
|
||||
Event: "A",
|
||||
}, maxMessageBytes)
|
||||
|
||||
m1, _ := makeMessage(Capture{
|
||||
DistinctId: "2",
|
||||
Event: "A",
|
||||
}, maxMessageBytes)
|
||||
|
||||
q := messageQueue{
|
||||
maxBatchSize: 100,
|
||||
maxBatchBytes: len(m0.json) + 1,
|
||||
}
|
||||
|
||||
if msgs := q.push(m0); msgs != nil {
|
||||
t.Error("unexpected message batch returned after pushing only one message")
|
||||
}
|
||||
|
||||
if msgs := q.push(m1); !reflect.DeepEqual(msgs, []message{m0}) {
|
||||
t.Error("invalid message batch returned after pushing two messages:", msgs)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(q.pending, []message{m1}) {
|
||||
t.Error("invalid state of the message queue after pushing two messages:", q.pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMessageTooBig(t *testing.T) {
|
||||
if _, err := makeMessage(Capture{DistinctId: "1"}, 1); err != ErrMessageTooBig {
|
||||
t.Error("invalid error returned when creating a message bigger than the limit:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEnqueue_InvalidMessageTypes tests various invalid message scenarios
|
||||
func TestEnqueue_InvalidMessageTypes(t *testing.T) {
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
t.Run("capture_missing_distinct_id", func(t *testing.T) {
|
||||
err := client.Enqueue(Capture{
|
||||
Event: "test_event",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "DistinctId")
|
||||
})
|
||||
|
||||
t.Run("capture_missing_event", func(t *testing.T) {
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "Event")
|
||||
})
|
||||
|
||||
t.Run("identify_missing_distinct_id", func(t *testing.T) {
|
||||
err := client.Enqueue(Identify{
|
||||
Properties: Properties{"name": "test"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("alias_missing_distinct_id", func(t *testing.T) {
|
||||
err := client.Enqueue(Alias{
|
||||
Alias: "new_alias",
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("alias_missing_alias", func(t *testing.T) {
|
||||
err := client.Enqueue(Alias{
|
||||
DistinctId: "user_1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("group_identify_missing_type", func(t *testing.T) {
|
||||
err := client.Enqueue(GroupIdentify{
|
||||
Key: "group_1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("group_identify_missing_key", func(t *testing.T) {
|
||||
err := client.Enqueue(GroupIdentify{
|
||||
Type: "company",
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFeatureFlag_MalformedResponses tests handling of malformed feature flag responses
|
||||
func TestFeatureFlag_MalformedResponses(t *testing.T) {
|
||||
malformedResponses := []struct {
|
||||
name string
|
||||
response string
|
||||
}{
|
||||
{"invalid_json", `{invalid json`},
|
||||
{"flags_not_object", `{"featureFlags": "not an object"}`},
|
||||
{"empty_response", ``},
|
||||
{"null_response", `null`},
|
||||
{"array_instead_of_object", `[]`},
|
||||
}
|
||||
|
||||
for _, tc := range malformedResponses {
|
||||
t.Run(tc.name, func(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(tc.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
PersonalApiKey: "test-personal",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// Should not panic, should handle gracefully
|
||||
result, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "test-flag",
|
||||
DistinctId: "user_1",
|
||||
})
|
||||
|
||||
// Either returns error or returns false/nil - shouldn't crash
|
||||
t.Logf("Result: %v, Error: %v", result, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_ServerErrors tests client behavior with various server error codes
|
||||
func TestClient_ServerErrors(t *testing.T) {
|
||||
errorCodes := []int{
|
||||
http.StatusBadRequest,
|
||||
http.StatusUnauthorized,
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout,
|
||||
}
|
||||
|
||||
for _, code := range errorCodes {
|
||||
t.Run(http.StatusText(code), func(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
maxRetries := 0
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
MaxRetries: &maxRetries, // Disable retries for faster test
|
||||
})
|
||||
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
})
|
||||
require.NoError(t, err) // Enqueue should not fail immediately
|
||||
|
||||
client.Close() // Will attempt to flush
|
||||
t.Logf("Server received %d request(s) for status %d", callCount, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_NetworkFailures tests client behavior with network failures
|
||||
func TestClient_NetworkFailures(t *testing.T) {
|
||||
t.Run("connection_refused", func(t *testing.T) {
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: "http://localhost:1", // Port 1 should refuse connections
|
||||
MaxRetries: Ptr(0), // Skip retries to avoid timeout
|
||||
})
|
||||
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
})
|
||||
require.NoError(t, err) // Enqueue should not fail immediately
|
||||
|
||||
// Close will try to flush and should handle the error gracefully
|
||||
err = client.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid_url", func(t *testing.T) {
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: "not-a-valid-url",
|
||||
MaxRetries: Ptr(0), // Skip retries to avoid timeout
|
||||
})
|
||||
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProperties_EdgeCases tests edge cases in property handling
|
||||
func TestProperties_EdgeCases(t *testing.T) {
|
||||
t.Run("nil_value", func(t *testing.T) {
|
||||
props := Properties{"key": nil}
|
||||
// Should serialize without error
|
||||
_, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty_key", func(t *testing.T) {
|
||||
props := Properties{"": "empty key"}
|
||||
_, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("very_large_string", func(t *testing.T) {
|
||||
largeString := strings.Repeat("x", 1000000)
|
||||
props := Properties{"key": largeString}
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
require.True(t, len(data) > 1000000)
|
||||
})
|
||||
|
||||
t.Run("special_characters", func(t *testing.T) {
|
||||
props := Properties{
|
||||
"unicode": "日本語テスト",
|
||||
"emoji": "🚀🔥💡",
|
||||
"newlines": "line1\nline2\nline3",
|
||||
"tabs": "col1\tcol2\tcol3",
|
||||
"quotes": `"quoted"`,
|
||||
"backslash": `path\to\file`,
|
||||
"null_bytes": "before\x00after",
|
||||
}
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be valid JSON that can be unmarshaled
|
||||
var decoded map[string]interface{}
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("deeply_nested", func(t *testing.T) {
|
||||
nested := map[string]interface{}{}
|
||||
current := nested
|
||||
for i := 0; i < 100; i++ {
|
||||
next := map[string]interface{}{}
|
||||
current["nested"] = next
|
||||
current = next
|
||||
}
|
||||
current["value"] = "deep"
|
||||
|
||||
props := Properties{"deep": nested}
|
||||
_, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("circular_reference_avoided", func(t *testing.T) {
|
||||
// Properties using map[string]interface{} can't have circular refs
|
||||
// but we can test that large recursive structures work
|
||||
props := Properties{
|
||||
"a": map[string]interface{}{
|
||||
"b": map[string]interface{}{
|
||||
"c": "value",
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfig_Validation tests configuration validation
|
||||
func TestConfig_Validation(t *testing.T) {
|
||||
t.Run("negative_batch_size", func(t *testing.T) {
|
||||
// SDK validates and rejects negative batch sizes
|
||||
_, err := NewWithConfig("test-key", Config{
|
||||
BatchSize: -1,
|
||||
Transport: NoOpTransport(),
|
||||
})
|
||||
require.Error(t, err, "negative batch size should be rejected")
|
||||
})
|
||||
|
||||
t.Run("zero_batch_size", func(t *testing.T) {
|
||||
// Zero batch size is valid - SDK uses default
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
BatchSize: 0,
|
||||
Transport: NoOpTransport(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
})
|
||||
|
||||
t.Run("empty_api_key", func(t *testing.T) {
|
||||
// Note: SDK currently accepts empty API keys (server will reject them)
|
||||
// This test documents current behavior
|
||||
client, err := NewWithConfig("", Config{
|
||||
Transport: NoOpTransport(),
|
||||
})
|
||||
require.NoError(t, err, "SDK accepts empty API key (server-side validation)")
|
||||
defer client.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// TestFeatureFlag_TimeoutHandling tests flag evaluation with network delays
|
||||
func TestFeatureFlag_TimeoutHandling(t *testing.T) {
|
||||
t.Run("slow_response", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.Write([]byte(`{"featureFlags": {"test-flag": true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
PersonalApiKey: "test-personal",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
start := time.Now()
|
||||
_, err := client.GetFeatureFlag(FeatureFlagPayload{
|
||||
Key: "test-flag",
|
||||
DistinctId: "user_1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
t.Logf("Request took %v, error: %v", elapsed, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEnqueue_AfterClose tests behavior when enqueueing after client is closed
|
||||
func TestEnqueue_AfterClose(t *testing.T) {
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Transport: NoOpTransport(),
|
||||
})
|
||||
|
||||
// Close the client
|
||||
client.Close()
|
||||
|
||||
// Try to enqueue - should not panic, should return error or handle gracefully
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
})
|
||||
|
||||
// The SDK might allow this or return an error - either is acceptable
|
||||
t.Logf("Enqueue after close result: %v", err)
|
||||
}
|
||||
|
||||
// TestCallback_Errors tests that callback errors are handled gracefully
|
||||
func TestCallback_Errors(t *testing.T) {
|
||||
callbackCalled := false
|
||||
panicCallback := struct {
|
||||
Callback
|
||||
}{}
|
||||
|
||||
// Create a callback that panics
|
||||
type panicyCallback struct{}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
callback := NewUnifiedCallback(t)
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
Callback: callback,
|
||||
})
|
||||
|
||||
err := client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
client.Close()
|
||||
|
||||
callbackCalled = callback.successCount > 0 || callback.failureCount > 0
|
||||
t.Logf("Callback was called: %v, panic callback: %v", callbackCalled, panicCallback)
|
||||
}
|
||||
|
||||
// TestBatch_EmptyAndLarge tests batching edge cases
|
||||
func TestBatch_EmptyAndLarge(t *testing.T) {
|
||||
t.Run("large_batch", func(t *testing.T) {
|
||||
var received int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var b struct {
|
||||
Messages []json.RawMessage `json:"batch"`
|
||||
}
|
||||
json.Unmarshal(body, &b)
|
||||
received += len(b.Messages)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1000,
|
||||
})
|
||||
|
||||
// Send more than default batch size
|
||||
for i := 0; i < 500; i++ {
|
||||
client.Enqueue(Capture{
|
||||
DistinctId: "user_1",
|
||||
Event: "test_event",
|
||||
Properties: Properties{"index": i},
|
||||
})
|
||||
}
|
||||
|
||||
client.Close()
|
||||
require.Equal(t, 500, received)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrepareForSend_EdgeCases tests prepareForSend with edge case data
|
||||
func TestPrepareForSend_EdgeCases(t *testing.T) {
|
||||
t.Run("empty_capture", func(t *testing.T) {
|
||||
capture := Capture{Type: "capture"}
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(data), 0, "Even empty capture should have non-zero serialized size")
|
||||
require.NotNil(t, apiMsg, "APIMessage should not be nil")
|
||||
})
|
||||
|
||||
t.Run("nil_properties_and_groups", func(t *testing.T) {
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "user",
|
||||
Event: "event",
|
||||
Properties: nil,
|
||||
Groups: nil,
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(data), 0)
|
||||
require.NotNil(t, apiMsg)
|
||||
})
|
||||
|
||||
t.Run("empty_properties_and_groups", func(t *testing.T) {
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "user",
|
||||
Event: "event",
|
||||
Properties: Properties{},
|
||||
Groups: Groups{},
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(data), 0)
|
||||
require.NotNil(t, apiMsg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrepareForSend_SerializationErrors tests that prepareForSend returns errors for unencodable values
|
||||
func TestPrepareForSend_SerializationErrors(t *testing.T) {
|
||||
t.Run("channel_in_properties", func(t *testing.T) {
|
||||
ch := make(chan int)
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "user",
|
||||
Event: "event",
|
||||
Properties: Properties{"channel": ch},
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
require.Error(t, err, "Should fail to serialize channel")
|
||||
require.Nil(t, data, "Data should be nil on error")
|
||||
require.NotNil(t, apiMsg, "APIMessage should be returned for callback")
|
||||
})
|
||||
|
||||
t.Run("function_in_properties", func(t *testing.T) {
|
||||
fn := func() {}
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "user",
|
||||
Event: "event",
|
||||
Properties: Properties{"func": fn},
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
require.Error(t, err, "Should fail to serialize function")
|
||||
require.Nil(t, data)
|
||||
require.NotNil(t, apiMsg, "APIMessage should be returned for callback")
|
||||
})
|
||||
|
||||
t.Run("channel_in_groups", func(t *testing.T) {
|
||||
ch := make(chan string)
|
||||
capture := Capture{
|
||||
Type: "capture",
|
||||
DistinctId: "user",
|
||||
Event: "event",
|
||||
Groups: Groups{"company": ch},
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
require.Error(t, err, "Should fail to serialize channel in groups")
|
||||
require.Nil(t, data)
|
||||
require.NotNil(t, apiMsg, "APIMessage should be returned for callback")
|
||||
})
|
||||
|
||||
t.Run("identify_with_unencodable", func(t *testing.T) {
|
||||
ch := make(chan int)
|
||||
identify := Identify{
|
||||
DistinctId: "user",
|
||||
Properties: Properties{"channel": ch},
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(identify)
|
||||
require.Error(t, err, "Identify should fail with unencodable value")
|
||||
require.Nil(t, data)
|
||||
require.NotNil(t, apiMsg, "APIMessage should be returned for callback")
|
||||
})
|
||||
|
||||
t.Run("alias_serializes_ok", func(t *testing.T) {
|
||||
// Alias has no Properties, so should always serialize successfully
|
||||
alias := Alias{
|
||||
DistinctId: "user",
|
||||
Alias: "alias",
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(alias)
|
||||
require.NoError(t, err, "Alias should serialize successfully")
|
||||
require.NotNil(t, data)
|
||||
require.NotNil(t, apiMsg)
|
||||
})
|
||||
|
||||
t.Run("group_identify_with_unencodable", func(t *testing.T) {
|
||||
fn := func() {}
|
||||
groupIdentify := GroupIdentify{
|
||||
Type: "company",
|
||||
Key: "company_1",
|
||||
Properties: Properties{"func": fn},
|
||||
}
|
||||
data, apiMsg, err := prepareForSend(groupIdentify)
|
||||
require.Error(t, err, "GroupIdentify should fail with unencodable value")
|
||||
require.Nil(t, data)
|
||||
require.NotNil(t, apiMsg, "APIMessage should be returned for callback")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGroups_EdgeCases tests group property edge cases
|
||||
func TestGroups_EdgeCases(t *testing.T) {
|
||||
t.Run("empty_group_type", func(t *testing.T) {
|
||||
groups := Groups{"": "value"}
|
||||
_, err := json.Marshal(groups)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty_group_value", func(t *testing.T) {
|
||||
groups := Groups{"type": ""}
|
||||
_, err := json.Marshal(groups)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("many_groups", func(t *testing.T) {
|
||||
groups := Groups{}
|
||||
for i := 0; i < 100; i++ {
|
||||
groups[string(rune('a'+i%26))+string(rune('0'+i/26))] = "value"
|
||||
}
|
||||
_, err := json.Marshal(groups)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
+416
-169
@@ -2,17 +2,18 @@ package posthog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/hashicorp/golang-lru/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,6 +21,13 @@ const (
|
||||
CACHE_DEFAULT_SIZE = 300_000
|
||||
|
||||
propertyGeoipDisable = "$geoip_disable"
|
||||
|
||||
// DefaultIdleConns is the default max idle connections for the HTTP client.
|
||||
DefaultIdleConns = 100
|
||||
// DefaultIdleConnsPerHost is the default max idle connections per host.
|
||||
// This is higher than http.DefaultTransport's value of 2 to reduce
|
||||
// connection churn when sending batches.
|
||||
DefaultIdleConnsPerHost = 10
|
||||
)
|
||||
|
||||
type EnqueueClient interface {
|
||||
@@ -71,8 +79,25 @@ type Client interface {
|
||||
// NB: This is only available when using a PersonalApiKey
|
||||
GetFeatureFlags() ([]FeatureFlag, error)
|
||||
|
||||
// GetLastCapturedEvent returns the last captured event
|
||||
GetLastCapturedEvent() *Capture
|
||||
// CloseWithContext gracefully shuts down the client with the provided context.
|
||||
// The context can be used to control the shutdown deadline.
|
||||
CloseWithContext(context.Context) error
|
||||
}
|
||||
|
||||
// preparedMessage bundles a pre-serialized message with the original APIMessage.
|
||||
// The data field contains pre-serialized JSON for efficient batch building.
|
||||
// The msg field retains the original APIMessage for callbacks.
|
||||
// Size is obtained via len(data) - O(1) since json.RawMessage is []byte.
|
||||
type preparedMessage struct {
|
||||
data json.RawMessage // pre-serialized JSON for batch submission
|
||||
msg APIMessage // original message for callbacks
|
||||
}
|
||||
|
||||
// preparedBatch holds both raw data for efficient serialization and
|
||||
// original API messages for callbacks.
|
||||
type preparedBatch struct {
|
||||
data []json.RawMessage // pre-serialized messages for batch submission
|
||||
msgs []APIMessage // original messages for callbacks
|
||||
}
|
||||
|
||||
type client struct {
|
||||
@@ -81,8 +106,16 @@ type client struct {
|
||||
|
||||
// This channel is where the `Enqueue` method writes messages so they can be
|
||||
// picked up and pushed by the backend goroutine taking care of applying the
|
||||
// batching rules.
|
||||
msgs chan APIMessage
|
||||
// batching rules. Messages are pre-converted to APIMessage format with
|
||||
// pre-computed size to avoid race conditions.
|
||||
msgs chan preparedMessage
|
||||
|
||||
// Channel for sending batches to workers. Acts as both a queue and
|
||||
// concurrency limiter - when full, new batches are shed via failure callback.
|
||||
batches chan preparedBatch
|
||||
|
||||
// Tracks in-flight batches for graceful shutdown
|
||||
inFlight atomic.Int64
|
||||
|
||||
// These two channels are used to synchronize the client shutting down when
|
||||
// `Close` is called.
|
||||
@@ -92,6 +125,18 @@ type client struct {
|
||||
quit chan struct{}
|
||||
shutdown chan struct{}
|
||||
|
||||
// Context and cancel function for graceful shutdown.
|
||||
// When Close is called, the context is cancelled to signal all goroutines
|
||||
// to stop, including in-flight HTTP requests.
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// closeOnce ensures Close is idempotent
|
||||
closeOnce sync.Once
|
||||
|
||||
// closed is set to true when the client is closed, used to fast-fail Enqueue
|
||||
closed atomic.Bool
|
||||
|
||||
// This HTTP client is used to send requests to the backend, it uses the
|
||||
// HTTP transport provided in the configuration.
|
||||
http http.Client
|
||||
@@ -101,11 +146,6 @@ type client struct {
|
||||
|
||||
distinctIdsFeatureFlagsReported *lru.Cache[flagUser, struct{}]
|
||||
|
||||
// Last captured event
|
||||
lastCapturedEvent *Capture
|
||||
// Mutex to protect last captured event
|
||||
lastEventMutex sync.RWMutex
|
||||
|
||||
// Decider for feature flag methods
|
||||
decider decider
|
||||
}
|
||||
@@ -139,13 +179,24 @@ func NewWithConfig(apiKey string, config Config) (cli Client, err error) {
|
||||
if err != nil && config.Logger != nil {
|
||||
config.Logger.Errorf("Error creating cache for reported flags: %v", err)
|
||||
}
|
||||
|
||||
// Channel sizing:
|
||||
// - batches queue sized by MaxEnqueuedRequests (default 1000)
|
||||
// - msgs queue sized to hold multiple batches worth of messages
|
||||
batchesQueueSize := config.MaxEnqueuedRequests
|
||||
msgQueueSize := max(100, config.BatchSize*10)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c := &client{
|
||||
Config: config,
|
||||
key: apiKey,
|
||||
msgs: make(chan APIMessage, 100),
|
||||
msgs: make(chan preparedMessage, msgQueueSize),
|
||||
batches: make(chan preparedBatch, batchesQueueSize),
|
||||
quit: make(chan struct{}),
|
||||
shutdown: make(chan struct{}),
|
||||
http: makeHttpClient(config.Transport),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
http: makeHttpClient(config.Transport, config.BatchUploadTimeout),
|
||||
distinctIdsFeatureFlagsReported: reportedCache,
|
||||
}
|
||||
|
||||
@@ -178,12 +229,20 @@ func NewWithConfig(apiKey string, config Config) (cli Client, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func makeHttpClient(transport http.RoundTripper) http.Client {
|
||||
func makeHttpClient(transport http.RoundTripper, timeout time.Duration) http.Client {
|
||||
// If no custom transport provided, clone DefaultTransport with tuned connection pool
|
||||
if transport == nil {
|
||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.MaxIdleConns = DefaultIdleConns
|
||||
t.MaxIdleConnsPerHost = DefaultIdleConnsPerHost
|
||||
transport = t
|
||||
}
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
if supportsTimeout(transport) {
|
||||
httpClient.Timeout = 10 * time.Second
|
||||
httpClient.Timeout = timeout
|
||||
}
|
||||
return httpClient
|
||||
}
|
||||
@@ -221,6 +280,11 @@ func dereferenceMessage(msg Message) Message {
|
||||
}
|
||||
|
||||
func (c *client) Enqueue(msg Message) (err error) {
|
||||
// Fast path: check if client is closed before doing any work
|
||||
if c.closed.Load() {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
msg = dereferenceMessage(msg)
|
||||
if err = msg.Validate(); err != nil {
|
||||
return
|
||||
@@ -228,23 +292,55 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
|
||||
var ts = c.now()
|
||||
|
||||
// Helper to send prepared message with panic recovery
|
||||
sendPrepared := func(prepared preparedMessage) {
|
||||
defer func() {
|
||||
// When the `msgs` channel is closed writing to it will trigger a panic.
|
||||
// To avoid letting the panic propagate to the caller we recover from it
|
||||
// and instead report that the client has been closed and shouldn't be
|
||||
// used anymore.
|
||||
if recover() != nil {
|
||||
err = ErrClosed
|
||||
}
|
||||
}()
|
||||
c.msgs <- prepared
|
||||
}
|
||||
|
||||
switch m := msg.(type) {
|
||||
case Alias:
|
||||
m.Type = "alias"
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
m.DisableGeoIP = c.GetDisableGeoIP()
|
||||
msg = m
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
return
|
||||
}
|
||||
sendPrepared(preparedMessage{data: data, msg: apiMsg})
|
||||
return
|
||||
|
||||
case Identify:
|
||||
m.Type = "identify"
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
m.DisableGeoIP = c.GetDisableGeoIP()
|
||||
msg = m
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
return
|
||||
}
|
||||
sendPrepared(preparedMessage{data: data, msg: apiMsg})
|
||||
return
|
||||
|
||||
case GroupIdentify:
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
m.DisableGeoIP = c.GetDisableGeoIP()
|
||||
msg = m
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
return
|
||||
}
|
||||
sendPrepared(preparedMessage{data: data, msg: apiMsg})
|
||||
return
|
||||
|
||||
case Capture:
|
||||
m.Type = "capture"
|
||||
@@ -291,50 +387,30 @@ func (c *client) Enqueue(msg Message) (err error) {
|
||||
m.Properties = NewProperties()
|
||||
}
|
||||
m.Properties.Merge(c.DefaultEventProperties)
|
||||
c.setLastCapturedEvent(m)
|
||||
msg = m
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
return
|
||||
}
|
||||
sendPrepared(preparedMessage{data: data, msg: apiMsg})
|
||||
return
|
||||
|
||||
case Exception:
|
||||
m.Type = "exception"
|
||||
m.Timestamp = makeTimestamp(m.Timestamp, ts)
|
||||
m.DisableGeoIP = c.GetDisableGeoIP()
|
||||
msg = m
|
||||
data, apiMsg, serErr := prepareForSend(m)
|
||||
if serErr != nil {
|
||||
c.notifyFailure([]APIMessage{apiMsg}, serErr)
|
||||
return
|
||||
}
|
||||
sendPrepared(preparedMessage{data: data, msg: apiMsg})
|
||||
return
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("messages with custom types cannot be enqueued: %T", msg)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// When the `msgs` channel is closed writing to it will trigger a panic.
|
||||
// To avoid letting the panic propagate to the caller we recover from it
|
||||
// and instead report that the client has been closed and shouldn't be
|
||||
// used anymore.
|
||||
if recover() != nil {
|
||||
err = ErrClosed
|
||||
}
|
||||
}()
|
||||
|
||||
c.msgs <- msg.APIfy()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *client) setLastCapturedEvent(event Capture) {
|
||||
c.lastEventMutex.Lock()
|
||||
defer c.lastEventMutex.Unlock()
|
||||
c.lastCapturedEvent = &event
|
||||
}
|
||||
|
||||
func (c *client) GetLastCapturedEvent() *Capture {
|
||||
c.lastEventMutex.RLock()
|
||||
defer c.lastEventMutex.RUnlock()
|
||||
if c.lastCapturedEvent == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to avoid data races
|
||||
eventCopy := *c.lastCapturedEvent
|
||||
return &eventCopy
|
||||
}
|
||||
|
||||
func (c *client) IsFeatureEnabled(flagConfig FeatureFlagPayload) (interface{}, error) {
|
||||
@@ -382,6 +458,18 @@ func (c *client) GetFeatureFlagPayload(flagConfig FeatureFlagPayload) (string, e
|
||||
}
|
||||
|
||||
func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, error) {
|
||||
return c.getFeatureFlagWithContext(context.Background(), flagConfig)
|
||||
}
|
||||
|
||||
// getFeatureFlagWithContext returns variant value if multivariant flag or otherwise a boolean
|
||||
// indicating if the given flag is on or off for the user.
|
||||
// The context can be used to control timeouts and cancellation.
|
||||
func (c *client) getFeatureFlagWithContext(ctx context.Context, flagConfig FeatureFlagPayload) (interface{}, error) {
|
||||
// Check context before starting
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := flagConfig.validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -411,6 +499,11 @@ func (c *client) GetFeatureFlag(flagConfig FeatureFlagPayload) (interface{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check context after flag evaluation
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
|
||||
cacheKey := flagUser{flagConfig.DistinctId, flagConfig.Key}
|
||||
if *flagConfig.SendFeatureFlagEvents && !c.distinctIdsFeatureFlagsReported.Contains(cacheKey) {
|
||||
var properties = NewProperties().
|
||||
@@ -456,9 +549,6 @@ func (c *client) GetRemoteConfigPayload(flagKey string) (string, error) {
|
||||
return c.makeRemoteConfigRequest(flagKey)
|
||||
}
|
||||
|
||||
// ErrNoPersonalAPIKey is returned when oen tries to use feature flags without specifying a PersonalAPIKey
|
||||
var ErrNoPersonalAPIKey = errors.New("no PersonalAPIKey provided")
|
||||
|
||||
// GetFeatureFlags returns all feature flag definitions used for local evaluation
|
||||
// This is only available when using a PersonalApiKey. Not to be confused with
|
||||
// GetAllFlags, which returns all flags and their values for a given user.
|
||||
@@ -476,6 +566,18 @@ func (c *client) GetFeatureFlags() ([]FeatureFlag, error) {
|
||||
// This first attempts local evaluation if a poller exists, otherwise it falls
|
||||
// back to the flags endpoint
|
||||
func (c *client) GetAllFlags(flagConfig FeatureFlagPayloadNoKey) (map[string]interface{}, error) {
|
||||
return c.getAllFlagsWithContext(context.Background(), flagConfig)
|
||||
}
|
||||
|
||||
// getAllFlagsWithContext returns all flags and their values for a given user.
|
||||
// The context can be used to control timeouts and cancellation.
|
||||
// A flag value is either a boolean or a variant string (for multivariate flags)
|
||||
func (c *client) getAllFlagsWithContext(ctx context.Context, flagConfig FeatureFlagPayloadNoKey) (map[string]interface{}, error) {
|
||||
// Check context before starting
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := flagConfig.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -494,84 +596,203 @@ func (c *client) GetAllFlags(flagConfig FeatureFlagPayloadNoKey) (map[string]int
|
||||
flagConfig.PersonProperties, flagConfig.GroupProperties)
|
||||
}
|
||||
|
||||
// Check context after operation
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
return flagsValue, err
|
||||
}
|
||||
|
||||
// Close and flush metrics.
|
||||
func (c *client) Close() (err error) {
|
||||
defer func() {
|
||||
// Always recover, a panic could be raised if `c`.quit was closed which
|
||||
// means the method was called more than once.
|
||||
if recover() != nil {
|
||||
err = ErrClosed
|
||||
}
|
||||
}()
|
||||
close(c.quit)
|
||||
<-c.shutdown
|
||||
return
|
||||
}
|
||||
|
||||
// Asynchronously send a batched requests.
|
||||
func (c *client) sendAsync(msgs []message, wg *sync.WaitGroup, ex *executor) {
|
||||
wg.Add(1)
|
||||
|
||||
if !ex.do(func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
// In case a bug is introduced in the send function that triggers
|
||||
// a panic, we don't want this to ever crash the application so we
|
||||
// catch it here and log it instead.
|
||||
if err := recover(); err != nil {
|
||||
c.Errorf("panic - %s", err)
|
||||
}
|
||||
}()
|
||||
c.send(msgs)
|
||||
}) {
|
||||
wg.Done()
|
||||
c.Errorf("sending messages failed - %s", ErrTooManyRequests)
|
||||
c.notifyFailure(msgs, ErrTooManyRequests)
|
||||
// Close gracefully shuts down the client, flushing any pending messages.
|
||||
// If ShutdownTimeout is set to a positive duration, Close waits up to that
|
||||
// duration for in-flight requests to complete. Otherwise, it waits indefinitely.
|
||||
// Close is safe to call multiple times; subsequent calls return ErrClosed.
|
||||
func (c *client) Close() error {
|
||||
if c.ShutdownTimeout <= 0 {
|
||||
// Zero or negative timeout means wait indefinitely (backward compatible)
|
||||
return c.CloseWithContext(context.Background())
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return c.CloseWithContext(ctx)
|
||||
}
|
||||
|
||||
// Send batch request.
|
||||
func (c *client) send(msgs []message) {
|
||||
// CloseWithContext gracefully shuts down the client with the provided context.
|
||||
// The context can be used to control the shutdown deadline. If the context
|
||||
// is cancelled before shutdown completes, in-flight requests may be aborted.
|
||||
// CloseWithContext is safe to call multiple times; subsequent calls return ErrClosed.
|
||||
func (c *client) CloseWithContext(ctx context.Context) error {
|
||||
var err error
|
||||
alreadyClosed := true
|
||||
|
||||
b, err := json.Marshal(batch{
|
||||
ApiKey: c.key,
|
||||
HistoricalMigration: c.HistoricalMigration,
|
||||
Messages: msgs,
|
||||
c.closeOnce.Do(func() {
|
||||
alreadyClosed = false
|
||||
// Mark as closed to fast-fail new Enqueue calls
|
||||
c.closed.Store(true)
|
||||
|
||||
// Signal the batch loop to stop and drain
|
||||
close(c.quit)
|
||||
|
||||
// Wait for shutdown with timeout from provided context
|
||||
select {
|
||||
case <-c.shutdown:
|
||||
// Clean shutdown completed
|
||||
c.debugf("shutdown completed successfully")
|
||||
case <-ctx.Done():
|
||||
// Timeout exceeded - cancel client context to abort in-flight requests
|
||||
c.cancel()
|
||||
err = fmt.Errorf("shutdown timeout: %w", ctx.Err())
|
||||
c.Warnf("shutdown timeout exceeded, some messages may be lost")
|
||||
// Wait for shutdown to acknowledge cancellation
|
||||
<-c.shutdown
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.Errorf("marshalling messages - %s", err)
|
||||
c.notifyFailure(msgs, err)
|
||||
if alreadyClosed {
|
||||
return ErrClosed
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// processBatch handles a single batch with guaranteed counter decrement.
|
||||
// It receives the batch from the channel and processes it.
|
||||
func (c *client) processBatch() {
|
||||
// Receive batch from channel - this also "releases" the semaphore slot
|
||||
batch, ok := <-c.batches
|
||||
if !ok {
|
||||
// Channel closed during shutdown
|
||||
c.inFlight.Add(-1)
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i != c.maxAttempts; i++ {
|
||||
if err = c.upload(b); err == nil {
|
||||
c.notifySuccess(msgs)
|
||||
// CRITICAL: defer ensures counter decrements on ANY exit path
|
||||
// (success, JSON error, HTTP error, panic, context cancellation)
|
||||
defer c.inFlight.Add(-1)
|
||||
|
||||
// Recover from panics to prevent goroutine death without cleanup
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
c.Errorf("panic in batch processor: %v", err)
|
||||
c.notifyFailure(batch.msgs, fmt.Errorf("panic: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
c.send(batch)
|
||||
}
|
||||
|
||||
// sendBatch attempts to enqueue a batch for processing.
|
||||
// Returns true if successful, false if the queue is full after waiting for
|
||||
// BatchSubmitTimeout (default 100ms). This backpressure smoothing allows
|
||||
// in-flight requests to complete during transient latency spikes, reducing data loss.
|
||||
// Set BatchSubmitTimeout to a negative value for non-blocking behavior.
|
||||
//
|
||||
// On success, spawns a goroutine to process the batch. The batches channel
|
||||
// acts as both a queue and concurrency limiter.
|
||||
func (c *client) sendBatch(batch preparedBatch) bool {
|
||||
c.inFlight.Add(1)
|
||||
|
||||
// Negative timeout = non-blocking (immediate drop when queue is full)
|
||||
if c.BatchSubmitTimeout < 0 {
|
||||
select {
|
||||
case c.batches <- batch:
|
||||
go c.processBatch()
|
||||
return true
|
||||
default:
|
||||
c.inFlight.Add(-1)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Blocking send with timeout - allows in-flight requests to complete during latency spikes
|
||||
timer := time.NewTimer(c.BatchSubmitTimeout)
|
||||
select {
|
||||
case c.batches <- batch:
|
||||
timer.Stop()
|
||||
go c.processBatch()
|
||||
return true
|
||||
case <-timer.C:
|
||||
c.inFlight.Add(-1)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// awaitDrain polls until all in-flight batches complete or context times out.
|
||||
func (c *client) awaitDrain(ctx context.Context) error {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for c.inFlight.Load() > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
// continue polling
|
||||
}
|
||||
}
|
||||
c.debugf("all in-flight batches completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// send encodes batch wrapper and performs HTTP POST with retries.
|
||||
// Messages are already pre-serialized, so only the batch wrapper is serialized here.
|
||||
func (c *client) send(pb preparedBatch) {
|
||||
// Build batch with pre-serialized messages - only the wrapper is serialized here
|
||||
b, err := json.Marshal(batch{
|
||||
ApiKey: c.key,
|
||||
HistoricalMigration: c.HistoricalMigration,
|
||||
Messages: pb.data,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.Errorf("marshalling batch wrapper - %s", err)
|
||||
c.notifyFailure(pb.msgs, err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < c.maxAttempts; i++ {
|
||||
if err = c.upload(c.ctx, b); err == nil {
|
||||
c.notifySuccess(pb.msgs)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for either a retry timeout or the client to be closed.
|
||||
// Check if context is cancelled (shutdown timeout exceeded)
|
||||
if c.ctx.Err() != nil {
|
||||
c.Errorf("%d messages dropped: shutdown timeout", len(pb.msgs))
|
||||
c.notifyFailure(pb.msgs, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for retry or shutdown
|
||||
retryTimer := time.NewTimer(c.RetryAfter(i))
|
||||
select {
|
||||
case <-time.After(c.RetryAfter(i)):
|
||||
case <-retryTimer.C:
|
||||
// continue to next attempt
|
||||
case <-c.quit:
|
||||
c.Errorf("%d messages dropped because they failed to be sent and the client was closed", len(msgs))
|
||||
c.notifyFailure(msgs, err)
|
||||
// Shutdown initiated - stop timer and exit retry loop
|
||||
if !retryTimer.Stop() {
|
||||
<-retryTimer.C // Drain channel if timer already fired
|
||||
}
|
||||
c.notifyFailure(pb.msgs, err)
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
// Context cancelled - stop timer and exit
|
||||
if !retryTimer.Stop() {
|
||||
<-retryTimer.C // Drain channel if timer already fired
|
||||
}
|
||||
c.notifyFailure(pb.msgs, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Errorf("%d messages dropped because they failed to be sent after %d attempts", len(msgs), c.maxAttempts)
|
||||
c.notifyFailure(msgs, err)
|
||||
c.Errorf("%d messages dropped after %d attempts", len(pb.msgs), c.maxAttempts)
|
||||
c.notifyFailure(pb.msgs, err)
|
||||
}
|
||||
|
||||
// Upload serialized batch message.
|
||||
func (c *client) upload(b []byte) error {
|
||||
func (c *client) upload(ctx context.Context, b []byte) error {
|
||||
url := c.Endpoint + "/batch/"
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(b))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
c.Errorf("creating request - %s", err)
|
||||
return err
|
||||
@@ -584,7 +805,6 @@ func (c *client) upload(b []byte) error {
|
||||
req.Header.Add("Content-Length", fmt.Sprintf("%d", len(b)))
|
||||
|
||||
res, err := c.http.Do(req)
|
||||
|
||||
if err != nil {
|
||||
c.Warnf("sending request - %s", err)
|
||||
return err
|
||||
@@ -603,7 +823,7 @@ func (c *client) report(res *http.Response) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
if body, err = ioutil.ReadAll(res.Body); err != nil {
|
||||
if body, err = io.ReadAll(res.Body); err != nil {
|
||||
c.Errorf("response %d %s - %s", res.StatusCode, res.Status, err)
|
||||
return
|
||||
}
|
||||
@@ -612,77 +832,111 @@ func (c *client) report(res *http.Response) (err error) {
|
||||
return fmt.Errorf("%d %s", res.StatusCode, res.Status)
|
||||
}
|
||||
|
||||
// Batch loop.
|
||||
// loop processes messages from the msgs channel, batches them by size,
|
||||
// and spawns goroutines to send batches.
|
||||
func (c *client) loop() {
|
||||
defer close(c.batches) // prevent any pending receives from blocking
|
||||
defer close(c.shutdown)
|
||||
if c.featureFlagsPoller != nil {
|
||||
defer c.featureFlagsPoller.shutdownPoller()
|
||||
}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
defer wg.Wait()
|
||||
var batchData []json.RawMessage
|
||||
var batchMsgs []APIMessage
|
||||
var batchSize int
|
||||
|
||||
tick := time.NewTicker(c.Interval)
|
||||
defer tick.Stop()
|
||||
|
||||
ex := newExecutor(c.maxConcurrentRequests)
|
||||
defer ex.close()
|
||||
// Helper to create and send a batch
|
||||
flushBatch := func() bool {
|
||||
if len(batchData) == 0 {
|
||||
return true
|
||||
}
|
||||
batch := preparedBatch{data: batchData, msgs: batchMsgs}
|
||||
if !c.sendBatch(batch) {
|
||||
c.Errorf("sending batch failed - %s", ErrTooManyRequests)
|
||||
c.notifyFailure(batchMsgs, ErrTooManyRequests)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
mq := messageQueue{
|
||||
maxBatchSize: c.BatchSize,
|
||||
maxBatchBytes: c.maxBatchBytes(),
|
||||
// Helper to reset batch state
|
||||
resetBatch := func() {
|
||||
batchData, batchMsgs, batchSize = nil, nil, 0
|
||||
}
|
||||
|
||||
// Helper to process a single message: validate, batch, flush if needed.
|
||||
// Returns false if message was rejected (oversized).
|
||||
processMessage := func(prepared preparedMessage) bool {
|
||||
msgSize := len(prepared.data)
|
||||
|
||||
if msgSize > maxMessageBytes {
|
||||
c.Errorf("message exceeds maximum size (%d > %d)", msgSize, maxMessageBytes)
|
||||
c.notifyFailure([]APIMessage{prepared.msg}, ErrMessageTooBig)
|
||||
return false
|
||||
}
|
||||
|
||||
if batchSize+msgSize > maxBatchBytes && len(batchData) > 0 {
|
||||
flushBatch()
|
||||
resetBatch()
|
||||
}
|
||||
|
||||
batchData = append(batchData, prepared.data)
|
||||
batchMsgs = append(batchMsgs, prepared.msg)
|
||||
batchSize += msgSize
|
||||
|
||||
if len(batchData) >= c.BatchSize {
|
||||
flushBatch()
|
||||
resetBatch()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-c.msgs:
|
||||
c.push(&mq, msg, wg, ex)
|
||||
case prepared := <-c.msgs:
|
||||
if !processMessage(prepared) {
|
||||
continue
|
||||
}
|
||||
c.debugf("buffer (%d/%d, %d bytes) %v", len(batchData), c.BatchSize, batchSize, prepared.msg)
|
||||
|
||||
case <-tick.C:
|
||||
c.flush(&mq, wg, ex)
|
||||
|
||||
case <-c.quit:
|
||||
c.debugf("exit requested – draining messages")
|
||||
|
||||
// Drain the msg channel, we have to close it first so no more
|
||||
// messages can be pushed and otherwise the loop would never end.
|
||||
close(c.msgs)
|
||||
for msg := range c.msgs {
|
||||
c.push(&mq, msg, wg, ex)
|
||||
if len(batchData) > 0 {
|
||||
c.debugf("interval flush – sending %d messages", len(batchData))
|
||||
flushBatch()
|
||||
resetBatch()
|
||||
}
|
||||
|
||||
c.flush(&mq, wg, ex)
|
||||
c.debugf("exit")
|
||||
case <-c.quit:
|
||||
c.debugf("shutdown requested – draining messages")
|
||||
|
||||
// Close msgs channel to stop accepting new messages
|
||||
close(c.msgs)
|
||||
|
||||
// Drain remaining messages using same logic as normal processing
|
||||
for prepared := range c.msgs {
|
||||
processMessage(prepared)
|
||||
}
|
||||
|
||||
// Flush any remaining messages
|
||||
if len(batchData) > 0 {
|
||||
c.debugf("flushing final batch of %d messages", len(batchData))
|
||||
flushBatch()
|
||||
}
|
||||
|
||||
// Wait for in-flight batches to complete
|
||||
if err := c.awaitDrain(c.ctx); err != nil {
|
||||
c.Warnf("shutdown timeout: %d batches still in flight", c.inFlight.Load())
|
||||
}
|
||||
|
||||
c.debugf("shutdown complete")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) push(q *messageQueue, m APIMessage, wg *sync.WaitGroup, ex *executor) {
|
||||
var msg message
|
||||
var err error
|
||||
|
||||
if msg, err = makeMessage(m, maxMessageBytes); err != nil {
|
||||
c.Errorf("%s - %v", err, m)
|
||||
c.notifyFailure([]message{{m, nil}}, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.debugf("buffer (%d/%d) %v", len(q.pending), c.BatchSize, m)
|
||||
|
||||
if msgs := q.push(msg); msgs != nil {
|
||||
c.debugf("exceeded messages batch limit with batch of %d messages – flushing", len(msgs))
|
||||
c.sendAsync(msgs, wg, ex)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) flush(q *messageQueue, wg *sync.WaitGroup, ex *executor) {
|
||||
if msgs := q.flush(); msgs != nil {
|
||||
c.debugf("flushing %d messages", len(msgs))
|
||||
c.sendAsync(msgs, wg, ex)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) debugf(format string, args ...interface{}) {
|
||||
c.Logger.Debugf(format, args...)
|
||||
}
|
||||
@@ -695,25 +949,18 @@ func (c *client) Warnf(format string, args ...interface{}) {
|
||||
c.Logger.Warnf(format, args...)
|
||||
}
|
||||
|
||||
func (c *client) maxBatchBytes() int {
|
||||
b, _ := json.Marshal(batch{
|
||||
Messages: []message{},
|
||||
})
|
||||
return maxBatchBytes - len(b)
|
||||
}
|
||||
|
||||
func (c *client) notifySuccess(msgs []message) {
|
||||
func (c *client) notifySuccess(msgs []APIMessage) {
|
||||
if c.Callback != nil {
|
||||
for _, m := range msgs {
|
||||
c.Callback.Success(m.msg)
|
||||
c.Callback.Success(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) notifyFailure(msgs []message, err error) {
|
||||
func (c *client) notifyFailure(msgs []APIMessage, err error) {
|
||||
if c.Callback != nil {
|
||||
for _, m := range msgs {
|
||||
c.Callback.Failure(m.msg, err)
|
||||
c.Callback.Failure(m, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-33
@@ -2,11 +2,10 @@ package posthog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -14,9 +13,12 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -110,7 +112,7 @@ var (
|
||||
Proto: r.Proto,
|
||||
ProtoMajor: r.ProtoMajor,
|
||||
ProtoMinor: r.ProtoMinor,
|
||||
Body: ioutil.NopCloser(strings.NewReader("")),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
@@ -126,7 +128,7 @@ var (
|
||||
Proto: r.Proto,
|
||||
ProtoMajor: r.ProtoMajor,
|
||||
ProtoMinor: r.ProtoMinor,
|
||||
Body: ioutil.NopCloser(strings.NewReader(body)),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
@@ -145,7 +147,7 @@ var (
|
||||
Proto: r.Proto,
|
||||
ProtoMajor: r.ProtoMajor,
|
||||
ProtoMinor: r.ProtoMinor,
|
||||
Body: ioutil.NopCloser(strings.NewReader("")),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
@@ -158,7 +160,7 @@ var (
|
||||
Proto: r.Proto,
|
||||
ProtoMajor: r.ProtoMajor,
|
||||
ProtoMinor: r.ProtoMinor,
|
||||
Body: ioutil.NopCloser(readFunc(func(b []byte) (int, error) { return 0, testError })),
|
||||
Body: io.NopCloser(readFunc(func(b []byte) (int, error) { return 0, testError })),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
@@ -175,7 +177,7 @@ func fixture(name string) string {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
b, err := ioutil.ReadAll(f)
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -726,6 +728,7 @@ func TestClientErrorWithMalformedEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClientRoundTripperError(t *testing.T) {
|
||||
t.Parallel()
|
||||
errchan := make(chan error, 1)
|
||||
|
||||
client, _ := NewWithConfig("0123456789", Config{
|
||||
@@ -787,6 +790,7 @@ func TestClientRetryError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClientResponse400(t *testing.T) {
|
||||
t.Parallel()
|
||||
errchan := make(chan error, 1)
|
||||
|
||||
client, _ := NewWithConfig("0123456789", Config{
|
||||
@@ -808,6 +812,7 @@ func TestClientResponse400(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClientResponseBodyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
errchan := make(chan error, 1)
|
||||
|
||||
client, _ := NewWithConfig("0123456789", Config{
|
||||
@@ -831,38 +836,29 @@ func TestClientResponseBodyError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMaxConcurrentRequests(t *testing.T) {
|
||||
reschan := make(chan bool, 1)
|
||||
errchan := make(chan error, 1)
|
||||
func TestClientWorkerPool(t *testing.T) {
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
client, err := NewWithConfig("0123456789", Config{
|
||||
Logger: testLogger{t.Logf, t.Logf},
|
||||
Callback: testCallback{
|
||||
func(m APIMessage) { reschan <- true },
|
||||
func(m APIMessage, e error) { errchan <- e },
|
||||
func(m APIMessage) { successCount.Add(1) },
|
||||
func(m APIMessage, e error) { t.Errorf("unexpected failure: %v", e) },
|
||||
},
|
||||
Transport: testTransportDelayed,
|
||||
// Only one concurrency request can be submitted, because the transport
|
||||
// introduces a short delay one of the uploads should fail.
|
||||
BatchSize: 1,
|
||||
maxConcurrentRequests: 1,
|
||||
// Use single worker with small batch size to test sequential processing
|
||||
BatchSize: 1,
|
||||
MaxEnqueuedRequests: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both messages should succeed with the worker pool (they queue up)
|
||||
require.NoError(t, client.Enqueue(Capture{DistinctId: "A", Event: "B"}))
|
||||
require.NoError(t, client.Enqueue(Capture{DistinctId: "A", Event: "B"}))
|
||||
require.NoError(t, client.Close())
|
||||
close(reschan)
|
||||
close(errchan)
|
||||
|
||||
if _, ok := <-reschan; !ok {
|
||||
t.Error("one of the requests should have succeeded but the result channel was empty")
|
||||
}
|
||||
|
||||
if err := <-errchan; err == nil {
|
||||
t.Error("failure callback not triggered after reaching the request limit")
|
||||
} else if err != ErrTooManyRequests {
|
||||
t.Errorf("invalid error returned by erroring response body: %T: %s", err, err)
|
||||
if successCount.Load() != 2 {
|
||||
t.Errorf("expected 2 successful callbacks, got %d", successCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,7 +1126,7 @@ func TestGetFeatureFlagPayloadWithNoPersonalApiKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check request body
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var requestData FlagsRequestData
|
||||
json.Unmarshal(body, &requestData)
|
||||
if requestData.DistinctId != tt.flagConfig.DistinctId {
|
||||
@@ -1179,11 +1175,27 @@ func TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Capture events via callback
|
||||
var capturedEvent *CaptureInApi
|
||||
var mu sync.Mutex
|
||||
eventCaptured := make(chan struct{}, 1)
|
||||
|
||||
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
|
||||
Endpoint: server.URL,
|
||||
Logger: testLogger{t.Logf, t.Logf},
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 1, // Send immediately
|
||||
Logger: testLogger{t.Logf, t.Logf},
|
||||
Callback: testCallback{
|
||||
func(m APIMessage) {},
|
||||
func(m APIMessage) {
|
||||
if capture, ok := m.(CaptureInApi); ok {
|
||||
mu.Lock()
|
||||
capturedEvent = &capture
|
||||
mu.Unlock()
|
||||
select {
|
||||
case eventCaptured <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
func(m APIMessage, e error) {},
|
||||
},
|
||||
})
|
||||
@@ -1205,7 +1217,17 @@ func TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) {
|
||||
t.Errorf("Expected flag value %v, got: %v", expectedValue, flagValue)
|
||||
}
|
||||
|
||||
lastEvent := client.GetLastCapturedEvent()
|
||||
// Wait for event to be captured
|
||||
select {
|
||||
case <-eventCaptured:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timed out waiting for captured event")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastEvent := capturedEvent
|
||||
mu.Unlock()
|
||||
|
||||
if lastEvent == nil || lastEvent.Event != "$feature_flag_called" {
|
||||
t.Errorf("Expected a $feature_flag_called event, got: %v", lastEvent)
|
||||
}
|
||||
@@ -1330,7 +1352,7 @@ func TestGetFeatureFlagWithNoPersonalApiKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check request body
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var requestData FlagsRequestData
|
||||
json.Unmarshal(body, &requestData)
|
||||
if requestData.DistinctId != tt.flagConfig.DistinctId {
|
||||
@@ -1467,7 +1489,7 @@ func TestGetAllFeatureFlagsWithNoPersonalApiKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check request body
|
||||
body, _ := ioutil.ReadAll(r.Body)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var requestData FlagsRequestData
|
||||
json.Unmarshal(body, &requestData)
|
||||
if requestData.DistinctId != tt.flagConfig.DistinctId {
|
||||
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStress_EndToEndThroughput measures end-to-end delivery at various loads
|
||||
func TestStress_EndToEndThroughput(t *testing.T) {
|
||||
eventCounts := []int{100, 250, 500}
|
||||
concurrencyLevels := []int{1, 10, 100, 500, 1000}
|
||||
|
||||
for _, eventCount := range eventCounts {
|
||||
for _, concurrency := range concurrencyLevels {
|
||||
t.Run(fmt.Sprintf("events_%d_goroutines_%d", eventCount, concurrency), func(t *testing.T) {
|
||||
// PRE-GENERATE all events with realistic cardinality distribution
|
||||
pool := NewEventPoolWithCardinalityDistribution(eventCount)
|
||||
events := pool.Events()
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Distribute events across goroutines
|
||||
var wg sync.WaitGroup
|
||||
var errorCount atomic.Int64
|
||||
eventsPerGoroutine := eventCount / concurrency
|
||||
if eventsPerGoroutine == 0 {
|
||||
eventsPerGoroutine = 1
|
||||
}
|
||||
|
||||
actualConcurrency := concurrency
|
||||
if actualConcurrency > eventCount {
|
||||
actualConcurrency = eventCount
|
||||
}
|
||||
|
||||
for g := 0; g < actualConcurrency && g*eventsPerGoroutine < eventCount; g++ {
|
||||
wg.Add(1)
|
||||
startIdx := g * eventsPerGoroutine
|
||||
endIdx := (g + 1) * eventsPerGoroutine
|
||||
if endIdx > eventCount {
|
||||
endIdx = eventCount
|
||||
}
|
||||
// Handle remainder for last goroutine
|
||||
if g == actualConcurrency-1 {
|
||||
endIdx = eventCount
|
||||
}
|
||||
|
||||
go func(start, end int) {
|
||||
defer wg.Done()
|
||||
for i := start; i < end; i++ {
|
||||
if err := client.Enqueue(events[i]); err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}(startIdx, endIdx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
throughput := float64(eventCount) / elapsed.Seconds()
|
||||
|
||||
t.Logf("Throughput: %.2f events/sec, Total time: %v", throughput, elapsed)
|
||||
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"No enqueue errors should occur")
|
||||
require.Equal(t, int64(eventCount), received.Load(),
|
||||
"All events should be delivered")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_BatchSizeBoundaries tests behavior at batch size limits
|
||||
func TestStress_BatchSizeBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test batch sizes around common boundaries (50, 250 are defaults)
|
||||
batchSizes := []int{49, 50, 51, 100, 249, 250, 251, 500}
|
||||
|
||||
for _, batchSize := range batchSizes {
|
||||
t.Run(fmt.Sprintf("batch_%d", batchSize), func(t *testing.T) {
|
||||
// Use enough events to force multiple batches (at least 3x batch size, minimum 300)
|
||||
eventCount := batchSize * 3
|
||||
if eventCount < 300 {
|
||||
eventCount = 300
|
||||
}
|
||||
pool := NewEventPoolWithCardinalityDistribution(eventCount)
|
||||
|
||||
var batchesReceived atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
batchesReceived.Add(1)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: batchSize,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var errorCount int
|
||||
for _, e := range pool.Events() {
|
||||
if err := client.Enqueue(e); err != nil {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
client.Close()
|
||||
require.Equal(t, 0, errorCount, "No enqueue errors should occur")
|
||||
|
||||
expectedBatches := (eventCount + batchSize - 1) / batchSize
|
||||
// Allow for some variance due to byte-based batching
|
||||
minExpected := expectedBatches / 2
|
||||
if minExpected < 2 {
|
||||
minExpected = 2
|
||||
}
|
||||
if batchesReceived.Load() < int64(minExpected) {
|
||||
t.Errorf("Expected at least %d batches (ideal: %d), got %d",
|
||||
minExpected, expectedBatches, batchesReceived.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_CardinalityDistribution verifies system handles various property cardinalities
|
||||
func TestStress_CardinalityDistribution(t *testing.T) {
|
||||
cardinalities := []struct {
|
||||
name string
|
||||
cardinality PropertyCardinality
|
||||
}{
|
||||
{"low_10-100_props", CardinalityLow},
|
||||
{"medium_500-2000_props", CardinalityMedium},
|
||||
{"high_2000-10000_props", CardinalityHigh},
|
||||
}
|
||||
|
||||
eventCount := 200
|
||||
concurrency := 50
|
||||
|
||||
for _, tc := range cardinalities {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Pre-generate events with specific cardinality
|
||||
events := make([]Capture, eventCount)
|
||||
for i := range events {
|
||||
events[i] = Capture{
|
||||
DistinctId: fmt.Sprintf("user_%d", i),
|
||||
Event: realisticEvents[i%len(realisticEvents)],
|
||||
Timestamp: time.Now(),
|
||||
Properties: generatePropertiesWithCardinality(i, tc.cardinality),
|
||||
Groups: generateGroupsWithCardinality(i, tc.cardinality),
|
||||
}
|
||||
}
|
||||
|
||||
var received atomic.Int64
|
||||
var totalBytes atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
totalBytes.Add(int64(len(body)))
|
||||
var b batch
|
||||
json.Unmarshal(body, &b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errorCount atomic.Int64
|
||||
eventsPerGoroutine := eventCount / concurrency
|
||||
for g := 0; g < concurrency; g++ {
|
||||
wg.Add(1)
|
||||
startIdx := g * eventsPerGoroutine
|
||||
endIdx := (g + 1) * eventsPerGoroutine
|
||||
if g == concurrency-1 {
|
||||
endIdx = eventCount
|
||||
}
|
||||
go func(start, end int) {
|
||||
defer wg.Done()
|
||||
for i := start; i < end; i++ {
|
||||
if err := client.Enqueue(events[i]); err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}(startIdx, endIdx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
throughput := float64(eventCount) / elapsed.Seconds()
|
||||
avgBytesPerEvent := float64(totalBytes.Load()) / float64(received.Load())
|
||||
|
||||
t.Logf("Cardinality: %s, Throughput: %.2f events/sec, Avg bytes/event: %.0f",
|
||||
tc.name, throughput, avgBytesPerEvent)
|
||||
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"No enqueue errors should occur")
|
||||
require.Equal(t, int64(eventCount), received.Load(),
|
||||
"All events should be delivered")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStress_HighConcurrencyLowVolume tests many goroutines with few events each
|
||||
func TestStress_HighConcurrencyLowVolume(t *testing.T) {
|
||||
goroutines := 1000
|
||||
eventsPerGoroutine := 5
|
||||
totalEvents := goroutines * eventsPerGoroutine
|
||||
|
||||
pool := NewEventPool(totalEvents)
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errorCount atomic.Int64
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < eventsPerGoroutine; i++ {
|
||||
idx := goroutineID*eventsPerGoroutine + i
|
||||
if err := client.Enqueue(pool.Get(idx)); err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
throughput := float64(totalEvents) / elapsed.Seconds()
|
||||
|
||||
t.Logf("High concurrency: %d goroutines, %d events, %.2f events/sec, %v total",
|
||||
goroutines, totalEvents, throughput, elapsed)
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"No enqueue errors should occur")
|
||||
|
||||
require.Equal(t, int64(totalEvents), received.Load(),
|
||||
"All events should be delivered")
|
||||
}
|
||||
|
||||
// TestStress_LowConcurrencyHighVolume tests few goroutines with many events each
|
||||
func TestStress_LowConcurrencyHighVolume(t *testing.T) {
|
||||
goroutines := 4
|
||||
eventsPerGoroutine := 125
|
||||
totalEvents := goroutines * eventsPerGoroutine
|
||||
|
||||
pool := NewEventPool(totalEvents)
|
||||
|
||||
var received atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errorCount atomic.Int64
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < eventsPerGoroutine; i++ {
|
||||
idx := goroutineID*eventsPerGoroutine + i
|
||||
if err := client.Enqueue(pool.Get(idx)); err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
throughput := float64(totalEvents) / elapsed.Seconds()
|
||||
|
||||
t.Logf("Low concurrency: %d goroutines, %d events, %.2f events/sec, %v total",
|
||||
goroutines, totalEvents, throughput, elapsed)
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"No enqueue errors should occur")
|
||||
|
||||
require.Equal(t, int64(totalEvents), received.Load(),
|
||||
"All events should be delivered")
|
||||
}
|
||||
|
||||
// TestStress_MixedCardinality tests a mix of cardinalities in a single test
|
||||
func TestStress_MixedCardinality(t *testing.T) {
|
||||
eventCount := 300
|
||||
concurrency := 30
|
||||
|
||||
// Generate events using the realistic distribution
|
||||
pool := NewEventPoolWithCardinalityDistribution(eventCount)
|
||||
|
||||
var received atomic.Int64
|
||||
var totalBytes atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
totalBytes.Add(int64(len(body)))
|
||||
var b batch
|
||||
json.Unmarshal(body, &b)
|
||||
received.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
// Uses production defaults for BatchSize, MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errorCount atomic.Int64
|
||||
eventsPerGoroutine := eventCount / concurrency
|
||||
for g := 0; g < concurrency; g++ {
|
||||
wg.Add(1)
|
||||
startIdx := g * eventsPerGoroutine
|
||||
endIdx := (g + 1) * eventsPerGoroutine
|
||||
if g == concurrency-1 {
|
||||
endIdx = eventCount
|
||||
}
|
||||
go func(start, end int) {
|
||||
defer wg.Done()
|
||||
for i := start; i < end; i++ {
|
||||
if err := client.Enqueue(pool.Get(i)); err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}(startIdx, endIdx)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
client.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
throughput := float64(eventCount) / elapsed.Seconds()
|
||||
avgBytesPerEvent := float64(0)
|
||||
if received.Load() > 0 {
|
||||
avgBytesPerEvent = float64(totalBytes.Load()) / float64(received.Load())
|
||||
}
|
||||
|
||||
t.Logf("Mixed cardinality: %d events, %.2f events/sec, %.0f avg bytes/event",
|
||||
eventCount, throughput, avgBytesPerEvent)
|
||||
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"No enqueue errors should occur")
|
||||
require.Equal(t, int64(eventCount), received.Load(),
|
||||
"All events should be delivered")
|
||||
}
|
||||
|
||||
// TestStress_RapidCloseReopen tests rapid client close and reopen
|
||||
func TestStress_RapidCloseReopen(t *testing.T) {
|
||||
t.Parallel()
|
||||
iterations := 10
|
||||
eventsPerIteration := 50
|
||||
|
||||
pool := NewEventPool(eventsPerIteration * iterations)
|
||||
|
||||
var totalReceived atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var b batch
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
totalReceived.Add(int64(len(b.Messages)))
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var totalErrors int
|
||||
for i := 0; i < iterations; i++ {
|
||||
client, err := NewWithConfig("test-key", Config{
|
||||
Endpoint: server.URL,
|
||||
BatchSize: 25,
|
||||
// Uses production defaults for MaxEnqueuedRequests
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for j := 0; j < eventsPerIteration; j++ {
|
||||
idx := i*eventsPerIteration + j
|
||||
if err := client.Enqueue(pool.Get(idx)); err != nil {
|
||||
totalErrors++
|
||||
}
|
||||
}
|
||||
|
||||
client.Close()
|
||||
}
|
||||
|
||||
require.Equal(t, 0, totalErrors,
|
||||
"No enqueue errors should occur")
|
||||
expectedTotal := int64(iterations * eventsPerIteration)
|
||||
require.Equal(t, expectedTotal, totalReceived.Load(),
|
||||
"All events across all iterations should be delivered")
|
||||
}
|
||||
|
||||
// TestStress_PrepareForSendUnderLoad verifies prepareForSend works correctly under concurrent load
|
||||
func TestStress_PrepareForSendUnderLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
cardinalities := []PropertyCardinality{CardinalityLow, CardinalityMedium, CardinalityHigh}
|
||||
|
||||
for _, cardinality := range cardinalities {
|
||||
t.Run(CardinalityName(cardinality), func(t *testing.T) {
|
||||
pool := NewEventPoolWithCardinality(100, cardinality)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorCount := atomic.Int64{}
|
||||
|
||||
// Concurrent prepareForSend
|
||||
for g := 0; g < 10; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
capture := pool.Get(i)
|
||||
capture.Type = "capture"
|
||||
data, apiMsg, err := prepareForSend(capture)
|
||||
if err != nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
if len(data) <= 0 {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
if apiMsg == nil {
|
||||
errorCount.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
require.Equal(t, int64(0), errorCount.Load(),
|
||||
"prepareForSend should never return error, non-positive size, or nil message")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PropertyCardinality defines the number of properties to generate
|
||||
type PropertyCardinality int
|
||||
|
||||
const (
|
||||
CardinalityLow PropertyCardinality = iota // 10-100 properties
|
||||
CardinalityMedium // 500-2000 properties
|
||||
CardinalityHigh // 2000-10000 properties
|
||||
)
|
||||
|
||||
// Pool sizes per cardinality - balance setup time vs. cycling frequency
|
||||
const (
|
||||
LowCardinalityPoolSize = 50000 // Fast to create, ~5s
|
||||
MediumCardinalityPoolSize = 10000 // Moderate setup time
|
||||
HighCardinalityPoolSize = 2000 // Expensive, keep small
|
||||
NumTemplates = 50 // Number of unique property templates per pool
|
||||
)
|
||||
|
||||
// PropertyCardinalityDistribution defines realistic distribution of property counts
|
||||
// Based on typical PostHog usage patterns
|
||||
var PropertyCardinalityDistribution = []struct {
|
||||
Cardinality PropertyCardinality
|
||||
Weight int // percentage of events with this cardinality
|
||||
}{
|
||||
{CardinalityLow, 60}, // 60% of events have 10-100 props
|
||||
{CardinalityMedium, 30}, // 30% have 500-2000 props
|
||||
{CardinalityHigh, 10}, // 10% have 2000-10000 props (power users)
|
||||
}
|
||||
|
||||
// EventPool holds pre-generated events to avoid generation time during benchmarks
|
||||
type EventPool struct {
|
||||
events []Capture
|
||||
index atomic.Int64
|
||||
}
|
||||
|
||||
// NewEventPool pre-generates n varied events for benchmark use
|
||||
// Events are varied enough to avoid caching effects
|
||||
func NewEventPool(n int) *EventPool {
|
||||
pool := &EventPool{events: make([]Capture, n)}
|
||||
for i := 0; i < n; i++ {
|
||||
pool.events[i] = generateVariedCapture(i)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// NewEventPoolWithCardinalityDistribution creates events with realistic cardinality distribution
|
||||
// Uses template-based generation for efficiency
|
||||
func NewEventPoolWithCardinalityDistribution(n int) *EventPool {
|
||||
// Create templates for each cardinality level
|
||||
templatesPerCardinality := make(map[PropertyCardinality][]templateData)
|
||||
for _, cardinality := range []PropertyCardinality{CardinalityLow, CardinalityMedium, CardinalityHigh} {
|
||||
templates := make([]templateData, NumTemplates)
|
||||
for i := 0; i < NumTemplates; i++ {
|
||||
templates[i] = templateData{
|
||||
properties: generatePropertiesWithCardinality(i, cardinality),
|
||||
groups: generateGroupsWithCardinality(i, cardinality),
|
||||
}
|
||||
}
|
||||
templatesPerCardinality[cardinality] = templates
|
||||
}
|
||||
|
||||
// Clone templates to fill pool - each slot gets unique maps
|
||||
pool := &EventPool{events: make([]Capture, n)}
|
||||
for i := 0; i < n; i++ {
|
||||
cardinality := selectCardinality(i)
|
||||
templates := templatesPerCardinality[cardinality]
|
||||
tmpl := templates[i%len(templates)]
|
||||
pool.events[i] = Capture{
|
||||
DistinctId: generateDistinctId(i),
|
||||
Event: realisticEvents[i%len(realisticEvents)],
|
||||
Timestamp: time.Now().Add(time.Duration(-i) * time.Second),
|
||||
Properties: cloneProperties(tmpl.properties),
|
||||
Groups: cloneGroups(tmpl.groups),
|
||||
}
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// NewEventPoolWithCardinality creates all events with a specific cardinality
|
||||
// Uses template-based generation: creates NumTemplates unique property sets,
|
||||
// then clones them to fill the pool. Each slot gets unique maps to prevent races.
|
||||
func NewEventPoolWithCardinality(n int, cardinality PropertyCardinality) *EventPool {
|
||||
// Create templates with varied properties
|
||||
templates := make([]templateData, NumTemplates)
|
||||
for i := 0; i < NumTemplates; i++ {
|
||||
templates[i] = templateData{
|
||||
properties: generatePropertiesWithCardinality(i, cardinality),
|
||||
groups: generateGroupsWithCardinality(i, cardinality),
|
||||
}
|
||||
}
|
||||
|
||||
// Clone templates to fill pool - each slot gets unique maps
|
||||
pool := &EventPool{events: make([]Capture, n)}
|
||||
for i := 0; i < n; i++ {
|
||||
tmpl := templates[i%NumTemplates]
|
||||
pool.events[i] = Capture{
|
||||
DistinctId: generateDistinctId(i),
|
||||
Event: realisticEvents[i%len(realisticEvents)],
|
||||
Timestamp: time.Now().Add(time.Duration(-i) * time.Second),
|
||||
Properties: cloneProperties(tmpl.properties),
|
||||
Groups: cloneGroups(tmpl.groups),
|
||||
}
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// NewEventPoolWithDefaultSize creates a pool with cardinality-appropriate default size
|
||||
// Use this when you don't need a specific pool size
|
||||
func NewEventPoolWithDefaultSize(cardinality PropertyCardinality) *EventPool {
|
||||
size := defaultPoolSize(cardinality)
|
||||
return NewEventPoolWithCardinality(size, cardinality)
|
||||
}
|
||||
|
||||
// defaultPoolSize returns the recommended pool size for a cardinality level
|
||||
func defaultPoolSize(cardinality PropertyCardinality) int {
|
||||
switch cardinality {
|
||||
case CardinalityLow:
|
||||
return LowCardinalityPoolSize
|
||||
case CardinalityMedium:
|
||||
return MediumCardinalityPoolSize
|
||||
case CardinalityHigh:
|
||||
return HighCardinalityPoolSize
|
||||
default:
|
||||
return LowCardinalityPoolSize
|
||||
}
|
||||
}
|
||||
|
||||
// templateData holds pre-generated properties/groups for cloning
|
||||
type templateData struct {
|
||||
properties Properties
|
||||
groups Groups
|
||||
}
|
||||
|
||||
// cloneProperties creates a shallow copy of a Properties map
|
||||
func cloneProperties(src Properties) Properties {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := make(Properties, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// cloneGroups creates a shallow copy of a Groups map
|
||||
func cloneGroups(src Groups) Groups {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := make(Groups, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// Next returns the next event (cycling through pool) - thread-safe
|
||||
// Returns a copy with cloned Properties/Groups to prevent races when the SDK
|
||||
// modifies the maps (via Merge, prepareForSend) in the Enqueue caller thread
|
||||
func (p *EventPool) Next() Capture {
|
||||
idx := p.index.Add(1) - 1
|
||||
original := p.events[idx%int64(len(p.events))]
|
||||
return Capture{
|
||||
DistinctId: original.DistinctId,
|
||||
Event: original.Event,
|
||||
Timestamp: original.Timestamp,
|
||||
Properties: cloneProperties(original.Properties),
|
||||
Groups: cloneGroups(original.Groups),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the event at index i (cycling through pool)
|
||||
func (p *EventPool) Get(i int) Capture {
|
||||
return p.events[i%len(p.events)]
|
||||
}
|
||||
|
||||
// Len returns the number of events in the pool
|
||||
func (p *EventPool) Len() int {
|
||||
return len(p.events)
|
||||
}
|
||||
|
||||
// Events returns the underlying slice of events
|
||||
func (p *EventPool) Events() []Capture {
|
||||
return p.events
|
||||
}
|
||||
|
||||
// selectCardinality returns cardinality based on weighted distribution
|
||||
func selectCardinality(seed int) PropertyCardinality {
|
||||
r := seed % 100
|
||||
cumulative := 0
|
||||
for _, dist := range PropertyCardinalityDistribution {
|
||||
cumulative += dist.Weight
|
||||
if r < cumulative {
|
||||
return dist.Cardinality
|
||||
}
|
||||
}
|
||||
return CardinalityLow
|
||||
}
|
||||
|
||||
// generateVariedCapture creates a unique, realistic event
|
||||
func generateVariedCapture(seed int) Capture {
|
||||
return Capture{
|
||||
DistinctId: generateDistinctId(seed),
|
||||
Event: realisticEvents[seed%len(realisticEvents)],
|
||||
Timestamp: time.Now().Add(time.Duration(-seed) * time.Second),
|
||||
Properties: generateVariedProperties(seed),
|
||||
}
|
||||
}
|
||||
|
||||
// generateDistinctId creates varied distinct IDs to avoid caching
|
||||
func generateDistinctId(seed int) string {
|
||||
formats := []func(int) string{
|
||||
func(s int) string { return fmt.Sprintf("user_%[email protected]", s) },
|
||||
func(s int) string { return fmt.Sprintf("auth0|%012d", s) },
|
||||
func(s int) string { return generateUUID(s) },
|
||||
func(s int) string { return fmt.Sprintf("anon_%s", randomHex(8)) },
|
||||
}
|
||||
return formats[seed%len(formats)](seed)
|
||||
}
|
||||
|
||||
// generateUUID creates a deterministic UUID-like string based on seed
|
||||
func generateUUID(seed int) string {
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
seed,
|
||||
(seed>>8)&0xffff,
|
||||
(seed>>16)&0xffff,
|
||||
(seed>>24)&0xffff,
|
||||
seed*1000000)
|
||||
}
|
||||
|
||||
// randomHex generates a random hex string of specified length
|
||||
func randomHex(length int) string {
|
||||
bytes := make([]byte, length/2+1)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)[:length]
|
||||
}
|
||||
|
||||
// Realistic event names matching PostHog patterns
|
||||
var realisticEvents = []string{
|
||||
"$pageview", "$autocapture", "$identify", "$pageleave",
|
||||
"user_signed_up", "purchase_completed", "item_added_to_cart",
|
||||
"feature_flag_called", "$exception", "form_submitted",
|
||||
"button_clicked", "search_performed", "video_played",
|
||||
}
|
||||
|
||||
// generateVariedProperties creates realistic property payloads with moderate cardinality
|
||||
func generateVariedProperties(seed int) Properties {
|
||||
props := Properties{
|
||||
"$lib": "posthog-go",
|
||||
"$lib_version": "1.0.0",
|
||||
"session_id": fmt.Sprintf("sess_%d", seed/100),
|
||||
"page_url": fmt.Sprintf("https://app.example.com/page/%d", seed%50),
|
||||
}
|
||||
// Add varied properties based on seed
|
||||
if seed%3 == 0 {
|
||||
props["user_agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
|
||||
props["screen_width"] = 1920
|
||||
props["screen_height"] = 1080
|
||||
}
|
||||
if seed%5 == 0 {
|
||||
props["utm_source"] = "google"
|
||||
props["utm_campaign"] = fmt.Sprintf("campaign_%d", seed%10)
|
||||
}
|
||||
if seed%7 == 0 {
|
||||
props["$referrer"] = "https://google.com/search?q=example"
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// generatePropertiesWithCardinality creates properties with specified cardinality
|
||||
func generatePropertiesWithCardinality(seed int, cardinality PropertyCardinality) Properties {
|
||||
var propCount int
|
||||
switch cardinality {
|
||||
case CardinalityLow:
|
||||
propCount = 10 + (seed % 91) // 10-100
|
||||
case CardinalityMedium:
|
||||
propCount = 500 + (seed % 1501) // 500-2000
|
||||
case CardinalityHigh:
|
||||
propCount = 2000 + (seed % 8001) // 2000-10000
|
||||
}
|
||||
|
||||
props := Properties{
|
||||
"$lib": "posthog-go",
|
||||
"$lib_version": "1.0.0",
|
||||
}
|
||||
|
||||
// Realistic string patterns to simulate real payloads
|
||||
stringPatterns := []func(seed, i int) string{
|
||||
func(s, i int) string { return fmt.Sprintf("string_value_%d_%d", s, i) }, // short
|
||||
func(s, i int) string { return fmt.Sprintf("https://app.example.com/dashboard/project/%d/page/%d", s%100, i%50) }, // URL
|
||||
func(s, i int) string { return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" }, // user agent
|
||||
func(s, i int) string { return fmt.Sprintf("user_%d@company%d.example.com", s, i%10) }, // email
|
||||
func(s, i int) string { return strings.Repeat("x", 50+i%200) }, // varied length
|
||||
}
|
||||
|
||||
for i := 0; i < propCount; i++ {
|
||||
key := fmt.Sprintf("prop_%d", i)
|
||||
// Vary value types based on index - 8 types for more realism
|
||||
switch i % 8 {
|
||||
case 0, 1, 2: // 3/8 strings with varied patterns
|
||||
props[key] = stringPatterns[i%len(stringPatterns)](seed, i)
|
||||
case 3:
|
||||
props[key] = seed*1000 + i // integer
|
||||
case 4:
|
||||
props[key] = float64(seed) + float64(i)/100.0 // float
|
||||
case 5:
|
||||
props[key] = i%2 == 0 // bool
|
||||
case 6:
|
||||
props[key] = []string{fmt.Sprintf("item_%d", i), fmt.Sprintf("item_%d", i+1)} // array
|
||||
case 7:
|
||||
props[key] = map[string]interface{}{"nested_key": i, "nested_val": seed} // nested map
|
||||
}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// generateGroupsWithCardinality creates groups with specified cardinality
|
||||
// Group cardinality is scaled proportionally but capped at realistic limits
|
||||
func generateGroupsWithCardinality(seed int, cardinality PropertyCardinality) Groups {
|
||||
var groupCount int
|
||||
switch cardinality {
|
||||
case CardinalityLow:
|
||||
groupCount = 1 + (seed % 3) // 1-3
|
||||
case CardinalityMedium:
|
||||
groupCount = 3 + (seed % 5) // 3-7
|
||||
case CardinalityHigh:
|
||||
groupCount = 5 + (seed % 6) // 5-10
|
||||
}
|
||||
|
||||
groups := Groups{}
|
||||
groupTypes := []string{"company", "project", "team", "workspace", "organization", "department", "region", "account", "tenant", "division"}
|
||||
for i := 0; i < groupCount && i < len(groupTypes); i++ {
|
||||
groups[groupTypes[i]] = fmt.Sprintf("%s_%d", groupTypes[i], seed+i)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GenerateCapturesBatch generates a batch of captures for stress tests
|
||||
func GenerateCapturesBatch(count int) []Capture {
|
||||
pool := NewEventPool(count)
|
||||
return pool.events
|
||||
}
|
||||
|
||||
// GenerateCapturesBatchWithCardinality generates a batch of captures with specific cardinality
|
||||
func GenerateCapturesBatchWithCardinality(count int, cardinality PropertyCardinality) []Capture {
|
||||
pool := NewEventPoolWithCardinality(count, cardinality)
|
||||
return pool.events
|
||||
}
|
||||
|
||||
// Property edge case generators for testing edge cases
|
||||
var edgeCaseProperties = map[string]Properties{
|
||||
"unicode": {"名前": "テスト", "emoji": "🚀", "arabic": "مرحبا"},
|
||||
"nested": {"user": map[string]interface{}{"id": 1, "meta": map[string]interface{}{"created": "2024-01-01"}}},
|
||||
"large_string": {"data": strings.Repeat("x", 10000)},
|
||||
"special_chars": {"path": "/api/v1?foo=bar&baz=qux", "json_in_string": `{"nested": "json"}`},
|
||||
"numbers": {"int": 42, "float": 3.14159, "negative": -100, "zero": 0},
|
||||
}
|
||||
|
||||
// GetEdgeCaseProperties returns edge case properties for testing
|
||||
func GetEdgeCaseProperties(name string) Properties {
|
||||
if props, ok := edgeCaseProperties[name]; ok {
|
||||
return props
|
||||
}
|
||||
return Properties{}
|
||||
}
|
||||
|
||||
// CardinalityName returns a human-readable name for a cardinality level
|
||||
func CardinalityName(c PropertyCardinality) string {
|
||||
switch c {
|
||||
case CardinalityLow:
|
||||
return "low_10-100_props"
|
||||
case CardinalityMedium:
|
||||
return "medium_500-2000_props"
|
||||
case CardinalityHigh:
|
||||
return "high_2000-10000_props"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package posthog
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScenario defines common test scenarios for transport mocking
|
||||
type TestScenario int
|
||||
|
||||
const (
|
||||
ScenarioOK TestScenario = iota
|
||||
ScenarioDelayed
|
||||
ScenarioBadRequest
|
||||
ScenarioBodyError
|
||||
ScenarioNetworkError
|
||||
ScenarioServerError
|
||||
)
|
||||
|
||||
// UnifiedCallback is a comprehensive callback mock with channels for synchronization
|
||||
type UnifiedCallback struct {
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
successCount int
|
||||
failureCount int
|
||||
lastError error
|
||||
SuccessChan chan APIMessage
|
||||
FailureChan chan error
|
||||
}
|
||||
|
||||
// NewUnifiedCallback creates a new unified callback for testing
|
||||
func NewUnifiedCallback(t *testing.T) *UnifiedCallback {
|
||||
return &UnifiedCallback{
|
||||
t: t,
|
||||
SuccessChan: make(chan APIMessage, 100),
|
||||
FailureChan: make(chan error, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UnifiedCallback) Success(msg APIMessage) {
|
||||
c.mu.Lock()
|
||||
c.successCount++
|
||||
c.mu.Unlock()
|
||||
if c.t != nil {
|
||||
c.t.Logf("Callback: SUCCESS")
|
||||
}
|
||||
select {
|
||||
case c.SuccessChan <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UnifiedCallback) Failure(msg APIMessage, err error) {
|
||||
c.mu.Lock()
|
||||
c.failureCount++
|
||||
c.lastError = err
|
||||
c.mu.Unlock()
|
||||
if c.t != nil {
|
||||
c.t.Logf("Callback: FAILURE - %v", err)
|
||||
}
|
||||
select {
|
||||
case c.FailureChan <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UnifiedCallback) GetCounts() (success, failure int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.successCount, c.failureCount
|
||||
}
|
||||
|
||||
func (c *UnifiedCallback) GetLastError() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lastError
|
||||
}
|
||||
|
||||
// MockServerConfig configures a mock HTTP server for testing
|
||||
type MockServerConfig struct {
|
||||
// Response configurations
|
||||
BatchResponse string
|
||||
BatchStatusCode int
|
||||
FlagsResponse string
|
||||
FlagsStatusCode int
|
||||
LocalEvalResponse string
|
||||
LocalEvalStatus int
|
||||
|
||||
// Behavior modifiers
|
||||
LatencyMs int
|
||||
FailAfterN int
|
||||
|
||||
// Handlers for custom behavior
|
||||
BatchHandler func(body []byte)
|
||||
FlagsHandler func(w http.ResponseWriter, r *http.Request)
|
||||
LocalEvalHandler func(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// MockServerBuilder builds configurable mock servers
|
||||
type MockServerBuilder struct {
|
||||
config MockServerConfig
|
||||
requestCount int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewMockServerBuilder creates a new mock server builder with defaults
|
||||
func NewMockServerBuilder() *MockServerBuilder {
|
||||
return &MockServerBuilder{
|
||||
config: MockServerConfig{
|
||||
BatchStatusCode: http.StatusOK,
|
||||
FlagsStatusCode: http.StatusOK,
|
||||
LocalEvalStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) WithBatchResponse(response string, statusCode int) *MockServerBuilder {
|
||||
b.config.BatchResponse = response
|
||||
b.config.BatchStatusCode = statusCode
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) WithFlagsResponse(response string, statusCode int) *MockServerBuilder {
|
||||
b.config.FlagsResponse = response
|
||||
b.config.FlagsStatusCode = statusCode
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) WithLocalEvalResponse(response string, statusCode int) *MockServerBuilder {
|
||||
b.config.LocalEvalResponse = response
|
||||
b.config.LocalEvalStatus = statusCode
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) WithBatchHandler(handler func(body []byte)) *MockServerBuilder {
|
||||
b.config.BatchHandler = handler
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) WithFailAfterN(n int) *MockServerBuilder {
|
||||
b.config.FailAfterN = n
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) GetRequestCount() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.requestCount
|
||||
}
|
||||
|
||||
func (b *MockServerBuilder) Build() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b.mu.Lock()
|
||||
b.requestCount++
|
||||
count := b.requestCount
|
||||
b.mu.Unlock()
|
||||
|
||||
// Check if we should fail
|
||||
if b.config.FailAfterN > 0 && count > b.config.FailAfterN {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Route to appropriate handler
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/batch"):
|
||||
if b.config.BatchHandler != nil {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
b.config.BatchHandler(body)
|
||||
}
|
||||
w.WriteHeader(b.config.BatchStatusCode)
|
||||
if b.config.BatchResponse != "" {
|
||||
w.Write([]byte(b.config.BatchResponse))
|
||||
}
|
||||
|
||||
case strings.HasPrefix(r.URL.Path, "/flags"):
|
||||
if b.config.FlagsHandler != nil {
|
||||
b.config.FlagsHandler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(b.config.FlagsStatusCode)
|
||||
if b.config.FlagsResponse != "" {
|
||||
w.Write([]byte(b.config.FlagsResponse))
|
||||
}
|
||||
|
||||
case strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"):
|
||||
if b.config.LocalEvalHandler != nil {
|
||||
b.config.LocalEvalHandler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(b.config.LocalEvalStatus)
|
||||
if b.config.LocalEvalResponse != "" {
|
||||
w.Write([]byte(b.config.LocalEvalResponse))
|
||||
}
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// NewTestTransport creates a transport for the given test scenario
|
||||
func NewTestTransport(scenario TestScenario) http.RoundTripper {
|
||||
switch scenario {
|
||||
case ScenarioOK:
|
||||
return testTransportOK
|
||||
case ScenarioDelayed:
|
||||
return testTransportDelayed
|
||||
case ScenarioBadRequest:
|
||||
return testTransportBadRequest
|
||||
case ScenarioBodyError:
|
||||
return testTransportBodyError
|
||||
case ScenarioNetworkError:
|
||||
return testTransportError
|
||||
case ScenarioServerError:
|
||||
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusInternalServerError),
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Proto: r.Proto,
|
||||
ProtoMajor: r.ProtoMajor,
|
||||
ProtoMinor: r.ProtoMinor,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
default:
|
||||
return testTransportOK
|
||||
}
|
||||
}
|
||||
|
||||
// NoOpTransport returns a transport that succeeds immediately without any network calls
|
||||
// Useful for benchmarking enqueue throughput without HTTP overhead
|
||||
func NoOpTransport() http.RoundTripper {
|
||||
return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
return &http.Response{
|
||||
Status: http.StatusText(http.StatusOK),
|
||||
StatusCode: http.StatusOK,
|
||||
Proto: r.Proto,
|
||||
ProtoMajor: r.ProtoMajor,
|
||||
ProtoMinor: r.ProtoMinor,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user