diff --git a/events/client.go b/events/client.go new file mode 100644 index 0000000000000000000000000000000000000000..ca19051960860a22f68dc9bd3155b41ba0e1a6e1 --- /dev/null +++ b/events/client.go @@ -0,0 +1,255 @@ +package events + +import ( + "errors" + "net/url" + "sync" + + storagememory "github.com/snowplow/snowplow-golang-tracker/v3/pkg/storage/memory" + sp "github.com/snowplow/snowplow-golang-tracker/v3/tracker" +) + +var ( + // ErrHostMissingScheme is returned when the host URL doesn't include a scheme (http/https). + ErrHostMissingScheme = errors.New("host must include a scheme (http:// or https://)") +) + +const ( + // SchemaCustomEvent is the Iglu schema for custom events. + SchemaCustomEvent = "iglu:com.gitlab/custom_event/jsonschema/1-0-0" + + // SchemaUserContext is the Iglu schema for user context. + SchemaUserContext = "iglu:com.gitlab/user_context/jsonschema/1-0-0" + + // trackerNamespace is the default namespace for the tracker. + trackerNamespace = "gitlab" + + // UserAgent is the user agent string sent with all events. + UserAgent = "GitLab LabKit" + + // BufferSize is the number of events sent in one request. + BufferSize = 5 + + // FlushAttempts is the number of retry attempts when blocking flush. + FlushAttempts = 5 + + // FlushSleepMs is the sleep time in milliseconds between flush attempts. + FlushSleepMs = 10 +) + +// Client is the main GitLab SDK client for tracking events. +// Client is safe for concurrent use by multiple goroutines. +type Client struct { + tracker *sp.Tracker + emitter *sp.Emitter + mu sync.Mutex + emitterActive bool // tracks if emitter has been started +} + +// UserTracker is a user-scoped tracker that automatically includes user context. +// This is safe for concurrent use and provides a cleaner API for tracking user events. +type UserTracker struct { + client *Client + userID string + userAttributes map[string]any +} + +// TrackOption is a functional option for configuring individual track calls. +type TrackOption func(*trackConfig) + +// trackConfig holds configuration for a single track call. +type trackConfig struct { + userID string + userAttributes map[string]any +} + +// NewClient creates a new GitLab SDK client +// +// Required parameters: +// - appID: The ID specified in the GitLab Project Analytics setup guide +// - host: The GitLab Project Analytics instance URL (must include scheme, e.g., http://localhost:9091) +func NewClient(appID, host string) (*Client, error) { + uri, err := url.Parse(host) + if err != nil { + return nil, err + } + if uri.Scheme == "" { + return nil, ErrHostMissingScheme + } + + endpoint := uri.Host + if uri.Path != "" && uri.Path != "/" { + endpoint += uri.Path + } + + storage := storagememory.Init() + emitter := sp.InitEmitter( + sp.RequireCollectorUri(endpoint), + sp.RequireStorage(*storage), + sp.OptionRequestType("POST"), + sp.OptionProtocol(uri.Scheme), + sp.OptionSendLimit(BufferSize), + ) + + client := &Client{ + emitter: emitter, + } + + tracker := sp.InitTracker( + sp.RequireEmitter(emitter), + sp.OptionAppId(appID), + sp.OptionNamespace(trackerNamespace), + ) + client.tracker = tracker + + return client, nil +} + +// WithUser sets user context for a specific event. +// This allows per-request user identification, making the SDK safe for +// concurrent use in multi-user applications (e.g., web servers). +// +// Parameters: +// - userID: The ID of the user +// - userAttributes: Optional user attributes to include with the event +// +// Example: +// +// client.Track("button_click", payload, events.WithUser("user123", map[string]any{ +// "user_name": "John Doe", +// })) +func WithUser(userID string, userAttributes map[string]any) TrackOption { + return func(cfg *trackConfig) { + cfg.userID = userID + if userAttributes != nil { + cfg.userAttributes = userAttributes + } + } +} + +// Track sends a custom event to GitLab Analytics. +// +// Parameters: +// - eventName: The name of the event +// - eventPayload: A map of event attributes to include with the event +// - opts: Optional TrackOption functions for per-event configuration (e.g., WithUser) +// +// Example without user: +// +// client.Track("page_view", map[string]any{"page": "/home"}) +// +// Example with user context: +// +// client.Track("button_click", map[string]any{"id": "submit"}, +// events.WithUser("user123", map[string]any{"name": "John"})) +func (c *Client) Track(eventName string, eventPayload map[string]any, opts ...TrackOption) { + c.mu.Lock() + defer c.mu.Unlock() + + cfg := &trackConfig{} + for _, opt := range opts { + opt(cfg) + } + + eventData := map[string]any{ + "name": eventName, + "props": eventPayload, + } + eventJSON := sp.InitSelfDescribingJson(SchemaCustomEvent, eventData) + + event := sp.SelfDescribingEvent{ + Event: eventJSON, + } + + subject := sp.InitSubject() + subject.SetUseragent(UserAgent) + if cfg.userID != "" { + subject.SetUserId(cfg.userID) + } + c.tracker.SetSubject(subject) + + if len(cfg.userAttributes) > 0 { + userContext := sp.InitSelfDescribingJson(SchemaUserContext, cfg.userAttributes) + event.Contexts = []sp.SelfDescribingJson{*userContext} + } + + c.tracker.TrackSelfDescribingEvent(event) + // Mark emitter as active after first event + c.emitterActive = true +} + +// ForUser creates a UserTracker scoped to a specific user. +// This provides a cleaner API for tracking events for a single user without passing context repeatedly. +// +// Parameters: +// - userID: The ID of the user +// - userAttributes: Optional user attributes to include with all events +// +// Example: +// +// userTracker := client.ForUser("user123", map[string]any{ +// "name": "John Doe", +// "email": "john@example.com", +// }) +// userTracker.Track("login", map[string]any{}) +// userTracker.Track("page_view", map[string]any{"page": "/dashboard"}) +func (c *Client) ForUser(userID string, userAttributes map[string]any) *UserTracker { + return &UserTracker{ + client: c, + userID: userID, + userAttributes: userAttributes, + } +} + +// Track sends a custom event with the user context automatically included. +// +// Parameters: +// - eventName: The name of the event +// - eventPayload: A map of event attributes to include with the event +// +// Example: +// +// userTracker := client.ForUser("user123", map[string]any{"name": "John"}) +// userTracker.Track("button_click", map[string]any{"button": "submit"}) +func (ut *UserTracker) Track(eventName string, eventPayload map[string]any) { + ut.client.Track(eventName, eventPayload, WithUser(ut.userID, ut.userAttributes)) +} + +// Close gracefully shuts down the client by stopping the emitter and flushing remaining events. +// This should be called when the client is no longer needed to ensure all events are sent +// and background goroutines are stopped. +// Close is safe to call multiple times. +// +// Example: +// +// client, err := events.NewClient("app-id", "https://collector.com") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + // Only flush and stop if emitter was activated (i.e., at least one event was tracked) + // The Snowplow emitter.Stop() blocks forever on a nil channel if never started + if c.emitterActive { + c.tracker.BlockingFlush(FlushAttempts, FlushSleepMs) + c.emitter.Stop() + c.emitterActive = false + } +} + +// FlushEvents manually flushes all events from the emitter. +// +// Parameters: +// - async: If true, flush events asynchronously; if false, flush synchronously (default: false) +func (c *Client) FlushEvents(async bool) { + c.mu.Lock() + defer c.mu.Unlock() + + c.emitter.Flush() + if !async { + c.tracker.BlockingFlush(FlushAttempts, FlushSleepMs) + } +} diff --git a/events/client_test.go b/events/client_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a134a114d707cfeceef789c49ed6647662059603 --- /dev/null +++ b/events/client_test.go @@ -0,0 +1,522 @@ +package events + +import ( + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" +) + +func TestMain(m *testing.M) { + // Disable Snowplow's HTTP error logging during tests to avoid noise + log.SetOutput(io.Discard) + os.Exit(m.Run()) +} + +// createTestClient creates a client with a mock HTTP server. +// Caller is responsible for calling client.Close() and server.Close(). +func createTestClient(t *testing.T) (*Client, *httptest.Server) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + client, err := NewClient("test-app", server.URL) + if err != nil { + server.Close() + t.Fatalf("Failed to create client: %v", err) + } + + return client, server +} + +// TestNewClient validates URL parsing and endpoint construction +func TestNewClient(t *testing.T) { + tests := []struct { + name string + appID string + host string + wantErr bool + expectedEndpoint string + }{ + { + name: "valid https host", + appID: "test-app-id", + host: "https://snowplowcollector.com", + wantErr: false, + expectedEndpoint: "snowplowcollector.com", + }, + { + name: "valid http host with port", + appID: "test-app-id", + host: "http://localhost:9091", + wantErr: false, + expectedEndpoint: "localhost:9091", + }, + { + name: "host without scheme", + appID: "test-app-id", + host: "snowplowcollector.com", + wantErr: true, + }, + { + name: "host with root path", + appID: "test-app-id", + host: "https://collector.com/", + wantErr: false, + expectedEndpoint: "collector.com", + }, + { + name: "host with path", + appID: "test-app-id", + host: "https://collector.com/api/v1", + wantErr: false, + expectedEndpoint: "collector.com/api/v1", + }, + { + name: "host with path and trailing slash", + appID: "test-app-id", + host: "https://collector.com/api/", + wantErr: false, + expectedEndpoint: "collector.com/api/", + }, + { + name: "host with port and path", + appID: "test-app-id", + host: "http://localhost:8080/collector", + wantErr: false, + expectedEndpoint: "localhost:8080/collector", + }, + { + name: "invalid URL", + appID: "test-app-id", + host: "://invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(tt.appID, tt.host) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if client == nil { + t.Error("NewClient() returned nil client") + return + } + defer client.Close() + + // Validate endpoint construction + if tt.expectedEndpoint != "" { + if client.emitter.CollectorUri != tt.expectedEndpoint { + t.Errorf("NewClient() endpoint = %v, want %v", client.emitter.CollectorUri, tt.expectedEndpoint) + } + } + } + }) + } +} + +// TestClient_Track validates basic event tracking +func TestClient_Track(t *testing.T) { + t.Run("without user context", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("test_event", map[string]any{ + "id": 123, + "value": "test", + }) + }) + + t.Run("with empty payload", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("test_event_empty", map[string]any{}) + }) + + t.Run("with nil payload", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("test_event_nil", nil) + }) + + t.Run("with user context", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("user_event", map[string]any{"action": "click"}, + WithUser("user123", map[string]any{ + "user_name": "Test User", + "email": "test@example.com", + })) + }) + + t.Run("with user ID only", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("user_event_id_only", map[string]any{"action": "view"}, + WithUser("user456", nil)) + }) + + t.Run("multiple events with same user context", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + userCtx := WithUser("user789", map[string]any{"role": "admin"}) + client.Track("event1", map[string]any{"id": 1}, userCtx) + client.Track("event2", map[string]any{"id": 2}, userCtx) + }) + + t.Run("multiple events with different users", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + for i := range 5 { + userCtx := WithUser(fmt.Sprintf("user_%d", i), map[string]any{"num": i}) + client.Track("multi_user_event", map[string]any{"user_num": i}, userCtx) + } + }) +} + +// TestClient_ForUser validates UserTracker creation and usage +func TestClient_ForUser(t *testing.T) { + t.Run("creates user tracker", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + userTracker := client.ForUser("user_123", map[string]any{ + "name": "John Doe", + "email": "john@example.com", + }) + + if userTracker == nil { + t.Fatal("ForUser returned nil") + } + + if userTracker.userID != "user_123" { + t.Errorf("UserTracker userID = %v, want user_123", userTracker.userID) + } + }) + + t.Run("with nil attributes", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + userTracker := client.ForUser("user_456", nil) + + if userTracker == nil { + t.Fatal("ForUser returned nil") + } + + if userTracker.userAttributes != nil { + t.Errorf("UserTracker userAttributes = %v, want nil", userTracker.userAttributes) + } + }) + + t.Run("with empty attributes", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + userTracker := client.ForUser("user_789", map[string]any{}) + + if userTracker == nil { + t.Fatal("ForUser returned nil") + } + }) +} + +// TestUserTracker_Track validates user-scoped event tracking +func TestUserTracker_Track(t *testing.T) { + t.Run("track event with user context", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + userTracker := client.ForUser("user_123", map[string]any{ + "name": "John Doe", + "email": "john@example.com", + }) + + userTracker.Track("login", map[string]any{ + "method": "email", + }) + }) + + t.Run("track multiple events with same user tracker", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + userTracker := client.ForUser("user_456", map[string]any{ + "role": "admin", + }) + + userTracker.Track("page_view", map[string]any{"page": "/dashboard"}) + userTracker.Track("button_click", map[string]any{"button": "export"}) + userTracker.Track("file_download", map[string]any{"file": "report.csv"}) + }) + + t.Run("multiple user trackers from same client", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + user1Tracker := client.ForUser("user_1", map[string]any{"name": "User 1"}) + user2Tracker := client.ForUser("user_2", map[string]any{"name": "User 2"}) + + // Track events sequentially to avoid potential Snowplow internal races + for i := range 3 { + user1Tracker.Track("event", map[string]any{"num": i}) + } + + for i := range 3 { + user2Tracker.Track("event", map[string]any{"num": i}) + } + }) +} + +// TestClient_FlushEvents validates manual event flushing +func TestClient_FlushEvents(t *testing.T) { + t.Run("flush events synchronously", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("event1", map[string]any{"id": 1}) + client.Track("event2", map[string]any{"id": 2}) + + // Synchronous flush should block until complete + client.FlushEvents(false) + }) + + t.Run("flush events asynchronously", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("event3", map[string]any{"id": 3}) + + // Async flush returns immediately + client.FlushEvents(true) + }) + + t.Run("flush empty queue", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + // Should not panic or error when flushing empty queue + client.FlushEvents(false) + }) +} + +// TestClient_Close validates cleanup and idempotency +func TestClient_Close(t *testing.T) { + t.Run("close after tracking events", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + + client.Track("event1", map[string]any{"id": 1}) + client.Track("event2", map[string]any{"id": 2}) + + // Should flush remaining events and stop emitter + client.Close() + }) + + t.Run("close multiple times", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + + client.Track("event1", map[string]any{"id": 1}) + + // First close + client.Close() + + // Second close should be safe (idempotent) + client.Close() + + // Third close should also be safe + client.Close() + }) + + t.Run("close immediately after creation", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + + // Should be safe to close without tracking any events + client.Close() + }) + + t.Run("track after close should not panic", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + + client.Close() + + // Tracking after close should not panic (though events won't be sent) + // This tests that the mutex protection works correctly + client.Track("event_after_close", map[string]any{"id": 1}) + }) +} + +// TestClient_Concurrency validates thread-safety +func TestClient_Concurrency(t *testing.T) { + t.Run("concurrent Track calls", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + // Start the emitter by tracking one event first + // This avoids the Snowplow library's internal race in emitter.start() + client.Track("init", map[string]any{"init": true}) + + var wg sync.WaitGroup + numGoroutines := 10 + eventsPerGoroutine := 5 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < eventsPerGoroutine; j++ { + client.Track("concurrent_event", map[string]any{ + "goroutine": id, + "event": j, + }) + } + }(i) + } + + wg.Wait() + }) + + t.Run("concurrent Track with WithUser", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + // Start the emitter by tracking one event first + // This avoids the Snowplow library's internal race in emitter.start() + client.Track("init", map[string]any{"init": true}) + + var wg sync.WaitGroup + numUsers := 5 + + for i := 0; i < numUsers; i++ { + wg.Add(1) + go func(userNum int) { + defer wg.Done() + userID := fmt.Sprintf("user_%d", userNum) + for j := 0; j < 3; j++ { + client.Track("user_event", map[string]any{"event": j}, + WithUser(userID, map[string]any{"user_num": userNum})) + } + }(i) + } + + wg.Wait() + }) + + t.Run("concurrent FlushEvents", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + client.Track("event1", map[string]any{"id": 1}) + client.Track("event2", map[string]any{"id": 2}) + + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + client.FlushEvents(true) + }() + } + + wg.Wait() + }) + + t.Run("concurrent Track and FlushEvents", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + defer client.Close() + + // Start the emitter by tracking one event first + // This avoids the Snowplow library's internal race in emitter.start() + client.Track("init", map[string]any{"init": true}) + + var wg sync.WaitGroup + + // Track events concurrently + for i := 0; i < 5; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < 3; j++ { + client.Track("event", map[string]any{"id": id, "num": j}) + } + }(i) + } + + // Flush concurrently + wg.Add(1) + go func() { + defer wg.Done() + client.FlushEvents(true) + }() + + wg.Wait() + }) + + t.Run("concurrent Track and Close", func(t *testing.T) { + client, server := createTestClient(t) + defer server.Close() + + // Start the emitter by tracking one event first + // This avoids the Snowplow library's internal race in emitter.start() + client.Track("init", map[string]any{"init": true}) + + var wg sync.WaitGroup + + // Track events concurrently + for i := 0; i < 5; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + client.Track("event", map[string]any{"id": id}) + }(i) + } + + // Close concurrently (after a brief moment) + wg.Add(1) + go func() { + defer wg.Done() + client.Close() + }() + + wg.Wait() + }) +} diff --git a/go.mod b/go.mod index dda720d3fd8bcb0198881de20a4568223d7c7a0a..68131374dc9ff202bd1a0fc714d9734a94f2b2f8 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/prometheus/client_model v0.6.1 github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a github.com/sirupsen/logrus v1.9.3 + github.com/snowplow/snowplow-golang-tracker/v3 v3.1.0 github.com/stretchr/testify v1.10.0 github.com/uber/jaeger-client-go v2.29.1+incompatible gitlab.com/gitlab-org/go/reopen v1.0.0 @@ -48,6 +49,9 @@ require ( github.com/google/pprof v0.0.0-20210804190019-f964ff605595 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/gax-go/v2 v2.0.5 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.4 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/kylelemons/godebug v1.1.0 // indirect diff --git a/go.sum b/go.sum index 7653da72ae084751ff43791c522d6a3cc5561135..e4f70f11249aed467d0768a7052b6430b653c82d 100644 --- a/go.sum +++ b/go.sum @@ -203,12 +203,23 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jarcoal/httpmock v1.2.0 h1:gSvTxxFR/MEMfsGrvRbdfpRUMBStovlSRLw0Ep1bwwc= +github.com/jarcoal/httpmock v1.2.0/go.mod h1:oCoTsnAz4+UoOUIf5lJOWV2QQIW5UoeUI6aM2YnWAZk= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -235,6 +246,8 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20210210170715-a github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20210210170715-a8dfcb80d3a7/go.mod h1:Spd59icnvRxSKuyijbbwe5AemzvcyXAUBgApa7VybMw= github.com/lightstep/lightstep-tracer-go v0.25.0 h1:sGVnz8h3jTQuHKMbUe2949nXm3Sg09N1UcR3VoQNN5E= github.com/lightstep/lightstep-tracer-go v0.25.0/go.mod h1:G1ZAEaqTHFPWpWunnbUn1ADEY/Jvzz7jIOaXwAfD6A8= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -282,6 +295,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/snowplow/snowplow-golang-tracker/v3 v3.1.0 h1:KltihpesBUw2gCTQ4YSFzoICLgrn4WpKinObXrPsdwY= +github.com/snowplow/snowplow-golang-tracker/v3 v3.1.0/go.mod h1:pG2FAfMzD7YSYju/xVSgIYthcI+0eyZ0hkwsq+VssKU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=