Skip to content
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ To see which config file is currently in use:
lstk config path
```

## Environment Variables

| Variable | Description |
|---|---|
| `LOCALSTACK_AUTH_TOKEN` | Auth token; for CI only |
| `LOCALSTACK_DISABLE_EVENTS=1` | Disables telemetry event reporting |

## Usage

```bash
Expand Down
3 changes: 2 additions & 1 deletion cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import (
"testing"

"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/telemetry"
)

func executeWithArgs(t *testing.T, args ...string) (string, error) {
t.Helper()
buf := new(bytes.Buffer)
cmd := NewRootCmd(&env.Env{})
cmd := NewRootCmd(&env.Env{}, telemetry.New("", true))
cmd.SetOut(buf)
cmd.SetErr(buf)
cmd.SetArgs(args)
Expand Down
36 changes: 22 additions & 14 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,24 @@ import (
"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/localstack/lstk/internal/ui"
"github.com/localstack/lstk/internal/version"
"github.com/spf13/cobra"
)

func NewRootCmd(cfg *env.Env) *cobra.Command {
func NewRootCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
root := &cobra.Command{
Use: "lstk",
Short: "LocalStack CLI",
Long: "lstk is the command-line interface for LocalStack.",
PreRunE: initConfig,
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

if err := runStart(cmd.Context(), rt, cfg); err != nil {
if !output.IsSilent(err) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
return err
}
return runStart(cmd.Context(), rt, cfg, tel)
},
}

Expand All @@ -47,7 +41,7 @@ func NewRootCmd(cfg *env.Env) *cobra.Command {
root.Flags().Lookup("version").Usage = "Show version"

root.AddCommand(
newStartCmd(cfg),
newStartCmd(cfg, tel),
newStopCmd(),
newLoginCmd(cfg),
newLogoutCmd(cfg),
Expand All @@ -61,10 +55,24 @@ func NewRootCmd(cfg *env.Env) *cobra.Command {

func Execute(ctx context.Context) error {
cfg := env.Init()
return NewRootCmd(cfg).ExecuteContext(ctx)
tel := telemetry.New(cfg.AnalyticsEndpoint, cfg.DisableEvents)
defer tel.Close()

root := NewRootCmd(cfg, tel)

if err := root.ExecuteContext(ctx); err != nil {
if !output.IsSilent(err) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
Comment on lines +63 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use internal/output typed emitters instead of direct stderr writes.

Line 66 prints directly via fmt.Fprintf(os.Stderr, ...), which bypasses the typed output pathway used by command handlers.

As per coding guidelines, "Emit typed events through internal/output (EmitInfo, EmitSuccess, EmitNote, EmitWarning, EmitStatus, EmitProgress, etc.) instead of printing from domain/command handlers".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/root.go` around lines 64 - 67, Replace the direct stderr write in the
root.ExecuteContext error path with the project’s typed output emitter: instead
of fmt.Fprintf(os.Stderr, "Error: %v\n", err) call the appropriate
internal/output emitter (e.g., output.EmitWarning or output.EmitStatus, or the
project’s error-specific emitter) to emit the error message, using the same
context and formatted text only if !output.IsSilent(err); update the block that
checks output.IsSilent(err) around root.ExecuteContext to call output.Emit...
with the formatted error string so all command errors go through the typed
output pipeline.

return err
}
return nil
}

func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env) error {
func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client) error {
// TODO: replace map with a typed payload struct once event schema is finalised
tel.Emit(ctx, "cli_cmd", map[string]any{"cmd": "lstk start", "params": []string{}})

platformClient := api.NewPlatformClient(cfg.APIEndpoint)
if ui.IsInteractive() {
return ui.Run(ctx, rt, version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL)
Expand Down
20 changes: 5 additions & 15 deletions cmd/start.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,24 @@
package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/env"
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/runtime"
"github.com/localstack/lstk/internal/telemetry"
"github.com/spf13/cobra"
)

func newStartCmd(cfg *env.Env) *cobra.Command {
func newStartCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start emulator",
Long: "Start emulator and services.",
PreRunE: initConfig,
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

if err := runStart(cmd.Context(), rt, cfg); err != nil {
if !output.IsSilent(err) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
return err
}
return runStart(cmd.Context(), rt, cfg, tel)
},
}
}
17 changes: 4 additions & 13 deletions cmd/stop.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cmd

import (
"fmt"
"os"

"github.com/localstack/lstk/internal/container"
Expand All @@ -17,25 +16,17 @@ func newStopCmd() *cobra.Command {
Short: "Stop emulator",
Long: "Stop emulator and services",
PreRunE: initConfig,
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := runtime.NewDockerRuntime()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
return err
}

if ui.IsInteractive() {
if err := ui.RunStop(cmd.Context(), rt); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
return ui.RunStop(cmd.Context(), rt)
}

if err := container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout))
},
}
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/containerd/errdefs v1.0.0
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/google/uuid v1.6.0
github.com/muesli/termenv v0.16.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.10.2
Expand Down
26 changes: 15 additions & 11 deletions internal/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
)

type Env struct {
AuthToken string
APIEndpoint string
WebAppURL string
ForceFileKeyring bool
AuthToken string
APIEndpoint string
WebAppURL string
ForceFileKeyring bool
AnalyticsEndpoint string
DisableEvents bool
}

// Init initializes environment variable configuration and returns the result.
Expand All @@ -22,14 +24,16 @@ func Init() *Env {

viper.SetDefault("api_endpoint", "https://api.localstack.cloud")
viper.SetDefault("web_app_url", "https://app.localstack.cloud")

// LOCALSTACK_AUTH_TOKEN is not prefixed with LSTK_
// in order to be shared seamlessly with other LocalStack tools
viper.SetDefault("analytics_endpoint", "https://analytics.localstack.cloud/v1/events")
// LOCALSTACK_AUTH_TOKEN and LOCALSTACK_DISABLE_EVENTS are not prefixed with LSTK_
// so they work seamlessly across all LocalStack tools without per-tool configuration
return &Env{
AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"),
APIEndpoint: viper.GetString("api_endpoint"),
WebAppURL: viper.GetString("web_app_url"),
ForceFileKeyring: viper.GetString("keyring") == "file",
AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"),
APIEndpoint: viper.GetString("api_endpoint"),
WebAppURL: viper.GetString("web_app_url"),
ForceFileKeyring: viper.GetString("keyring") == "file",
AnalyticsEndpoint: viper.GetString("analytics_endpoint"),
DisableEvents: os.Getenv("LOCALSTACK_DISABLE_EVENTS") == "1",
}

}
143 changes: 143 additions & 0 deletions internal/telemetry/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package telemetry

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"runtime"
"sync"
"time"

"github.com/google/uuid"
"github.com/localstack/lstk/internal/version"
)

func userAgent() string {
return fmt.Sprintf("localstack lstk/%s (%s; %s)", version.Version(), runtime.GOOS, runtime.GOARCH)
}

type Client struct {
enabled bool
sessionID string
machineID string

httpClient *http.Client
endpoint string

events chan eventBody
done chan struct{}
closeOnce sync.Once
}

func New(endpoint string, disabled bool) *Client {
if disabled {
return &Client{enabled: false}
}
c := &Client{
enabled: true,
sessionID: uuid.NewString(),
machineID: LoadOrCreateMachineID(),
// http.Client has no default timeout (zero means none). Without one, a
// slow or unreachable endpoint would block the worker goroutine.
httpClient: &http.Client{
Timeout: 3 * time.Second,
},
endpoint: endpoint,
events: make(chan eventBody, 64),
done: make(chan struct{}),
}
go c.worker()
return c
}

type requestBody struct {
Events []eventBody `json:"events"`
}

type eventBody struct {
ctx context.Context // not serialized; carries context to the worker
Name string `json:"name"`
Metadata eventMetadata `json:"metadata"`
Payload any `json:"payload"`
}

type eventMetadata struct {
ClientTime string `json:"client_time"`
SessionID string `json:"session_id"`
}

func (c *Client) Emit(ctx context.Context, name string, payload map[string]any) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I wonder if this naming won't be confusing with the other events we have. But I raised the idea of merging those into the same concept in the future either way so it makes sense.

if !c.enabled {
return
}

enriched := make(map[string]any, len(payload)+6)
for k, v := range payload {
enriched[k] = v
}
enriched["version"] = version.Version()
enriched["os"] = runtime.GOOS
enriched["arch"] = runtime.GOARCH
_, enriched["is_ci"] = os.LookupEnv("CI")
if c.machineID != "" {
enriched["machine_id"] = c.machineID
}

body := eventBody{
ctx: context.WithoutCancel(ctx),
Name: name,
Metadata: eventMetadata{
ClientTime: time.Now().UTC().Format("2006-01-02 15:04:05.000000"),
SessionID: c.sessionID,
},
Payload: enriched,
}

select {
case c.events <- body:
default:
}
}

func (c *Client) worker() {
defer close(c.done)
for body := range c.events {
c.send(body)
}
}

func (c *Client) send(body eventBody) {
data, err := json.Marshal(requestBody{Events: []eventBody{body}})
if err != nil {
return
}

req, err := http.NewRequestWithContext(body.ctx, http.MethodPost, c.endpoint, bytes.NewReader(data))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent())

resp, err := c.httpClient.Do(req)
if err != nil {
return
}
_ = resp.Body.Close()
}

// Close stops accepting new events, drains the event buffer, and blocks until
// all pending HTTP requests have completed. Call it before process exit to
// avoid dropping telemetry events.
func (c *Client) Close() {
if !c.enabled {
return
}
c.closeOnce.Do(func() {
close(c.events)
<-c.done
})
}
Loading