Skip to content

Commit d31e771

Browse files
Merge branch 'carole/drg-589' into carole/drg-589-2
2 parents 5ae74e2 + 32b27b5 commit d31e771

31 files changed

Lines changed: 858 additions & 85 deletions

.claude/skills/add-command/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ Create `test/integration/<name>_test.go` with:
9494
- Use `cleanup()` and `t.Cleanup(cleanup)` for container state
9595
- Use `context.WithTimeout` for all tests
9696

97+
## Telemetry
98+
99+
Every new command must emit an `lstk_command` telemetry event. Wrap the command's `RunE` with `commandWithTelemetry(name, tel, fn)` — this handles timing, exit code, and error message automatically.
100+
101+
Start and stop are exceptions: they emit `lstk_lifecycle` events in addition to `lstk_command`, so they manage their own telemetry manually instead of using `commandWithTelemetry`.
102+
103+
In the corresponding integration test, add an assertion that the `lstk_command` event was emitted.
104+
97105
## Anti-patterns to avoid
98106

99107
- Do NOT put business logic in `cmd/` — the command file should be thin wiring only

cmd/config.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,25 @@ import (
44
"fmt"
55

66
"github.com/localstack/lstk/internal/config"
7+
"github.com/localstack/lstk/internal/env"
8+
"github.com/localstack/lstk/internal/telemetry"
79
"github.com/spf13/cobra"
810
)
911

10-
func newConfigCmd() *cobra.Command {
12+
func newConfigCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
1113
cmd := &cobra.Command{
1214
Use: "config",
1315
Short: "Manage configuration",
1416
}
15-
cmd.AddCommand(newConfigPathCmd())
17+
cmd.AddCommand(newConfigPathCmd(cfg, tel))
1618
return cmd
1719
}
1820

19-
func newConfigPathCmd() *cobra.Command {
21+
func newConfigPathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
2022
return &cobra.Command{
2123
Use: "path",
2224
Short: "Print the configuration file path",
23-
RunE: func(cmd *cobra.Command, args []string) error {
25+
RunE: commandWithTelemetry("config path", tel, func(cmd *cobra.Command, args []string) error {
2426
path, err := cmd.Flags().GetString("config")
2527
if err != nil {
2628
return err
@@ -37,6 +39,6 @@ func newConfigPathCmd() *cobra.Command {
3739

3840
_, err = fmt.Fprintln(cmd.OutOrStdout(), configPath)
3941
return err
40-
},
42+
}),
4143
}
4244
}

cmd/login.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,35 @@ import (
44
"fmt"
55

66
"github.com/localstack/lstk/internal/api"
7+
"github.com/localstack/lstk/internal/auth"
78
"github.com/localstack/lstk/internal/env"
89
"github.com/localstack/lstk/internal/log"
10+
"github.com/localstack/lstk/internal/telemetry"
911
"github.com/localstack/lstk/internal/ui"
1012
"github.com/localstack/lstk/internal/version"
1113
"github.com/spf13/cobra"
1214
)
1315

14-
func newLoginCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
16+
func newLoginCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
1517
return &cobra.Command{
1618
Use: "login",
1719
Short: "Manage login",
1820
Long: "Manage login and store credentials in system keyring",
1921
PreRunE: initConfig,
20-
RunE: func(cmd *cobra.Command, args []string) error {
22+
RunE: commandWithTelemetry("login", tel, func(cmd *cobra.Command, args []string) error {
2123
if !isInteractiveMode(cfg) {
2224
return fmt.Errorf("login requires an interactive terminal")
2325
}
2426
platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger)
25-
return ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger)
26-
},
27+
if err := ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger); err != nil {
28+
return err
29+
}
30+
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil {
31+
if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" {
32+
tel.SetAuthToken(token)
33+
}
34+
}
35+
return nil
36+
}),
2737
}
2838
}

cmd/logout.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@ import (
1313
"github.com/localstack/lstk/internal/log"
1414
"github.com/localstack/lstk/internal/output"
1515
"github.com/localstack/lstk/internal/runtime"
16+
"github.com/localstack/lstk/internal/telemetry"
1617
"github.com/localstack/lstk/internal/ui"
1718
"github.com/spf13/cobra"
1819
)
1920

20-
func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
21+
func newLogoutCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
2122
return &cobra.Command{
2223
Use: "logout",
2324
Short: "Remove stored authentication credentials",
2425
PreRunE: initConfig,
25-
RunE: func(cmd *cobra.Command, args []string) error {
26+
RunE: commandWithTelemetry("logout", tel, func(cmd *cobra.Command, args []string) error {
2627
platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger)
2728
appConfig, err := config.Get()
2829
if err != nil {
@@ -56,6 +57,6 @@ func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
5657
}
5758
}
5859
return nil
59-
},
60+
}),
6061
}
6162
}

cmd/logs.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ import (
1010
"github.com/localstack/lstk/internal/output"
1111
"github.com/localstack/lstk/internal/runtime"
1212
"github.com/localstack/lstk/internal/ui"
13+
"github.com/localstack/lstk/internal/telemetry"
1314
"github.com/spf13/cobra"
1415
)
1516

16-
func newLogsCmd(cfg *env.Env) *cobra.Command {
17+
func newLogsCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
1718
cmd := &cobra.Command{
1819
Use: "logs",
1920
Short: "Show emulator logs",
2021
Long: "Show logs from the emulator. Use --follow to stream in real-time.",
2122
PreRunE: initConfig,
22-
RunE: func(cmd *cobra.Command, args []string) error {
23+
RunE: commandWithTelemetry("logs", tel, func(cmd *cobra.Command, args []string) error {
2324
follow, err := cmd.Flags().GetBool("follow")
2425
if err != nil {
2526
return err
@@ -40,7 +41,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command {
4041
return ui.RunLogs(cmd.Context(), rt, appConfig.Containers, follow, verbose)
4142
}
4243
return container.Logs(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, follow, verbose)
43-
},
44+
}),
4445
}
4546
cmd.Flags().BoolP("follow", "f", false, "Follow log output")
4647
cmd.Flags().BoolP("verbose", "v", false, "Show all log output without filtering")

cmd/root.go

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"time"
89

910
"github.com/localstack/lstk/internal/api"
11+
"github.com/localstack/lstk/internal/auth"
1012
"github.com/localstack/lstk/internal/config"
1113
"github.com/localstack/lstk/internal/container"
1214
"github.com/localstack/lstk/internal/env"
@@ -17,6 +19,7 @@ import (
1719
"github.com/localstack/lstk/internal/ui"
1820
"github.com/localstack/lstk/internal/version"
1921
"github.com/spf13/cobra"
22+
"github.com/spf13/pflag"
2023
)
2124

2225
func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command {
@@ -30,7 +33,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
3033
if err != nil {
3134
return err
3235
}
33-
return runStart(cmd.Context(), rt, cfg, tel, logger)
36+
return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger)
3437
},
3538
}
3639

@@ -50,13 +53,13 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
5053

5154
root.AddCommand(
5255
newStartCmd(cfg, tel, logger),
53-
newStopCmd(cfg),
54-
newLoginCmd(cfg, logger),
55-
newLogoutCmd(cfg, logger),
56-
newStatusCmd(cfg),
57-
newLogsCmd(cfg),
58-
newConfigCmd(),
59-
newUpdateCmd(cfg),
56+
newStopCmd(cfg, tel),
57+
newLoginCmd(cfg, tel, logger),
58+
newLogoutCmd(cfg, tel, logger),
59+
newStatusCmd(cfg, tel),
60+
newLogsCmd(cfg, tel),
61+
newConfigCmd(cfg, tel),
62+
newUpdateCmd(cfg, tel),
6063
)
6164

6265
return root
@@ -74,6 +77,16 @@ func Execute(ctx context.Context) error {
7477
defer cleanup()
7578
logger.Info("lstk %s starting", version.Version())
7679

80+
// Resolve auth token for telemetry: keyring first, then env var.
81+
resolvedToken := cfg.AuthToken
82+
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil {
83+
if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" {
84+
resolvedToken = token
85+
}
86+
}
87+
cfg.AuthToken = resolvedToken
88+
tel.SetAuthToken(resolvedToken)
89+
7790
root := NewRootCmd(cfg, tel, logger)
7891
root.SilenceErrors = true
7992
root.SilenceUsage = true
@@ -87,9 +100,7 @@ func Execute(ctx context.Context) error {
87100
return nil
88101
}
89102

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

94105
appConfig, err := config.Get()
95106
if err != nil {
@@ -105,6 +116,7 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme
105116
Containers: appConfig.Containers,
106117
Env: appConfig.Env,
107118
Logger: logger,
119+
Telemetry: tel,
108120
}
109121

110122
if isInteractiveMode(cfg) {
@@ -113,6 +125,51 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme
113125
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), opts, false)
114126
}
115127

128+
func runStart(ctx context.Context, cmdFlags *pflag.FlagSet, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {
129+
startTime := time.Now()
130+
131+
var flags []string
132+
cmdFlags.Visit(func(f *pflag.Flag) {
133+
flags = append(flags, "--"+f.Name)
134+
})
135+
136+
runErr := startEmulator(ctx, rt, cfg, tel, logger)
137+
138+
exitCode := 0
139+
errorMsg := ""
140+
if runErr != nil {
141+
exitCode = 1
142+
errorMsg = runErr.Error()
143+
}
144+
tel.EmitCommand(ctx, "start", flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)
145+
146+
return runErr
147+
}
148+
149+
// wraps a RunE function so that an lstk_command event is emitted after every invocation
150+
// used for commands that do not emit lstk_lifecycle events (i.e. status, logs, config path, etc)
151+
func commandWithTelemetry(name string, tel *telemetry.Client, fn func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
152+
return func(cmd *cobra.Command, args []string) error {
153+
startTime := time.Now()
154+
runErr := fn(cmd, args)
155+
156+
var flags []string
157+
cmd.Flags().Visit(func(f *pflag.Flag) {
158+
flags = append(flags, "--"+f.Name)
159+
})
160+
161+
exitCode := 0
162+
errorMsg := ""
163+
if runErr != nil {
164+
exitCode = 1
165+
errorMsg = runErr.Error()
166+
}
167+
tel.EmitCommand(cmd.Context(), name, flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)
168+
169+
return runErr
170+
}
171+
}
172+
116173
func isInteractiveMode(cfg *env.Env) bool {
117174
return !cfg.NonInteractive && ui.IsInteractive()
118175
}

cmd/start.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.
1919
if err != nil {
2020
return err
2121
}
22-
return runStart(cmd.Context(), rt, cfg, tel, logger)
22+
return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger)
2323
},
2424
}
2525
}

cmd/status.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,18 @@ 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/spf13/cobra"
1617
)
1718

18-
func newStatusCmd(cfg *env.Env) *cobra.Command {
19+
func newStatusCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
1920
return &cobra.Command{
2021
Use: "status",
2122
Short: "Show emulator status and deployed resources",
2223
Long: "Show the status of a running emulator and its deployed resources",
2324
PreRunE: initConfig,
24-
RunE: func(cmd *cobra.Command, args []string) error {
25+
RunE: commandWithTelemetry("status", tel, func(cmd *cobra.Command, args []string) error {
2526
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
2627
if err != nil {
2728
return err
@@ -38,6 +39,6 @@ func newStatusCmd(cfg *env.Env) *cobra.Command {
3839
return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient)
3940
}
4041
return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient, output.NewPlainSink(os.Stdout))
41-
},
42+
}),
4243
}
4344
}

cmd/stop.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,27 @@ package cmd
33
import (
44
"fmt"
55
"os"
6+
"time"
67

78
"github.com/localstack/lstk/internal/config"
89
"github.com/localstack/lstk/internal/container"
910
"github.com/localstack/lstk/internal/env"
1011
"github.com/localstack/lstk/internal/output"
1112
"github.com/localstack/lstk/internal/runtime"
13+
"github.com/localstack/lstk/internal/telemetry"
1214
"github.com/localstack/lstk/internal/ui"
1315
"github.com/spf13/cobra"
16+
"github.com/spf13/pflag"
1417
)
1518

16-
func newStopCmd(cfg *env.Env) *cobra.Command {
19+
func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command {
1720
return &cobra.Command{
1821
Use: "stop",
1922
Short: "Stop emulator",
2023
Long: "Stop emulator and services",
2124
PreRunE: initConfig,
2225
RunE: func(cmd *cobra.Command, args []string) error {
26+
startTime := time.Now()
2327
rt, err := runtime.NewDockerRuntime(cfg.DockerHost)
2428
if err != nil {
2529
return err
@@ -29,11 +33,32 @@ func newStopCmd(cfg *env.Env) *cobra.Command {
2933
return fmt.Errorf("failed to get config: %w", err)
3034
}
3135

36+
stopOpts := container.StopOptions{
37+
Telemetry: tel,
38+
}
39+
40+
var runErr error
41+
3242
if isInteractiveMode(cfg) {
33-
return ui.RunStop(cmd.Context(), rt, appConfig.Containers)
43+
runErr = ui.RunStop(cmd.Context(), rt, appConfig.Containers, stopOpts)
44+
} else {
45+
runErr = container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, stopOpts)
3446
}
3547

36-
return container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers)
48+
exitCode := 0
49+
errorMsg := ""
50+
if runErr != nil {
51+
exitCode = 1
52+
errorMsg = runErr.Error()
53+
}
54+
55+
var flags []string
56+
cmd.Flags().Visit(func(f *pflag.Flag) {
57+
flags = append(flags, "--"+f.Name)
58+
})
59+
tel.EmitCommand(cmd.Context(), "stop", flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg)
60+
61+
return runErr
3762
},
3863
}
3964
}

0 commit comments

Comments
 (0)