Skip to content

Commit 37daf75

Browse files
telemetry client
1 parent 3ac17a7 commit 37daf75

File tree

11 files changed

+411
-18
lines changed

11 files changed

+411
-18
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ When no config file exists, `lstk` creates one at:
1616

1717
Use `lstk config path` to print the resolved config file path currently in use.
1818

19+
## Environment Variables
20+
21+
| Variable | Description |
22+
|---|---|
23+
| `LOCALSTACK_AUTH_TOKEN` | Auth token; for CI only |
24+
| `LOCALSTACK_DISABLE_EVENTS=1` | Disables telemetry event reporting |
25+
1926
## Versioning
2027
`lstk` uses calendar versioning in a SemVer-compatible format:
2128

cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/localstack/lstk/internal/env"
1212
"github.com/localstack/lstk/internal/output"
1313
"github.com/localstack/lstk/internal/runtime"
14+
"github.com/localstack/lstk/internal/telemetry"
1415
"github.com/localstack/lstk/internal/ui"
1516
"github.com/localstack/lstk/internal/version"
1617
"github.com/spf13/cobra"
@@ -53,6 +54,11 @@ func Execute(ctx context.Context) error {
5354
}
5455

5556
func runStart(ctx context.Context, rt runtime.Runtime) error {
57+
tel := telemetry.New()
58+
defer tel.Flush()
59+
// TODO: replace map with a typed payload struct once event schema is finalised
60+
tel.Track("cli_cmd", map[string]any{"cmd": "lstk start", "params": []string{}})
61+
5662
platformClient := api.NewPlatformClient()
5763
if ui.IsInteractive() {
5864
return ui.Run(ctx, rt, version.Version(), platformClient)

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ go 1.26.0
44

55
require (
66
github.com/99designs/keyring v1.2.2
7+
github.com/charmbracelet/bubbles v1.0.0
78
github.com/charmbracelet/bubbletea v1.3.10
89
github.com/charmbracelet/lipgloss v1.1.0
910
github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c
1011
github.com/containerd/errdefs v1.0.0
1112
github.com/docker/docker v28.5.2+incompatible
1213
github.com/docker/go-connections v0.6.0
14+
github.com/google/uuid v1.6.0
1315
github.com/muesli/termenv v0.16.0
1416
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1517
github.com/spf13/cobra v1.10.2
@@ -27,7 +29,6 @@ require (
2729
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
2830
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
2931
github.com/cespare/xxhash/v2 v2.3.0 // indirect
30-
github.com/charmbracelet/bubbles v1.0.0 // indirect
3132
github.com/charmbracelet/colorprofile v0.4.2 // indirect
3233
github.com/charmbracelet/x/ansi v0.11.6 // indirect
3334
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF
2626
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
2727
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
2828
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
29-
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
30-
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
3129
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
3230
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
3331
github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c h1:/pbU92+xMwttewB4XK69/B9ISH0HMhOMrTIVhV4AS7M=

internal/env/env.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
)
99

1010
type Env struct {
11-
AuthToken string
12-
APIEndpoint string
13-
WebAppURL string
14-
Keyring string
11+
AuthToken string
12+
APIEndpoint string
13+
WebAppURL string
14+
Keyring string
15+
AnalyticsEndpoint string
16+
DisableEvents bool
1517
}
1618

1719
var Vars = &Env{}
@@ -24,13 +26,15 @@ func Init() {
2426

2527
viper.SetDefault("api_endpoint", "https://api.localstack.cloud")
2628
viper.SetDefault("web_app_url", "https://app.localstack.cloud")
27-
28-
// LOCALSTACK_AUTH_TOKEN is not prefixed with LSTK_
29-
// in order to be shared seamlessly with other LocalStack tools
29+
viper.SetDefault("analytics_endpoint", "https://analytics.localstack.cloud/v1/events")
30+
// LOCALSTACK_AUTH_TOKEN and LOCALSTACK_DISABLE_EVENTS are not prefixed with LSTK_
31+
// so they work seamlessly across all LocalStack tools without per-tool configuration
3032
Vars = &Env{
31-
AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"),
32-
APIEndpoint: viper.GetString("api_endpoint"),
33-
WebAppURL: viper.GetString("web_app_url"),
34-
Keyring: viper.GetString("keyring"),
33+
AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"),
34+
APIEndpoint: viper.GetString("api_endpoint"),
35+
WebAppURL: viper.GetString("web_app_url"),
36+
Keyring: viper.GetString("keyring"),
37+
AnalyticsEndpoint: viper.GetString("analytics_endpoint"),
38+
DisableEvents: os.Getenv("LOCALSTACK_DISABLE_EVENTS") == "1",
3539
}
3640
}

internal/telemetry/client.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package telemetry
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"os"
8+
"runtime"
9+
"sync"
10+
"time"
11+
12+
"github.com/google/uuid"
13+
"github.com/localstack/lstk/internal/env"
14+
"github.com/localstack/lstk/internal/version"
15+
)
16+
17+
const clientHeader = "lstk/v2"
18+
19+
type Client struct {
20+
enabled bool
21+
sessionID string
22+
machineID string
23+
httpClient *http.Client
24+
endpoint string
25+
wg sync.WaitGroup
26+
}
27+
28+
func New() *Client {
29+
if env.Vars.DisableEvents {
30+
return &Client{enabled: false}
31+
}
32+
return &Client{
33+
enabled: true,
34+
sessionID: uuid.NewString(),
35+
machineID: LoadOrCreateMachineID(),
36+
// http.Client has no default timeout (zero means none). Without one, a
37+
// slow or unreachable endpoint would block the goroutine until process
38+
// exit — which matters for long-running commands like `lstk logs --follow`.
39+
httpClient: &http.Client{
40+
Timeout: 3 * time.Second,
41+
},
42+
endpoint: env.Vars.AnalyticsEndpoint,
43+
}
44+
}
45+
46+
type requestBody struct {
47+
Events []eventBody `json:"events"`
48+
}
49+
50+
type eventBody struct {
51+
Name string `json:"name"`
52+
Metadata eventMetadata `json:"metadata"`
53+
Payload any `json:"payload"`
54+
}
55+
56+
type eventMetadata struct {
57+
ClientTime string `json:"client_time"`
58+
SessionID string `json:"session_id"`
59+
}
60+
61+
func (c *Client) Track(name string, payload map[string]any) {
62+
if !c.enabled {
63+
return
64+
}
65+
66+
enriched := make(map[string]any, len(payload)+6)
67+
for k, v := range payload {
68+
enriched[k] = v
69+
}
70+
enriched["version"] = version.Version()
71+
enriched["os"] = runtime.GOOS
72+
enriched["arch"] = runtime.GOARCH
73+
enriched["is_ci"] = os.Getenv("CI") != ""
74+
if c.machineID != "" {
75+
enriched["machine_id"] = c.machineID
76+
}
77+
78+
body := eventBody{
79+
Name: name,
80+
Metadata: eventMetadata{
81+
ClientTime: time.Now().UTC().Format("2006-01-02 15:04:05.000000"),
82+
SessionID: c.sessionID,
83+
},
84+
Payload: enriched,
85+
}
86+
87+
c.wg.Add(1)
88+
go func() {
89+
defer c.wg.Done()
90+
91+
data, err := json.Marshal(requestBody{Events: []eventBody{body}})
92+
if err != nil {
93+
return
94+
}
95+
96+
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(data))
97+
if err != nil {
98+
return
99+
}
100+
req.Header.Set("Content-Type", "application/json")
101+
req.Header.Set("X-Client", clientHeader)
102+
103+
resp, err := c.httpClient.Do(req)
104+
if err != nil {
105+
return
106+
}
107+
_ = resp.Body.Close()
108+
}()
109+
}
110+
111+
// Flush blocks until all in-flight Track goroutines have completed. Call it
112+
// before process exit to avoid dropping telemetry events. It returns quickly
113+
// when no events are pending, and is bounded by the HTTP client's timeout in
114+
// the worst case.
115+
func (c *Client) Flush() {
116+
c.wg.Wait()
117+
}
118+

internal/telemetry/client_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package telemetry
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"runtime"
10+
"testing"
11+
"time"
12+
13+
"github.com/localstack/lstk/internal/env"
14+
"github.com/localstack/lstk/internal/version"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestTrack_SendsCorrectPayloadAndHeaders(t *testing.T) {
20+
type captured struct {
21+
event map[string]any
22+
header http.Header
23+
}
24+
ch := make(chan captured, 1)
25+
26+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
body, err := io.ReadAll(r.Body)
28+
require.NoError(t, err)
29+
30+
var req struct {
31+
Events []map[string]any `json:"events"`
32+
}
33+
require.NoError(t, json.Unmarshal(body, &req))
34+
require.Len(t, req.Events, 1)
35+
36+
ch <- captured{event: req.Events[0], header: r.Header.Clone()}
37+
w.WriteHeader(http.StatusOK)
38+
}))
39+
defer srv.Close()
40+
41+
env.Vars.DisableEvents = false
42+
env.Vars.AnalyticsEndpoint = srv.URL
43+
44+
c := New()
45+
c.Track("cli_cmd", map[string]any{"cmd": "lstk start", "params": []string{}})
46+
c.Flush()
47+
48+
got := <-ch
49+
50+
assert.Equal(t, "cli_cmd", got.event["name"])
51+
52+
metadata, ok := got.event["metadata"].(map[string]any)
53+
require.True(t, ok, "metadata should be an object")
54+
assert.Equal(t, c.sessionID, metadata["session_id"])
55+
_, err := time.Parse("2006-01-02 15:04:05.000000", metadata["client_time"].(string))
56+
assert.NoError(t, err, "client_time should match expected format")
57+
assert.Nil(t, metadata["version"], "version should be in payload, not metadata")
58+
assert.Nil(t, metadata["machine_id"], "machine_id should be in payload, not metadata")
59+
60+
payload, ok := got.event["payload"].(map[string]any)
61+
require.True(t, ok, "payload should be an object")
62+
assert.Equal(t, "lstk start", payload["cmd"])
63+
assert.Equal(t, version.Version(), payload["version"])
64+
assert.Equal(t, runtime.GOOS, payload["os"])
65+
assert.Equal(t, runtime.GOARCH, payload["arch"])
66+
assert.Equal(t, os.Getenv("CI") != "", payload["is_ci"])
67+
68+
assert.Equal(t, "lstk/v2", got.header.Get("X-Client"))
69+
}

internal/telemetry/machine_id.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/google/uuid"
9+
"github.com/localstack/lstk/internal/config"
10+
)
11+
12+
const machineIDFileName = "machine_id"
13+
14+
// LoadOrCreateMachineID reads the persisted machine ID from disk, generating
15+
// and writing a new one if none exists. Returns an empty string on any error
16+
// so that telemetry can continue without a machine ID rather than failing.
17+
func LoadOrCreateMachineID() string {
18+
path, err := machineIDPath()
19+
if err != nil {
20+
return ""
21+
}
22+
23+
data, err := os.ReadFile(path)
24+
if err == nil {
25+
id := strings.TrimSpace(string(data))
26+
if id != "" {
27+
return id
28+
}
29+
} else if !os.IsNotExist(err) {
30+
return ""
31+
}
32+
33+
id := uuid.NewString()
34+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
35+
return ""
36+
}
37+
if err := os.WriteFile(path, []byte(id), 0600); err != nil {
38+
return ""
39+
}
40+
return id
41+
}
42+
43+
func machineIDPath() (string, error) {
44+
dir, err := config.ConfigDir()
45+
if err != nil {
46+
return "", err
47+
}
48+
return filepath.Join(dir, machineIDFileName), nil
49+
}

test/integration/env/env.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import (
99
type Key string
1010

1111
const (
12-
AuthToken Key = "LOCALSTACK_AUTH_TOKEN"
13-
APIEndpoint Key = "LSTK_API_ENDPOINT"
14-
Keyring Key = "LSTK_KEYRING"
15-
CI Key = "CI"
12+
AuthToken Key = "LOCALSTACK_AUTH_TOKEN"
13+
APIEndpoint Key = "LSTK_API_ENDPOINT"
14+
Keyring Key = "LSTK_KEYRING"
15+
CI Key = "CI"
16+
AnalyticsEndpoint Key = "LSTK_ANALYTICS_ENDPOINT"
17+
DisableEvents Key = "LOCALSTACK_DISABLE_EVENTS"
1618
)
1719

1820
func Get(key Key) string {

test/integration/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/99designs/keyring v1.2.2
77
github.com/creack/pty v1.1.18
88
github.com/docker/docker v28.2.2+incompatible
9+
github.com/google/uuid v1.6.0
910
github.com/stretchr/testify v1.11.1
1011
)
1112

0 commit comments

Comments
 (0)