diff --git a/.claude/skills/add-command/SKILL.md b/.claude/skills/add-command/SKILL.md index 0cc80ad3..7d532b2f 100644 --- a/.claude/skills/add-command/SKILL.md +++ b/.claude/skills/add-command/SKILL.md @@ -94,6 +94,14 @@ Create `test/integration/_test.go` with: - Use `cleanup()` and `t.Cleanup(cleanup)` for container state - Use `context.WithTimeout` for all tests +## Telemetry + +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. + +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`. + +In the corresponding integration test, add an assertion that the `lstk_command` event was emitted. + ## Anti-patterns to avoid - Do NOT put business logic in `cmd/` — the command file should be thin wiring only diff --git a/cmd/config.go b/cmd/config.go index 5f6705a5..d2947ef3 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -4,23 +4,25 @@ import ( "fmt" "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/telemetry" "github.com/spf13/cobra" ) -func newConfigCmd() *cobra.Command { +func newConfigCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage configuration", } - cmd.AddCommand(newConfigPathCmd()) + cmd.AddCommand(newConfigPathCmd(cfg, tel)) return cmd } -func newConfigPathCmd() *cobra.Command { +func newConfigPathCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { return &cobra.Command{ Use: "path", Short: "Print the configuration file path", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: commandWithTelemetry("config path", tel, func(cmd *cobra.Command, args []string) error { path, err := cmd.Flags().GetString("config") if err != nil { return err @@ -37,6 +39,6 @@ func newConfigPathCmd() *cobra.Command { _, err = fmt.Fprintln(cmd.OutOrStdout(), configPath) return err - }, + }), } } diff --git a/cmd/login.go b/cmd/login.go index e8577e82..6e7153cc 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -4,25 +4,35 @@ import ( "fmt" "github.com/localstack/lstk/internal/api" + "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/log" + "github.com/localstack/lstk/internal/telemetry" "github.com/localstack/lstk/internal/ui" "github.com/localstack/lstk/internal/version" "github.com/spf13/cobra" ) -func newLoginCmd(cfg *env.Env, logger log.Logger) *cobra.Command { +func newLoginCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { return &cobra.Command{ Use: "login", Short: "Manage login", Long: "Manage login and store credentials in system keyring", PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: commandWithTelemetry("login", tel, func(cmd *cobra.Command, args []string) error { if !isInteractiveMode(cfg) { return fmt.Errorf("login requires an interactive terminal") } platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger) - return ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger) - }, + if err := ui.RunLogin(cmd.Context(), version.Version(), platformClient, cfg.AuthToken, cfg.ForceFileKeyring, cfg.WebAppURL, logger); err != nil { + return err + } + if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil { + if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" { + tel.SetAuthToken(token) + } + } + return nil + }), } } diff --git a/cmd/logout.go b/cmd/logout.go index d7fc23ce..4c1969b5 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -13,16 +13,17 @@ import ( "github.com/localstack/lstk/internal/log" "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/spf13/cobra" ) -func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command { +func newLogoutCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.Command { return &cobra.Command{ Use: "logout", Short: "Remove stored authentication credentials", PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: commandWithTelemetry("logout", tel, func(cmd *cobra.Command, args []string) error { platformClient := api.NewPlatformClient(cfg.APIEndpoint, logger) appConfig, err := config.Get() if err != nil { @@ -56,6 +57,6 @@ func newLogoutCmd(cfg *env.Env, logger log.Logger) *cobra.Command { } } return nil - }, + }), } } diff --git a/cmd/logs.go b/cmd/logs.go index b056a5f5..7d490ce4 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -9,16 +9,17 @@ 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/spf13/cobra" ) -func newLogsCmd(cfg *env.Env) *cobra.Command { +func newLogsCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { cmd := &cobra.Command{ Use: "logs", Short: "Show emulator logs", Long: "Show logs from the emulator. Use --follow to stream in real-time.", PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: commandWithTelemetry("logs", tel, func(cmd *cobra.Command, args []string) error { follow, err := cmd.Flags().GetBool("follow") if err != nil { return err @@ -32,7 +33,7 @@ func newLogsCmd(cfg *env.Env) *cobra.Command { return fmt.Errorf("failed to get config: %w", err) } return container.Logs(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, follow) - }, + }), } cmd.Flags().BoolP("follow", "f", false, "Follow log output") return cmd diff --git a/cmd/root.go b/cmd/root.go index 62bc62fe..9c88bc0e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,8 +5,10 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/localstack/lstk/internal/api" + "github.com/localstack/lstk/internal/auth" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "github.com/localstack/lstk/internal/env" @@ -17,6 +19,7 @@ import ( "github.com/localstack/lstk/internal/ui" "github.com/localstack/lstk/internal/version" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) 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 if err != nil { return err } - return runStart(cmd.Context(), rt, cfg, tel, logger) + return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger) }, } @@ -50,13 +53,13 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C root.AddCommand( newStartCmd(cfg, tel, logger), - newStopCmd(cfg), - newLoginCmd(cfg, logger), - newLogoutCmd(cfg, logger), - newStatusCmd(cfg), - newLogsCmd(cfg), - newConfigCmd(), - newUpdateCmd(cfg), + newStopCmd(cfg, tel), + newLoginCmd(cfg, tel, logger), + newLogoutCmd(cfg, tel, logger), + newStatusCmd(cfg, tel), + newLogsCmd(cfg, tel), + newConfigCmd(cfg, tel), + newUpdateCmd(cfg, tel), ) return root @@ -74,6 +77,16 @@ func Execute(ctx context.Context) error { defer cleanup() logger.Info("lstk %s starting", version.Version()) + // Resolve auth token for telemetry: keyring first, then env var. + resolvedToken := cfg.AuthToken + if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil { + if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" { + resolvedToken = token + } + } + cfg.AuthToken = resolvedToken + tel.SetAuthToken(resolvedToken) + root := NewRootCmd(cfg, tel, logger) root.SilenceErrors = true root.SilenceUsage = true @@ -87,9 +100,7 @@ func Execute(ctx context.Context) error { return nil } -func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) 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{}}) +func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error { appConfig, err := config.Get() if err != nil { @@ -105,6 +116,7 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme Containers: appConfig.Containers, Env: appConfig.Env, Logger: logger, + Telemetry: tel, } if isInteractiveMode(cfg) { @@ -113,6 +125,51 @@ func runStart(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *teleme return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), opts, false) } +func runStart(ctx context.Context, cmdFlags *pflag.FlagSet, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error { + startTime := time.Now() + + var flags []string + cmdFlags.Visit(func(f *pflag.Flag) { + flags = append(flags, "--"+f.Name) + }) + + runErr := startEmulator(ctx, rt, cfg, tel, logger) + + exitCode := 0 + errorMsg := "" + if runErr != nil { + exitCode = 1 + errorMsg = runErr.Error() + } + tel.EmitCommand(ctx, "start", flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg) + + return runErr +} + +// wraps a RunE function so that an lstk_command event is emitted after every invocation +// used for commands that do not emit lstk_lifecycle events (i.e. status, logs, config path, etc) +func commandWithTelemetry(name string, tel *telemetry.Client, fn func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + startTime := time.Now() + runErr := fn(cmd, args) + + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, "--"+f.Name) + }) + + exitCode := 0 + errorMsg := "" + if runErr != nil { + exitCode = 1 + errorMsg = runErr.Error() + } + tel.EmitCommand(cmd.Context(), name, flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg) + + return runErr + } +} + func isInteractiveMode(cfg *env.Env) bool { return !cfg.NonInteractive && ui.IsInteractive() } diff --git a/cmd/start.go b/cmd/start.go index c0379b69..b82a1bb3 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -19,7 +19,7 @@ func newStartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra. if err != nil { return err } - return runStart(cmd.Context(), rt, cfg, tel, logger) + return runStart(cmd.Context(), cmd.Flags(), rt, cfg, tel, logger) }, } } diff --git a/cmd/status.go b/cmd/status.go index a87949b9..a89a5904 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -11,17 +11,18 @@ 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/spf13/cobra" ) -func newStatusCmd(cfg *env.Env) *cobra.Command { +func newStatusCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show emulator status and deployed resources", Long: "Show the status of a running emulator and its deployed resources", PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: commandWithTelemetry("status", tel, func(cmd *cobra.Command, args []string) error { rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -38,6 +39,6 @@ func newStatusCmd(cfg *env.Env) *cobra.Command { return ui.RunStatus(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient) } return container.Status(cmd.Context(), rt, appCfg.Containers, cfg.LocalStackHost, awsClient, output.NewPlainSink(os.Stdout)) - }, + }), } } diff --git a/cmd/stop.go b/cmd/stop.go index 3f517ddc..348a1e85 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -3,23 +3,27 @@ package cmd import ( "fmt" "os" + "time" "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/container" "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/spf13/cobra" + "github.com/spf13/pflag" ) -func newStopCmd(cfg *env.Env) *cobra.Command { +func newStopCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { return &cobra.Command{ Use: "stop", Short: "Stop emulator", Long: "Stop emulator and services", PreRunE: initConfig, RunE: func(cmd *cobra.Command, args []string) error { + startTime := time.Now() rt, err := runtime.NewDockerRuntime(cfg.DockerHost) if err != nil { return err @@ -29,11 +33,32 @@ func newStopCmd(cfg *env.Env) *cobra.Command { return fmt.Errorf("failed to get config: %w", err) } + stopOpts := container.StopOptions{ + Telemetry: tel, + } + + var runErr error + if isInteractiveMode(cfg) { - return ui.RunStop(cmd.Context(), rt, appConfig.Containers) + runErr = ui.RunStop(cmd.Context(), rt, appConfig.Containers, stopOpts) + } else { + runErr = container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers, stopOpts) } - return container.Stop(cmd.Context(), rt, output.NewPlainSink(os.Stdout), appConfig.Containers) + exitCode := 0 + errorMsg := "" + if runErr != nil { + exitCode = 1 + errorMsg = runErr.Error() + } + + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, "--"+f.Name) + }) + tel.EmitCommand(cmd.Context(), "stop", flags, time.Since(startTime).Milliseconds(), exitCode, errorMsg) + + return runErr }, } } diff --git a/cmd/update.go b/cmd/update.go index 7cdabf25..e74af96c 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -5,12 +5,13 @@ import ( "github.com/localstack/lstk/internal/env" "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/telemetry" "github.com/localstack/lstk/internal/ui" "github.com/localstack/lstk/internal/update" "github.com/spf13/cobra" ) -func newUpdateCmd(cfg *env.Env) *cobra.Command { +func newUpdateCmd(cfg *env.Env, tel *telemetry.Client) *cobra.Command { var checkOnly bool cmd := &cobra.Command{ @@ -18,12 +19,12 @@ func newUpdateCmd(cfg *env.Env) *cobra.Command { Short: "Update lstk to the latest version", Long: "Check for and apply updates to the lstk CLI. Respects the original installation method (Homebrew, npm, or direct binary).", PreRunE: initConfig, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: commandWithTelemetry("update", tel, func(cmd *cobra.Command, args []string) error { if isInteractiveMode(cfg) { return ui.RunUpdate(cmd.Context(), checkOnly, cfg.GitHubToken) } return update.Update(cmd.Context(), output.NewPlainSink(os.Stdout), checkOnly, cfg.GitHubToken) - }, + }), } cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check for updates without applying them") diff --git a/internal/container/info.go b/internal/container/info.go new file mode 100644 index 00000000..070511f5 --- /dev/null +++ b/internal/container/info.go @@ -0,0 +1,33 @@ +package container + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/localstack/lstk/internal/telemetry" +) + +func fetchLocalStackInfo(ctx context.Context, port string) (*telemetry.LocalStackInfo, error) { + url := fmt.Sprintf("http://localhost:%s/_localstack/info", port) + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + var info telemetry.LocalStackInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + return &info, nil +} diff --git a/internal/container/start.go b/internal/container/start.go index ce2ef024..c4f99262 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -23,6 +23,7 @@ import ( "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ports" "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/telemetry" ) type postStartSetupFunc func(ctx context.Context, sink output.Sink, interactive bool, resolvedHost string) error @@ -37,6 +38,35 @@ type StartOptions struct { Containers []config.ContainerConfig Env map[string]map[string]string Logger log.Logger + Telemetry *telemetry.Client +} + +func emitEmulatorStartError(ctx context.Context, tel *telemetry.Client, c runtime.ContainerConfig, errorCode, errorMsg string) { + if tel == nil { + return + } + tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ + EventType: telemetry.LifecycleStartError, + Emulator: c.EmulatorType, + Image: c.Image, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + }) +} + +func emitEmulatorStartSuccess(ctx context.Context, tel *telemetry.Client, c runtime.ContainerConfig, containerID string, durationMS int64, pulled bool, info *telemetry.LocalStackInfo) { + if tel == nil { + return + } + tel.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ + EventType: telemetry.LifecycleStartSuccess, + Emulator: c.EmulatorType, + Image: c.Image, + ContainerID: containerID, + DurationMS: durationMS, + Pulled: pulled, + LocalStackInfo: info, + }) } func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, interactive bool) error { @@ -56,10 +86,16 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start return err } + if opts.Telemetry != nil { + opts.Telemetry.SetAuthToken(token) + } + if hasDuplicateContainerTypes(opts.Containers) { output.EmitWarning(sink, "Multiple emulators of the same type are defined in your config; this setup is not supported yet") } + tel := opts.Telemetry + containers := make([]runtime.ContainerConfig, len(opts.Containers)) for i, c := range opts.Containers { image, err := c.Image() @@ -110,6 +146,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start containers[i] = runtime.ContainerConfig{ Image: image, Name: containerName, + EmulatorType: string(c.Type), Port: c.Port, ContainerPort: containerPort, HealthPath: healthPath, @@ -121,7 +158,7 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } } - containers, err = selectContainersToStart(ctx, rt, sink, containers) + containers, err = selectContainersToStart(ctx, rt, sink, tel, containers) if err != nil { return err } @@ -131,15 +168,16 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start // TODO validate license for tag "latest" without resolving the actual image version, // and avoid pulling all images first - if err := pullImages(ctx, rt, sink, containers); err != nil { + pulled, err := pullImages(ctx, rt, sink, tel, containers) + if err != nil { return err } - if err := validateLicenses(ctx, rt, sink, opts, containers, token); err != nil { + if err := validateLicenses(ctx, rt, sink, opts, tel, containers, token); err != nil { return err } - if err := startContainers(ctx, rt, sink, containers); err != nil { + if err := startContainers(ctx, rt, sink, tel, containers, pulled); err != nil { return err } @@ -188,11 +226,12 @@ func emitPostStartPointers(sink output.Sink, resolvedHost, webAppURL string) { output.EmitSecondary(sink, tips[rand.IntN(len(tips))]) } -func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error { +func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig) (map[string]bool, error) { + pulled := make(map[string]bool, len(containers)) for _, c := range containers { // Remove any existing stopped container with the same name if err := rt.Remove(ctx, c.Name); err != nil && !errdefs.IsNotFound(err) { - return fmt.Errorf("failed to remove existing container %s: %w", c.Name, err) + return nil, fmt.Errorf("failed to remove existing container %s: %w", c.Name, err) } output.EmitSpinnerStart(sink, fmt.Sprintf("Pulling %s", c.Image)) @@ -209,43 +248,51 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, conta Title: fmt.Sprintf("Failed to pull %s", c.Image), Summary: err.Error(), }) - return output.NewSilentError(fmt.Errorf("failed to pull image %s: %w", c.Image, err)) + emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodeImagePullFailed, err.Error()) + return nil, output.NewSilentError(fmt.Errorf("failed to pull image %s: %w", c.Image, err)) } output.EmitSpinnerStop(sink) output.EmitSuccess(sink, fmt.Sprintf("Pulled %s", c.Image)) + pulled[c.Name] = true } - return nil + return pulled, nil } -func validateLicenses(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token string) error { +func validateLicenses(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, tel *telemetry.Client, containers []runtime.ContainerConfig, token string) error { for _, c := range containers { - if err := validateLicense(ctx, rt, sink, opts, c, token); err != nil { + if err := validateLicense(ctx, rt, sink, opts, tel, c, token); err != nil { return err } } return nil } -func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) error { +func startContainers(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig, pulled map[string]bool) error { for _, c := range containers { + startTime := time.Now() output.EmitStatus(sink, "starting", c.Name, "") containerID, err := rt.Start(ctx, c) if err != nil { + emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodeStartFailed, err.Error()) return fmt.Errorf("failed to start LocalStack: %w", err) } output.EmitStatus(sink, "waiting", c.Name, "") healthURL := fmt.Sprintf("http://localhost:%s%s", c.Port, c.HealthPath) if err := awaitStartup(ctx, rt, sink, containerID, "LocalStack", healthURL); err != nil { + emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodeStartFailed, err.Error()) return err } output.EmitStatus(sink, "ready", c.Name, fmt.Sprintf("containerId: %s", containerID[:12])) + + lsInfo, _ := fetchLocalStackInfo(ctx, c.Port) + emitEmulatorStartSuccess(ctx, tel, c, containerID[:12], time.Since(startTime).Milliseconds(), pulled[c.Name], lsInfo) } return nil } -func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig) ([]runtime.ContainerConfig, error) { +func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *telemetry.Client, containers []runtime.ContainerConfig) ([]runtime.ContainerConfig, error) { var filtered []runtime.ContainerConfig for _, c := range containers { running, err := rt.IsRunning(ctx, c.Name) @@ -258,6 +305,7 @@ func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink outpu } if err := ports.CheckAvailable(c.Port); err != nil { emitPortInUseError(sink, c.Port) + emitEmulatorStartError(ctx, tel, c, telemetry.ErrCodePortConflict, err.Error()) return nil, output.NewSilentError(err) } filtered = append(filtered, c) @@ -280,7 +328,7 @@ func emitPortInUseError(sink output.Sink, port string) { }) } -func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containerConfig runtime.ContainerConfig, token string) error { +func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, tel *telemetry.Client, containerConfig runtime.ContainerConfig, token string) error { version := containerConfig.Tag if version == "" || version == "latest" { actualVersion, err := rt.GetImageVersion(ctx, containerConfig.Image) @@ -313,6 +361,7 @@ func validateLicense(ctx context.Context, rt runtime.Runtime, sink output.Sink, if errors.As(err, &licErr) && licErr.Detail != "" { opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail) } + emitEmulatorStartError(ctx, tel, containerConfig, telemetry.ErrCodeLicenseInvalid, err.Error()) return fmt.Errorf("license validation failed for %s:%s: %w", containerConfig.ProductName, version, err) } diff --git a/internal/container/stop.go b/internal/container/stop.go index 9f867d2f..a16330d8 100644 --- a/internal/container/stop.go +++ b/internal/container/stop.go @@ -8,11 +8,16 @@ import ( "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/telemetry" ) -func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig) error { - const stopTimeout = 30 * time.Second +// StopOptions carries optional telemetry context for the stop command. +type StopOptions struct { + Telemetry *telemetry.Client +} +func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []config.ContainerConfig, opts StopOptions) error { + const stopTimeout = 30 * time.Second for _, c := range containers { name := c.Name() @@ -25,6 +30,12 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers if !running { return fmt.Errorf("LocalStack is not running") } + + // Fetch localstack info before stopping so it can be included in telemetry. + lsInfo, _ := fetchLocalStackInfo(ctx, c.Port) + + stopStart := time.Now() + output.EmitSpinnerStart(sink, "Stopping LocalStack...") stopCtx, stopCancel := context.WithTimeout(ctx, stopTimeout) if err := rt.Stop(stopCtx, name); err != nil { @@ -35,6 +46,15 @@ func Stop(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers stopCancel() output.EmitSpinnerStop(sink) output.EmitSuccess(sink, "LocalStack stopped") + + if opts.Telemetry != nil { + opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ + EventType: telemetry.LifecycleStop, + Emulator: string(c.Type), + DurationMS: time.Since(stopStart).Milliseconds(), + LocalStackInfo: lsInfo, + }) + } } return nil diff --git a/internal/container/telemetry_test.go b/internal/container/telemetry_test.go new file mode 100644 index 00000000..2d721f53 --- /dev/null +++ b/internal/container/telemetry_test.go @@ -0,0 +1,116 @@ +package container + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/localstack/lstk/internal/config" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/runtime" + "github.com/localstack/lstk/internal/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// newCapturingTelClient starts an httptest server that captures emitted events. +func newCapturingTelClient(t *testing.T) (*telemetry.Client, <-chan map[string]any) { + t.Helper() + ch := make(chan map[string]any, 8) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var req struct { + Events []map[string]any `json:"events"` + } + if assert.NoError(t, json.Unmarshal(body, &req)) && len(req.Events) > 0 { + ch <- req.Events[0] + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return telemetry.New(srv.URL, false), ch +} + +func TestStop_EmitsLifecycleStopEvent(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + mockRT.EXPECT().Stop(gomock.Any(), "localstack-aws").Return(nil) + + tel, ch := newCapturingTelClient(t) + + containers := []config.ContainerConfig{{Type: config.EmulatorAWS, Port: "4566"}} + sink := output.NewPlainSink(io.Discard) + + tel.SetAuthToken("ls-abc") + err := Stop(context.Background(), mockRT, sink, containers, StopOptions{ + Telemetry: tel, + }) + require.NoError(t, err) + tel.Close() + + var got map[string]any + select { + case got = <-ch: + default: + t.Fatal("no lifecycle event received") + } + + assert.Equal(t, "lstk_lifecycle", got["name"]) + payload := got["payload"].(map[string]any) + assert.Equal(t, telemetry.LifecycleStop, payload["event_type"]) + assert.Equal(t, "aws", payload["emulator"]) + + env := payload["environment"].(map[string]any) + assert.Equal(t, "ls-abc", env["auth_token_id"]) +} + +func TestStop_SkipsTelemetryWhenNil(t *testing.T) { + ctrl := gomock.NewController(t) + mockRT := runtime.NewMockRuntime(ctrl) + mockRT.EXPECT().IsRunning(gomock.Any(), "localstack-aws").Return(true, nil) + mockRT.EXPECT().Stop(gomock.Any(), "localstack-aws").Return(nil) + + containers := []config.ContainerConfig{{Type: config.EmulatorAWS, Port: "4566"}} + sink := output.NewPlainSink(io.Discard) + + err := Stop(context.Background(), mockRT, sink, containers, StopOptions{}) + require.NoError(t, err) +} + +func TestEmitEmulatorStartError_IsNoOpWhenTelNil(t *testing.T) { + c := runtime.ContainerConfig{EmulatorType: "aws", Image: "localstack/localstack-pro:latest"} + // Must not panic. + emitEmulatorStartError(context.Background(), nil, c, telemetry.ErrCodePortConflict, "port 4566 in use") +} + +func TestEmitEmulatorStartError_SendsLifecycleEvent(t *testing.T) { + tel, ch := newCapturingTelClient(t) + tel.SetAuthToken("ls-xyz") + + c := runtime.ContainerConfig{ + EmulatorType: "aws", + Image: "localstack/localstack-pro:latest", + } + emitEmulatorStartError(context.Background(), tel, c, telemetry.ErrCodePortConflict, "port 4566 already in use") + tel.Close() + + var got map[string]any + select { + case got = <-ch: + default: + t.Fatal("no event received") + } + + assert.Equal(t, "lstk_lifecycle", got["name"]) + payload := got["payload"].(map[string]any) + assert.Equal(t, telemetry.LifecycleStartError, payload["event_type"]) + assert.Equal(t, "aws", payload["emulator"]) + assert.Equal(t, telemetry.ErrCodePortConflict, payload["error_code"]) + assert.Equal(t, "port 4566 already in use", payload["error_msg"]) +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 61204b10..d397d79e 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -25,6 +25,7 @@ type PortMapping struct { type ContainerConfig struct { Image string Name string + EmulatorType string // e.g., "aws", "snowflake" — used for telemetry Port string ContainerPort string // internal port the emulator listens on inside the container (e.g. "4566/tcp") HealthPath string diff --git a/internal/telemetry/client.go b/internal/telemetry/client.go index f4766316..7af8be8a 100644 --- a/internal/telemetry/client.go +++ b/internal/telemetry/client.go @@ -23,6 +23,7 @@ type Client struct { enabled bool sessionID string machineID string + authToken string httpClient *http.Client endpoint string @@ -32,6 +33,12 @@ type Client struct { closeOnce sync.Once } +// SetAuthToken stores the resolved auth token for inclusion in telemetry events. +// Call this once the token is known (e.g. after keyring resolution or interactive login). +func (c *Client) SetAuthToken(token string) { + c.authToken = token +} + func New(endpoint string, disabled bool) *Client { if disabled { return &Client{enabled: false} @@ -74,11 +81,10 @@ func (c *Client) Emit(ctx context.Context, name string, payload map[string]any) return } - enriched := make(map[string]any, len(payload)+6) + enriched := make(map[string]any, len(payload)+5) 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") @@ -95,7 +101,6 @@ func (c *Client) Emit(ctx context.Context, name string, payload map[string]any) }, Payload: enriched, } - select { case c.events <- body: default: diff --git a/internal/telemetry/client_test.go b/internal/telemetry/client_test.go index 4566219e..3effd731 100644 --- a/internal/telemetry/client_test.go +++ b/internal/telemetry/client_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/localstack/lstk/internal/version" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -65,13 +64,12 @@ func TestTrack_SendsCorrectPayloadAndHeaders(t *testing.T) { assert.Equal(t, c.sessionID, metadata["session_id"]) _, err := time.Parse("2006-01-02 15:04:05.000000", metadata["client_time"].(string)) assert.NoError(t, err, "client_time should match expected format") - assert.Nil(t, metadata["version"], "version should be in payload, not metadata") - assert.Nil(t, metadata["machine_id"], "machine_id should be in payload, not metadata") + assert.Nil(t, metadata["version"], "version should not be in metadata") + assert.Nil(t, metadata["machine_id"], "machine_id should not be in metadata") payload, ok := got.event["payload"].(map[string]any) require.True(t, ok, "payload should be an object") assert.Equal(t, "lstk start", payload["cmd"]) - assert.Equal(t, version.Version(), payload["version"]) assert.Equal(t, runtime.GOOS, payload["os"]) assert.Equal(t, runtime.GOARCH, payload["arch"]) _, isCI := os.LookupEnv("CI") diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go new file mode 100644 index 00000000..0344b0ea --- /dev/null +++ b/internal/telemetry/events.go @@ -0,0 +1,125 @@ +package telemetry + +import ( + "context" + "encoding/json" + "runtime" + + "github.com/localstack/lstk/internal/version" +) + +// Environment is the common environment block included in all telemetry events. +type Environment struct { + LstkVersion string `json:"lstk_version"` + AuthTokenID string `json:"auth_token_id,omitempty"` + MachineID string `json:"machine_id,omitempty"` + OS string `json:"os"` + Arch string `json:"arch"` +} + +// LocalStackInfo mirrors the /_localstack/info response. +type LocalStackInfo struct { + Version string `json:"version"` + Edition string `json:"edition"` + IsLicenseActivated bool `json:"is_license_activated"` + SessionID string `json:"session_id"` + MachineID string `json:"machine_id"` + System string `json:"system"` + IsDocker bool `json:"is_docker"` + ServerTimeUTC string `json:"server_time_utc"` + Uptime int `json:"uptime"` +} + +// CommandEvent is the payload for an lstk_command telemetry event. +type CommandEvent struct { + Environment Environment `json:"environment"` + Parameters CommandParameters `json:"parameters"` + Result CommandResult `json:"result"` +} + +// CommandParameters holds the command name and set flags. +type CommandParameters struct { + Command string `json:"command"` + Flags []string `json:"flags"` +} + +// CommandResult holds the outcome of a command invocation. +type CommandResult struct { + DurationMS int64 `json:"duration_ms"` + ExitCode int `json:"exit_code"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +// LifecycleEvent is the payload for an lstk_lifecycle telemetry event. +type LifecycleEvent struct { + EventType string `json:"event_type"` + Environment Environment `json:"environment"` + Emulator string `json:"emulator"` + Image string `json:"image,omitempty"` + ContainerID string `json:"container_id,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Pulled bool `json:"pulled,omitempty"` + LocalStackInfo *LocalStackInfo `json:"localstack_info,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +// Lifecycle event type constants. +const ( + LifecycleStartSuccess = "start_success" + LifecycleStop = "stop" + LifecycleStartError = "start_error" +) + +// Error codes for start_error lifecycle events. +const ( + ErrCodePortConflict = "port_conflict" + ErrCodeImagePullFailed = "image_pull_failed" + ErrCodeLicenseInvalid = "license_invalid" + ErrCodeStartFailed = "start_failed" +) + +// ToMap converts a telemetry event struct to a map[string]any for use with Emit. +func ToMap(v any) map[string]any { + b, _ := json.Marshal(v) + m := map[string]any{} + _ = json.Unmarshal(b, &m) + return m +} + +// GetEnvironment returns the common environment payload for telemetry events, +// using the auth token set via SetAuthToken. +func (c *Client) GetEnvironment() Environment { + env := Environment{ + LstkVersion: version.Version(), + AuthTokenID: c.authToken, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + if c.machineID != "" { + env.MachineID = c.machineID + } + return env +} + +// EmitCommand emits an lstk_command telemetry event. The Environment block is +// populated automatically from the client state. +func (c *Client) EmitCommand(ctx context.Context, command string, flags []string, durationMS int64, exitCode int, errorMsg string) { + c.Emit(ctx, "lstk_command", ToMap(CommandEvent{ + Environment: c.GetEnvironment(), + Parameters: CommandParameters{Command: command, Flags: flags}, + Result: CommandResult{ + DurationMS: durationMS, + ExitCode: exitCode, + ErrorMsg: errorMsg, + }, + })) +} + +// EmitEmulatorLifecycleEvent emits an lstk_lifecycle telemetry event. The +// Environment field is populated automatically from the client state; any +// value set by the caller is overwritten. +func (c *Client) EmitEmulatorLifecycleEvent(ctx context.Context, event LifecycleEvent) { + event.Environment = c.GetEnvironment() + c.Emit(ctx, "lstk_lifecycle", ToMap(event)) +} diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go new file mode 100644 index 00000000..ed7e7720 --- /dev/null +++ b/internal/telemetry/events_test.go @@ -0,0 +1,129 @@ +package telemetry + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "testing" + "time" + + "github.com/localstack/lstk/internal/version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureEvents(t *testing.T) (*Client, <-chan map[string]any) { + t.Helper() + ch := make(chan map[string]any, 8) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var req struct { + Events []map[string]any `json:"events"` + } + if assert.NoError(t, json.Unmarshal(body, &req)) && len(req.Events) > 0 { + ch <- req.Events[0] + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return New(srv.URL, false), ch +} + +func drainEvent(t *testing.T, tel *Client, ch <-chan map[string]any) map[string]any { + t.Helper() + tel.Close() + select { + case ev := <-ch: + return ev + default: + t.Fatal("no telemetry event received") + return nil + } +} + +func TestGetEnvironment_PopulatesAllFields(t *testing.T) { + c := New("http://localhost", false) + c.SetAuthToken("ls-abc123") + env := c.GetEnvironment() + + assert.Equal(t, version.Version(), env.LstkVersion) + assert.Equal(t, "ls-abc123", env.AuthTokenID) + assert.Equal(t, runtime.GOOS, env.OS) + assert.Equal(t, runtime.GOARCH, env.Arch) + assert.NotEmpty(t, env.MachineID) +} + +func TestGetEnvironment_OmitsAuthTokenWhenEmpty(t *testing.T) { + c := New("http://localhost", false) + env := c.GetEnvironment() + assert.Empty(t, env.AuthTokenID) +} + +func TestEmitCommand_SendsCorrectEventNameAndStructure(t *testing.T) { + tel, ch := captureEvents(t) + + tel.SetAuthToken("ls-token") + tel.EmitCommand(context.Background(), "start", []string{"--non-interactive"}, 1200, 0, "") + + got := drainEvent(t, tel, ch) + + assert.Equal(t, "lstk_command", got["name"]) + + metadata, ok := got["metadata"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, metadata["session_id"]) + _, err := time.Parse("2006-01-02 15:04:05.000000", metadata["client_time"].(string)) + assert.NoError(t, err) + + payload, ok := got["payload"].(map[string]any) + require.True(t, ok) + + env, ok := payload["environment"].(map[string]any) + require.True(t, ok) + assert.Equal(t, version.Version(), env["lstk_version"]) + assert.Equal(t, "ls-token", env["auth_token_id"]) + + params, ok := payload["parameters"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "start", params["command"]) + assert.Equal(t, []any{"--non-interactive"}, params["flags"]) + + result, ok := payload["result"].(map[string]any) + require.True(t, ok) + assert.InDelta(t, 1200, result["duration_ms"], 1) + assert.InDelta(t, 0, result["exit_code"], 0) +} + +func TestEmitCommand_IncludesErrorMsgOnFailure(t *testing.T) { + tel, ch := captureEvents(t) + + tel.EmitCommand(context.Background(), "start", nil, 50, 1, "port 4566 already in use") + + got := drainEvent(t, tel, ch) + payload := got["payload"].(map[string]any) + result := payload["result"].(map[string]any) + assert.Equal(t, "port 4566 already in use", result["error_msg"]) + assert.InDelta(t, 1, result["exit_code"], 0) +} + +func TestEmitCommand_IsNoOpWhenDisabled(t *testing.T) { + received := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- struct{}{} + })) + defer srv.Close() + + tel := New(srv.URL, true) // disabled + tel.EmitCommand(context.Background(), "start", nil, 0, 0, "") + tel.Close() + + select { + case <-received: + t.Fatal("disabled client should not send events") + default: + } +} diff --git a/internal/ui/run_stop.go b/internal/ui/run_stop.go index f24f3cc9..7108dd57 100644 --- a/internal/ui/run_stop.go +++ b/internal/ui/run_stop.go @@ -12,7 +12,7 @@ import ( "github.com/localstack/lstk/internal/runtime" ) -func RunStop(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig) error { +func RunStop(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, opts container.StopOptions) error { ctx, cancel := context.WithCancel(parentCtx) defer cancel() @@ -21,7 +21,7 @@ func RunStop(parentCtx context.Context, rt runtime.Runtime, containers []config. runErrCh := make(chan error, 1) go func() { - err := container.Stop(ctx, rt, output.NewTUISink(programSender{p: p}), containers) + err := container.Stop(ctx, rt, output.NewTUISink(programSender{p: p}), containers, opts) runErrCh <- err if err != nil && !errors.Is(err, context.Canceled) { p.Send(runErrMsg{err: err}) diff --git a/test/integration/config_test.go b/test/integration/config_test.go index 6fba3416..4629026d 100644 --- a/test/integration/config_test.go +++ b/test/integration/config_test.go @@ -133,12 +133,14 @@ func TestConfigPathCommand(t *testing.T) { xdgConfigFile := filepath.Join(tmpHome, ".config", "lstk", "config.toml") writeConfigFile(t, xdgConfigFile) - e := testEnvWithHome(tmpHome, filepath.Join(tmpHome, "xdg-config-home")) + analyticsSrv, events := mockAnalyticsServer(t) + e := env.Environ(testEnvWithHome(tmpHome, filepath.Join(tmpHome, "xdg-config-home"))).With(env.AnalyticsEndpoint, analyticsSrv.URL) stdout, stderr, err := runLstk(t, testContext(t), workDir, e, "config", "path") require.NoError(t, err, stderr) requireExitCode(t, 0, err) assertSamePath(t, xdgConfigFile, stdout) + assertCommandTelemetry(t, events, "config path", 0) } func TestConfigPathCommandDoesNotCreateConfig(t *testing.T) { diff --git a/test/integration/login_test.go b/test/integration/login_test.go index cb8f082f..c912526d 100644 --- a/test/integration/login_test.go +++ b/test/integration/login_test.go @@ -89,11 +89,13 @@ func TestDeviceFlowSuccess(t *testing.T) { mockServer := createMockAPIServer(t, licenseToken, true) defer mockServer.Close() + analyticsSrv, events := mockAnalyticsServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, binaryPath(), "login") - cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL) + cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL) ptmx, err := pty.Start(cmd) require.NoError(t, err, "failed to start command in PTY") @@ -133,6 +135,7 @@ func TestDeviceFlowSuccess(t *testing.T) { storedToken, err := GetAuthTokenFromKeyring() require.NoError(t, err) assert.Equal(t, licenseToken, storedToken) + assertCommandTelemetry(t, events, "login", 0) } func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { @@ -146,11 +149,13 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { mockServer := createMockAPIServer(t, "", false) defer mockServer.Close() + analyticsSrv, events := mockAnalyticsServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, binaryPath(), "login") - cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL) + cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL) ptmx, err := pty.Start(cmd) require.NoError(t, err, "failed to start command in PTY") @@ -187,4 +192,5 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { // Verify no token was stored in keyring _, err = GetAuthTokenFromKeyring() assert.Error(t, err, "no token should be stored when login fails") + assertCommandTelemetry(t, events, "login", 1) } diff --git a/test/integration/logout_test.go b/test/integration/logout_test.go index 6c0d8766..e937d53e 100644 --- a/test/integration/logout_test.go +++ b/test/integration/logout_test.go @@ -17,22 +17,26 @@ func TestLogoutCommandRemovesToken(t *testing.T) { err := SetAuthTokenInKeyring("test-token") require.NoError(t, err, "failed to store token in keyring") - stdout, stderr, err := runLstk(t, testContext(t), "", nil, "logout") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, testContext(t), "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "logout") require.NoError(t, err, "lstk logout failed: %s", stderr) requireExitCode(t, 0, err) assert.Contains(t, stdout, "Logged out successfully") _, err = GetAuthTokenFromKeyring() assert.Error(t, err, "token should be removed from keyring") + assertCommandTelemetry(t, events, "logout", 0) } func TestLogoutCommandSucceedsWhenNoToken(t *testing.T) { _ = DeleteAuthTokenFromKeyring() - stdout, stderr, err := runLstk(t, testContext(t), "", env.Without(env.AuthToken), "logout") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, testContext(t), "", env.Without(env.AuthToken).With(env.AnalyticsEndpoint, analyticsSrv.URL), "logout") require.NoError(t, err, "lstk logout should succeed even with no token: %s", stderr) requireExitCode(t, 0, err) assert.Contains(t, stdout, "Not currently logged in") + assertCommandTelemetry(t, events, "logout", 0) } func TestLogoutCommandWithEnvVarToken(t *testing.T) { diff --git a/test/integration/logs_test.go b/test/integration/logs_test.go index 70ac1b9a..81db2ba5 100644 --- a/test/integration/logs_test.go +++ b/test/integration/logs_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/docker/docker/api/types/container" + "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,9 +21,11 @@ func TestLogsExitsByDefault(t *testing.T) { ctx := testContext(t) startTestContainer(t, ctx) - _, _, err := runLstk(t, ctx, "", nil, "logs") + analyticsSrv, events := mockAnalyticsServer(t) + _, _, err := runLstk(t, ctx, "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "logs") require.NoError(t, err, "lstk logs should exit cleanly when container is running") requireExitCode(t, 0, err) + assertCommandTelemetry(t, events, "logs", 0) } func TestLogsCommandFailsWhenNotRunning(t *testing.T) { @@ -30,10 +33,12 @@ func TestLogsCommandFailsWhenNotRunning(t *testing.T) { cleanup() t.Cleanup(cleanup) - _, stderr, err := runLstk(t, testContext(t), "", nil, "logs", "--follow") + analyticsSrv, events := mockAnalyticsServer(t) + _, stderr, err := runLstk(t, testContext(t), "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "logs", "--follow") require.Error(t, err, "expected lstk logs --follow to fail when container not running") requireExitCode(t, 1, err) assert.Contains(t, stderr, "emulator is not running") + assertCommandTelemetry(t, events, "logs", 1) } func TestLogsFollowStreamsOutput(t *testing.T) { diff --git a/test/integration/start_test.go b/test/integration/start_test.go index ef59424d..a3861ea8 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -83,10 +83,12 @@ func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) { ctx := testContext(t) startTestContainer(t, ctx) - stdout, stderr, err := runLstk(t, ctx, "", env.With(env.AuthToken, "fake-token"), "start") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.AuthToken, "fake-token").With(env.AnalyticsEndpoint, analyticsSrv.URL), "start") require.NoError(t, err, "lstk start should succeed when container is already running: %s", stderr) requireExitCode(t, 0, err) assert.Contains(t, stdout, "already running") + assertCommandTelemetry(t, events, "start", 0) } func TestStartCommandFailsWhenPortInUse(t *testing.T) { @@ -98,12 +100,18 @@ func TestStartCommandFailsWhenPortInUse(t *testing.T) { require.NoError(t, err, "failed to bind port 4566 for test") defer func() { _ = ln.Close() }() - stdout, _, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token"), "start") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, _, err := runLstk(t, testContext(t), "", env.With(env.AuthToken, "fake-token").With(env.AnalyticsEndpoint, analyticsSrv.URL), "start") require.Error(t, err, "expected lstk start to fail when port is in use") requireExitCode(t, 1, err) assert.Contains(t, stdout, "Port 4566 already in use") assert.Contains(t, stdout, "LocalStack may already be running.") assert.Contains(t, stdout, "lstk stop") + + // Both lstk_lifecycle (start_error) and lstk_command events should be emitted. + byName := collectTelemetryByName(t, events, 2) + assert.Contains(t, byName, "lstk_lifecycle") + assert.Contains(t, byName, "lstk_command") } func TestStartCommandSucceedsWithNonDefaultPort(t *testing.T) { diff --git a/test/integration/status_test.go b/test/integration/status_test.go index fb72e5a1..4662c711 100644 --- a/test/integration/status_test.go +++ b/test/integration/status_test.go @@ -20,12 +20,14 @@ func TestStatusCommandFailsWhenNotRunning(t *testing.T) { cleanup() t.Cleanup(cleanup) - stdout, _, err := runLstk(t, testContext(t), "", nil, "status") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, _, err := runLstk(t, testContext(t), "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "status") require.Error(t, err, "expected lstk status to fail when emulator not running") requireExitCode(t, 1, err) assert.Contains(t, stdout, "is not running") assert.Contains(t, stdout, "Start LocalStack:") assert.Contains(t, stdout, "See help:") + assertCommandTelemetry(t, events, "status", 1) } func TestStatusCommandShowsResourcesWhenRunning(t *testing.T) { @@ -54,7 +56,8 @@ func TestStatusCommandShowsResourcesWhenRunning(t *testing.T) { host := strings.TrimPrefix(server.URL, "http://") - stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host), "status") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.LocalStackHost, host).With(env.AnalyticsEndpoint, analyticsSrv.URL), "status") require.NoError(t, err, "lstk status failed: %s", stderr) requireExitCode(t, 0, err) assert.Contains(t, stdout, "running") @@ -67,6 +70,7 @@ func TestStatusCommandShowsResourcesWhenRunning(t *testing.T) { assert.Contains(t, stdout, "my-bucket") assert.Contains(t, stdout, "Lambda") assert.Contains(t, stdout, "my-function") + assertCommandTelemetry(t, events, "status", 0) } func TestStatusCommandWorksWithNonDefaultPort(t *testing.T) { diff --git a/test/integration/stop_test.go b/test/integration/stop_test.go index 6ac92b40..ae8f1749 100644 --- a/test/integration/stop_test.go +++ b/test/integration/stop_test.go @@ -3,6 +3,7 @@ package integration_test import ( "testing" + "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,7 +16,8 @@ func TestStopCommandSucceeds(t *testing.T) { ctx := testContext(t) startTestContainer(t, ctx) - stdout, stderr, err := runLstk(t, ctx, "", nil, "stop") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "stop") require.NoError(t, err, "lstk stop failed: %s", stderr) requireExitCode(t, 0, err) assert.Contains(t, stdout, "Stopping", "should show stopping message") @@ -23,6 +25,11 @@ func TestStopCommandSucceeds(t *testing.T) { _, err = dockerClient.ContainerInspect(ctx, containerName) assert.Error(t, err, "container should not exist after stop") + + // Both lstk_lifecycle (stop) and lstk_command events should be emitted. + byName := collectTelemetryByName(t, events, 2) + assert.Contains(t, byName, "lstk_lifecycle") + assert.Contains(t, byName, "lstk_command") } func TestStopCommandFailsWhenNotRunning(t *testing.T) { @@ -30,10 +37,12 @@ func TestStopCommandFailsWhenNotRunning(t *testing.T) { cleanup() t.Cleanup(cleanup) - _, stderr, err := runLstk(t, testContext(t), "", nil, "stop") + analyticsSrv, events := mockAnalyticsServer(t) + _, stderr, err := runLstk(t, testContext(t), "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "stop") require.Error(t, err, "expected lstk stop to fail when container not running") requireExitCode(t, 1, err) assert.Contains(t, stderr, "is not running") + assertCommandTelemetry(t, events, "stop", 1) } func TestStopCommandIsIdempotent(t *testing.T) { diff --git a/test/integration/telemetry_test.go b/test/integration/telemetry_test.go index f1dfd0e0..d8783866 100644 --- a/test/integration/telemetry_test.go +++ b/test/integration/telemetry_test.go @@ -69,7 +69,7 @@ func TestStartCommandSendsTelemetryEvent(t *testing.T) { // The telemetry goroutine is async; wait up to 3s for the event to arrive. select { case event := <-events: - assert.Equal(t, "cli_cmd", event["name"]) + assert.Equal(t, "lstk_command", event["name"]) metadata, ok := event["metadata"].(map[string]any) require.True(t, ok) @@ -80,17 +80,72 @@ func TestStartCommandSendsTelemetryEvent(t *testing.T) { payload, ok := event["payload"].(map[string]any) require.True(t, ok) - assert.Equal(t, "lstk start", payload["cmd"]) - assert.NotEmpty(t, payload["version"]) - assert.Equal(t, runtime.GOOS, payload["os"]) - assert.Equal(t, runtime.GOARCH, payload["arch"]) assert.NotEmpty(t, payload["machine_id"], "machine_id should be present") assert.Equal(t, os.Getenv("CI") != "", payload["is_ci"]) + + environment, ok := payload["environment"].(map[string]any) + require.True(t, ok) + assert.NotEmpty(t, environment["lstk_version"]) + assert.Equal(t, runtime.GOOS, environment["os"]) + assert.Equal(t, runtime.GOARCH, environment["arch"]) + + params, ok := payload["parameters"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "start", params["command"]) + + result, ok := payload["result"].(map[string]any) + require.True(t, ok) + assert.InDelta(t, 0, result["exit_code"], 0) case <-time.After(3 * time.Second): t.Fatal("timed out waiting for telemetry event") } } +func TestStopCommandSendsTelemetryEvents(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + analyticsSrv, events := mockAnalyticsServer(t) + + _, stderr, err := runLstk(t, ctx, "", env.With(env.AuthToken, "fake-token"). + With(env.AnalyticsEndpoint, analyticsSrv.URL), "stop") + require.NoError(t, err, "lstk stop failed: %s", stderr) + requireExitCode(t, 0, err) + + // Collect both the lstk_lifecycle and lstk_command events (order not guaranteed). + byName := make(map[string]map[string]any) + deadline := time.After(3 * time.Second) + for len(byName) < 2 { + select { + case event := <-events: + name, _ := event["name"].(string) + byName[name] = event + case <-deadline: + t.Fatalf("timed out waiting for telemetry events; received: %v", byName) + } + } + + lifecycle, ok := byName["lstk_lifecycle"] + require.True(t, ok, "expected lstk_lifecycle event") + lp := lifecycle["payload"].(map[string]any) + assert.Equal(t, "stop", lp["event_type"]) + assert.Equal(t, "aws", lp["emulator"]) + + command, ok := byName["lstk_command"] + require.True(t, ok, "expected lstk_command event") + cp := command["payload"].(map[string]any) + params, ok := cp["parameters"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "stop", params["command"]) + result, ok := cp["result"].(map[string]any) + require.True(t, ok) + assert.InDelta(t, 0, result["exit_code"], 0) +} + func TestStartCommandSucceedsWhenAnalyticsEndpointUnreachable(t *testing.T) { requireDocker(t) cleanup() @@ -138,3 +193,49 @@ func TestStartCommandDoesNotSendTelemetryWhenDisabled(t *testing.T) { // No event received — correct. } } + +// receiveEventByName waits up to 3s for an event with the given name. +// Events with a different name are skipped until the deadline. +func receiveEventByName(t *testing.T, events <-chan map[string]any, name string) map[string]any { + t.Helper() + deadline := time.After(3 * time.Second) + for { + select { + case event := <-events: + if event["name"] == name { + return event + } + case <-deadline: + t.Fatalf("timed out waiting for %q telemetry event", name) + return nil + } + } +} + +// asserts that a lstk_command event was emitted with the expected command name and exit code +func assertCommandTelemetry(t *testing.T, events <-chan map[string]any, command string, exitCode int) { + t.Helper() + event := receiveEventByName(t, events, "lstk_command") + payload, _ := event["payload"].(map[string]any) + params, _ := payload["parameters"].(map[string]any) + assert.Equal(t, command, params["command"]) + result, _ := payload["result"].(map[string]any) + assert.InDelta(t, exitCode, result["exit_code"], 0) +} + +// collects events until count distinct event names have been received or the deadline expires. +func collectTelemetryByName(t *testing.T, events <-chan map[string]any, count int) map[string]map[string]any { + t.Helper() + byName := make(map[string]map[string]any) + deadline := time.After(3 * time.Second) + for len(byName) < count { + select { + case event := <-events: + name, _ := event["name"].(string) + byName[name] = event + case <-deadline: + t.Fatalf("timed out waiting for %d telemetry events; received: %v", count, byName) + } + } + return byName +} diff --git a/test/integration/update_test.go b/test/integration/update_test.go index 3be8de6d..89f70ca1 100644 --- a/test/integration/update_test.go +++ b/test/integration/update_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/localstack/lstk/test/integration/env" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,12 +16,14 @@ import ( func TestUpdateCheckCommand(t *testing.T) { ctx := testContext(t) - stdout, stderr, err := runLstk(t, ctx, "", nil, "update", "--check") + analyticsSrv, events := mockAnalyticsServer(t) + stdout, stderr, err := runLstk(t, ctx, "", env.With(env.AnalyticsEndpoint, analyticsSrv.URL), "update", "--check") require.NoError(t, err, "lstk update --check failed: %s", stderr) requireExitCode(t, 0, err) // Dev builds report a note about skipping update check assert.Contains(t, stdout, "Note:", "should show a note (dev build or up-to-date)") + assertCommandTelemetry(t, events, "update", 0) } func TestUpdateCheckCommandNonInteractive(t *testing.T) {