Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/dev_server/dev_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func NewDevServerCmd(client resources.Client, analyticsTrackerFn analytics.Track
cmd.AddCommand(NewDeleteOverridesCmd(client))
cmd.AddGroup(&cobra.Group{ID: "server", Title: "Server commands:"})

cmd.AddCommand(NewStartServerCmd(ldClient))
cmd.AddCommand(NewStartServerCmd(ldClient, analyticsTrackerFn))
cmd.AddCommand(NewUICmd())

cmd.SetUsageTemplate(resourcecmd.SubcommandUsageTemplate())
Expand Down
12 changes: 9 additions & 3 deletions cmd/dev_server/start_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ import (
"github.com/launchdarkly/ldcli/cmd/cliflags"
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/cmd/validators"
"github.com/launchdarkly/ldcli/internal/analytics"
"github.com/launchdarkly/ldcli/internal/dev_server"
"github.com/launchdarkly/ldcli/internal/dev_server/adapters"
"github.com/launchdarkly/ldcli/internal/dev_server/model"
)

func NewStartServerCmd(client dev_server.Client) *cobra.Command {
func NewStartServerCmd(client dev_server.Client, analyticsTrackerFn analytics.TrackerFn) *cobra.Command {
cmd := &cobra.Command{
GroupID: "server",
Args: validators.Validate(),
Long: "start the dev server",
RunE: startServer(client),
RunE: startServer(client, analyticsTrackerFn),
Short: "start the dev server",
Use: "start",
}
Expand Down Expand Up @@ -57,7 +58,7 @@ func NewStartServerCmd(client dev_server.Client) *cobra.Command {
return cmd
}

func startServer(client dev_server.Client) func(*cobra.Command, []string) error {
func startServer(client dev_server.Client, analyticsTrackerFn analytics.TrackerFn) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx := context.Background()

Expand Down Expand Up @@ -108,6 +109,11 @@ func startServer(client dev_server.Client) func(*cobra.Command, []string) error
StreamFlagStartup: viper.GetBool(StreamFlagStartupFlag),
SdkInitTimeout: sdkInitTimeout,
InitialProjectSettings: initialSetting,
Tracker: analyticsTrackerFn(
viper.GetString(cliflags.AccessTokenFlag),
viper.GetString(cliflags.BaseURIFlag),
viper.GetBool(cliflags.AnalyticsOptOut),
),
}

client.RunServer(ctx, params)
Expand Down
1 change: 1 addition & 0 deletions cmd/dev_server/start_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestStartServerCmd(t *testing.T) {

require.NoError(t, err)
assert.True(t, mockClient.RunServerCalled)
assert.IsType(t, &analytics.NoopClient{}, mockClient.RunServerParams.Tracker)
require.NotNil(t, mockClient.RunServerParams.InitialProjectSettings.Context)
assert.Equal(t, "test-user", mockClient.RunServerParams.InitialProjectSettings.Context.Key())
})
Expand Down
50 changes: 47 additions & 3 deletions internal/analytics/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
)

const maxUIAnalyticsInFlight = 10

type ClientFn struct {
ID string
Version string
Expand Down Expand Up @@ -42,10 +45,20 @@ type Client struct {
id string
version string
wg sync.WaitGroup
uiInFlight atomic.Int32
}

// sendEvent makes an async request to track the given event with properties.
func (c *Client) sendEvent(eventName string, properties map[string]interface{}) {
func (c *Client) sendEvent(eventName string, properties map[string]interface{}, release func()) {
started := false
if release != nil {
defer func() {
if !started {
release()
}
}()
}

properties["id"] = c.id
if c.agentContext != "" {
properties["agent_context"] = c.agentContext
Expand Down Expand Up @@ -81,13 +94,17 @@ func (c *Client) sendEvent(eventName string, properties map[string]interface{})
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", fmt.Sprintf("launchdarkly-cli/%s", c.version))
var resp *http.Response
started = true
go func() {
defer c.wg.Done()
if release != nil {
defer release()
}
resp, err = c.httpClient.Do(req)
if err != nil { //nolint:staticcheck
// TODO: log error
}
if resp == nil {
c.wg.Done()
return
}

Expand All @@ -96,14 +113,30 @@ func (c *Client) sendEvent(eventName string, properties map[string]interface{})
// TODO: log error
}
resp.Body.Close()
c.wg.Done()
}()
}

func (c *Client) reserveUIAnalyticsSlot() bool {
for {
current := c.uiInFlight.Load()
if current >= maxUIAnalyticsInFlight {
return false
}
if c.uiInFlight.CompareAndSwap(current, current+1) {
return true
}
}
}

func (c *Client) releaseUIAnalyticsSlot() {
c.uiInFlight.Add(-1)
}

func (c *Client) SendCommandRunEvent(properties map[string]interface{}) {
c.sendEvent(
"CLI Command Run",
properties,
nil,
)
}

Expand All @@ -113,6 +146,7 @@ func (c *Client) SendCommandCompletedEvent(outcome string) {
map[string]interface{}{
"outcome": outcome,
},
nil,
)
}

Expand All @@ -122,6 +156,7 @@ func (c *Client) SendSetupStepStartedEvent(step string) {
map[string]interface{}{
"step": step,
},
nil,
)
}

Expand All @@ -131,6 +166,7 @@ func (c *Client) SendSetupSDKSelectedEvent(sdk string) {
map[string]interface{}{
"sdk": sdk,
},
nil,
)
}

Expand All @@ -142,9 +178,17 @@ func (c *Client) SendSetupFlagToggledEvent(on bool, count int, duration_ms int64
"count": count,
"duration_ms": duration_ms,
},
nil,
)
}

func (c *Client) SendDevServerUIEvent(name string, properties map[string]interface{}) {
if !c.reserveUIAnalyticsSlot() {
return
}
c.sendEvent(name, properties, c.releaseUIAnalyticsSlot)
}

func (a *Client) Wait() {
a.wg.Wait()
}
123 changes: 123 additions & 0 deletions internal/analytics/client_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package analytics

import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -178,3 +183,121 @@ func TestClient_SendEvent(t *testing.T) {
assert.Equal(t, "codex", received.Properties["agent_context"])
})
}

func TestClient_SendDevServerUIEvent(t *testing.T) {
t.Run("posts the event name, id, and headers", func(t *testing.T) {
var received trackingPayload
var gotAuth, gotType, gotAgent, gotMethod, gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
gotAuth = r.Header.Get("Authorization")
gotType = r.Header.Get("Content-Type")
gotAgent = r.Header.Get("User-Agent")
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

fn := ClientFn{ID: "test-id", Version: "1.0.0", AgentContext: "cursor"}
tracker := fn.Tracker("test-token", server.URL, false)
tracker.SendDevServerUIEvent("Dev Server UI Page Viewed", map[string]interface{}{
"page": "flags",
})
tracker.Wait()

assert.Equal(t, "Dev Server UI Page Viewed", received.Event)
assert.Equal(t, "test-id", received.Properties["id"])
assert.Equal(t, "cursor", received.Properties["agent_context"])
assert.Equal(t, "flags", received.Properties["page"])
assert.Equal(t, "test-token", gotAuth)
assert.Equal(t, "application/json", gotType)
assert.Equal(t, "launchdarkly-cli/1.0.0", gotAgent)
assert.Equal(t, http.MethodPost, gotMethod)
assert.Equal(t, "/internal/tracking", gotPath)
})

t.Run("omits agent_context when empty", func(t *testing.T) {
var received trackingPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

fn := ClientFn{ID: "test-id", Version: "1.0.0"}
tracker := fn.Tracker("test-token", server.URL, false)
tracker.SendDevServerUIEvent("Dev Server UI Events Stream Opened", map[string]interface{}{})
tracker.Wait()

_, hasAgentCtx := received.Properties["agent_context"]
assert.False(t, hasAgentCtx)
assert.Equal(t, "test-id", received.Properties["id"])
})

t.Run("noop client does not start a request", func(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

tracker := ClientFn{ID: "test-id", Version: "1.0.0"}.Tracker("test-token", server.URL, true)
_, ok := tracker.(*NoopClient)
require.True(t, ok)
tracker.SendDevServerUIEvent("Dev Server UI Page Viewed", map[string]interface{}{"page": "flags"})
tracker.Wait()
assert.False(t, called)
})

t.Run("drops the event when 10 calls are in flight", func(t *testing.T) {
var mu sync.Mutex
var started int
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
started++
mu.Unlock()
<-release
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

tracker := ClientFn{ID: "test-id", Version: "1.0.0"}.Tracker("token", server.URL, false)
for i := 0; i < maxUIAnalyticsInFlight; i++ {
tracker.SendDevServerUIEvent("Dev Server UI Page Viewed", map[string]interface{}{"page": "flags"})
}
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return started == maxUIAnalyticsInFlight
}, time.Second, 10*time.Millisecond)

tracker.SendDevServerUIEvent("Dev Server UI Page Viewed", map[string]interface{}{"page": "events"})
time.Sleep(50 * time.Millisecond)
mu.Lock()
assert.Equal(t, maxUIAnalyticsInFlight, started)
mu.Unlock()

close(release)
tracker.Wait()
})
}

func TestLogClient_SendDevServerUIEvent(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() {
log.SetOutput(os.Stderr)
})

(&LogClient{}).SendDevServerUIEvent("Dev Server UI Page Viewed", map[string]interface{}{
"page": "flags",
})

assert.Contains(t, buf.String(), "SendDevServerUIEvent")
assert.Contains(t, buf.String(), "Dev Server UI Page Viewed")
}
3 changes: 3 additions & 0 deletions internal/analytics/log_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ func (c *LogClient) SendSetupSDKSelectedEvent(sdk string) {
func (c *LogClient) SendSetupFlagToggledEvent(on bool, count int, duration_ms int64) {
log.Printf("SendSetupFlagToggledEvent, count: %v", count)
}
func (c *LogClient) SendDevServerUIEvent(name string, properties map[string]interface{}) {
log.Printf("SendDevServerUIEvent, name: %s, properties: %v", name, properties)
}
func (a *LogClient) Wait() {}
4 changes: 4 additions & 0 deletions internal/analytics/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,8 @@ func (m *MockTracker) SendSetupFlagToggledEvent(on bool, count int, duration_ms
)
}

func (m *MockTracker) SendDevServerUIEvent(name string, properties map[string]interface{}) {
m.sendEvent(name, properties)
}

func (a *MockTracker) Wait() {}
4 changes: 3 additions & 1 deletion internal/analytics/noop_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ func (c *NoopClient) SendCommandCompletedEvent(outcome string)
func (c *NoopClient) SendSetupStepStartedEvent(step string) {}
func (c *NoopClient) SendSetupSDKSelectedEvent(sdk string) {}
func (c *NoopClient) SendSetupFlagToggledEvent(on bool, count int, duration_ms int64) {}
func (a *NoopClient) Wait() {}
func (c *NoopClient) SendDevServerUIEvent(name string, properties map[string]interface{}) {
}
func (a *NoopClient) Wait() {}
1 change: 1 addition & 0 deletions internal/analytics/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ type Tracker interface {
SendSetupStepStartedEvent(step string)
SendSetupSDKSelectedEvent(sdk string)
SendSetupFlagToggledEvent(on bool, count int, duration_ms int64)
SendDevServerUIEvent(name string, properties map[string]interface{})
Wait()
}
Loading
Loading