From 7ba3e2e401c59e3c63bccb2ff9700d1f516479e4 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 6 Jul 2026 09:40:01 +0200 Subject: [PATCH 1/5] feat(devtui): track usage telemetry for setup flows and dashboard Add anonymous usage events to the project dev TUI so we can see where the setup wizards lose users, whether Docker environments start reliably, and which dashboard features are used: - project.dev.install: install wizard outcome incl. abandoned step, failed deployment-helper step, and chosen language/currency - project.dev.migration_wizard: replaces migration_wizard_completed, now also reporting cancelled (with step) and failed runs - project.dev.docker_start: compose up duration and result, for both initial startup and config-change restarts - project.dev.action: command-palette actions with task result/duration - project.dev.watcher: per-watcher-run uptime and end reason - project.dev.health: one-per-session setup-health snapshot (levels only) - project.dev.session: session duration, tabs visited, action counts, and stop-containers vs keep-running exit choice All state lives in a nil-safe telemetryState shared across Model copies; outcome events latch so quit paths cannot double-report. No URLs, credentials, or free-text input are ever sent. Events are documented in docs/TELEMETRY.md, and a package TestMain sets DO_NOT_TRACK so tests never emit real events. Co-Authored-By: Claude Fable 5 --- docs/TELEMETRY.md | 107 ++++++++- internal/devtui/install_wizard.go | 9 + internal/devtui/lifecycle.go | 11 + internal/devtui/model.go | 35 +++ internal/devtui/model_commands.go | 2 + internal/devtui/model_update.go | 66 ++++-- internal/devtui/telemetry.go | 377 ++++++++++++++++++++++++++++++ internal/devtui/telemetry_test.go | 232 ++++++++++++++++++ 8 files changed, 821 insertions(+), 18 deletions(-) create mode 100644 internal/devtui/telemetry.go create mode 100644 internal/devtui/telemetry_test.go diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 81103e44..25e6895e 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -31,6 +31,9 @@ The goal of telemetry is to answer questions like: CI pipelines? - For key flows (project creation, upgrade checks), which configuration options are popular (e.g. which Shopware version, which CI provider, Docker yes/no)? +- For the interactive development TUI (`project dev`): do the setup wizards + succeed or where do users abandon them, do Docker environments start + reliably, and which dashboard features (watchers, builds, tabs) are used? ## How we identify users @@ -87,7 +90,8 @@ All events share a common envelope: | `timestamp` | RFC 3339 timestamp of when the event was sent | | `tags` | Event-specific key/value pairs (all string values), described below| -There are currently **three** tracked events. +There are three command-level events, plus a set of events sent by the +interactive development TUI (`shopware-cli project dev`). ### `shopware_cli.command` — every command run @@ -135,6 +139,107 @@ how often blockers are encountered. | `target_version` | The version the user wants to upgrade to | `6.6.0` | | `has_blockers` | Whether any blocking incompatibilities were found| `true` | +## Development TUI events (`shopware-cli project dev`) + +The interactive development dashboard sends the events below so we can see +where the setup flows lose users, whether Docker environments start reliably, +and which dashboard features are actually used. As everywhere else: no URLs, +no sales-channel or theme names, no credentials, and no free-text input are +ever transmitted — only enumerated choices, outcomes, and durations. + +### `shopware_cli.project.dev.install` — first-run Shopware installation + +Sent when the "Shopware is not initialized yet" wizard reaches a terminal +state. Choice tags are only present once the user made that choice (an event +for a run cancelled on the language step carries no `language` tag). + +| Tag | Meaning | Example | +|----------------------|---------------------------------------------------------------|------------------| +| `result` | Outcome of the wizard | `success` / `failure` / `cancelled` / `skipped` | +| `abandoned_at` | Step shown when the user quit (only for `cancelled`) | `ask` / `language` / `currency` / `credentials` / `installing` | +| `failed_step` | Last install step that had started (only for `failure`) | `system:install` | +| `duration_ms` | Install runtime, once the install actually started | `84213` | +| `language` | Selected default language | `de-DE` | +| `currency` | Selected default currency | `EUR` | +| `custom_credentials` | Whether the default admin username/password were changed. The credentials themselves are **never** sent. | `false` | + +### `shopware_cli.project.dev.migration_wizard` — dev-environment setup wizard + +Sent when the wizard that migrates an existing project to the Docker dev +environment reaches a terminal state. This replaces the earlier +`shopware_cli.migration_wizard_completed` event, which only reported +successful runs. + +| Tag | Meaning | Example | +|---------------------------|------------------------------------------------------|--------------| +| `result` | Outcome of the wizard | `completed` / `cancelled` / `failed` | +| `abandoned_at` | Step shown when the user quit (only for `cancelled`) | `welcome` / `admin_user` / `docker_php` / `review` | +| `duration_ms` | Time since the welcome screen was confirmed | `45120` | +| `php_version` | Selected PHP version (`completed` / `failed` only) | `8.3` | +| `deployment_helper_added` | Whether composer.json was missing `shopware/deployment-helper` and it was added (`completed` only) | `true` | + +### `shopware_cli.project.dev.docker_start` — container startup + +Sent when a `docker compose up -d` run by the TUI finishes. + +| Tag | Meaning | Example | +|---------------|--------------------------------------------------------------|-----------------| +| `trigger` | Why the containers were (re)started | `initial` / `config_change` | +| `result` | Outcome (`cancelled` when the user quit while waiting) | `success` / `failure` / `cancelled` | +| `duration_ms` | How long the startup took | `12894` | + +### `shopware_cli.project.dev.action` — dashboard actions + +Sent when a command-palette action runs. Instant actions (opening the shop or +admin in the browser) carry only the action name; task actions also report +their outcome and runtime. + +| Tag | Meaning | Example | +|---------------|--------------------------------------------|----------------| +| `action` | The invoked action | `open-shop` / `open-admin` / `cache-clear` / `admin-build` / `sf-build` | +| `result` | Task outcome (task actions only) | `success` / `failure` / `cancelled` | +| `duration_ms` | Task runtime (task actions only) | `31022` | + +### `shopware_cli.project.dev.watcher` — admin/storefront watchers + +Sent once per watcher run, when the watcher ends. + +| Tag | Meaning | Example | +|-------------|-------------------------------------------------------------|------------| +| `watcher` | Which watcher ran | `admin` / `storefront` | +| `result` | How the run ended: preparation failed, the dev-server process exited on its own, the user stopped it, or the TUI session ended | `prep_failed` / `crashed` / `user_stopped` / `session_end` | +| `uptime_ms` | How long the watcher ran | `1830211` | + +### `shopware_cli.project.dev.health` — setup health snapshot + +Sent once per session when the Overview tab's setup-health report first +loads: one tag per check, with the check's level as the value. This tells us +how common misconfigurations are in real projects — and which checks are +worth turning into automatic fixes. Only the level is sent, never the +underlying values. + +| Tag | Meaning | Example | +|--------------------------|--------------------------------------------|---------| +| `php_version` | Running PHP version vs. Shopware constraint| `ok` / `warn` / `critical` | +| `memory_limit` | PHP memory_limit vs. recommendation | `ok` / `warn` | +| `admin_worker` | Browser admin worker disabled? | `ok` / `warn` | +| `flow_builder_log_level` | Flow Builder log level vs. recommendation | `ok` / `warn` | + +### `shopware_cli.project.dev.session` — dashboard session shape + +Sent when the user leaves the dashboard (the overall command duration is +already covered by `shopware_cli.command`; this event adds what happened +inside the TUI). + +| Tag | Meaning | Example | +|-----------------|----------------------------------------------------|-----------------------------| +| `executor` | Environment the project runs in | `docker` / `local` | +| `duration_ms` | TUI session length | `1912734` | +| `tabs_visited` | Which tabs were opened (sorted, comma-separated) | `config,instance,overview` | +| `actions` | Number of palette actions invoked | `3` | +| `watchers_used` | Which watchers were started (omitted if none) | `admin,storefront` | +| `exit` | How the session ended: via the "stop containers?" dialog or a plain quit | `stop_containers` / `keep_running` / `quit` | + ## What we explicitly do **not** collect - No names, emails, usernames, or account / license identifiers. diff --git a/internal/devtui/install_wizard.go b/internal/devtui/install_wizard.go index da973ed7..9abaae8d 100644 --- a/internal/devtui/install_wizard.go +++ b/internal/devtui/install_wizard.go @@ -110,6 +110,11 @@ var installStepPatterns = []struct { func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if k := msg.String(); k == keyQ || k == keyCtrlC { + if m.telemetry.installOnce() { + tags := m.telemetry.installTags("cancelled", m.install) + tags["abandoned_at"] = installStepTagName(m.install.step) + trackEventNow(eventDevInstall, tags) + } return m, tea.Quit } @@ -141,6 +146,9 @@ func (m Model) updateInstallStepAsk(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.install.cursor = 0 return m, nil } + if m.telemetry.installOnce() { + trackEvent(eventDevInstall, m.telemetry.installTags("skipped", m.install)) + } m.phase = phaseDashboard return m, m.startDashboard() } @@ -197,6 +205,7 @@ func (m Model) handleInstallCredentialsEnter() (tea.Model, tea.Cmd) { return m, nil } m.install.blur() + m.telemetry.beginInstall() m.phase = phaseInstalling m.overlayLines = nil m.installProg = installProgress{ diff --git a/internal/devtui/lifecycle.go b/internal/devtui/lifecycle.go index cdaafd78..656915d5 100644 --- a/internal/devtui/lifecycle.go +++ b/internal/devtui/lifecycle.go @@ -46,6 +46,9 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case dockerStartedMsg: + if tags, ok := m.telemetry.dockerStartTags(msg.err); ok { + trackEvent(eventDevDockerStart, tags) + } if msg.err != nil { m.dockerShowLogs = true m.overlayLines = append(m.overlayLines, errorStyle.Render("Failed: "+msg.err.Error())) @@ -74,6 +77,11 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { case shopwareInstallDoneMsg: if msg.err != nil { + if m.telemetry.installOnce() { + tags := m.telemetry.installTags("failure", m.install) + tags["failed_step"] = installFailedStep(m.installProg.currentStep) + trackEvent(eventDevInstall, tags) + } m.installProg.showLogs = true m.overlayLines = append(m.overlayLines, "", errorStyle.Render("Installation failed: "+msg.err.Error())) m.overlayLines = append(m.overlayLines, "", helpStyle.Render("Press q to exit")) @@ -81,6 +89,9 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { } m.installProg.done = true m.installProg.currentStep = len(installStepPatterns) + if m.telemetry.installOnce() { + trackEvent(eventDevInstall, m.telemetry.installTags("success", m.install)) + } username := m.install.username.Value() password := m.install.password.Value() diff --git a/internal/devtui/model.go b/internal/devtui/model.go index afa9a91c..2e9a4915 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -74,6 +74,7 @@ type Model struct { taskErr error watchers map[string]*watcherHandle migrationWizard migrationWizard + telemetry *telemetryState } type dockerAlreadyRunningMsg struct{} @@ -100,6 +101,7 @@ func New(opts Options) Model { config: opts.Config, envConfig: opts.EnvConfig, watchers: make(map[string]*watcherHandle), + telemetry: newTelemetryState(opts.Executor.Type() == executor.TypeDocker), } m.rebuildTabs() return m @@ -161,9 +163,16 @@ func (m *Model) shutdown() { defer cancel() for name, h := range m.watchers { + if tags, ok := m.telemetry.watcherEndTags(name, "session_end"); ok { + trackEventNow(eventDevWatcher, tags) + } h.stop(ctx) delete(m.watchers, name) } + + if tags, ok := m.telemetry.sessionTags(); ok { + trackEventNow(eventDevSession, tags) + } } func (m *Model) startDashboard() tea.Cmd { @@ -191,6 +200,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case taskDoneMsg: m.taskDone = true m.taskErr = msg.err + if tags, ok := m.telemetry.taskTags(resultTag(msg.err)); ok { + trackEvent(eventDevAction, tags) + } if msg.err != nil { m.overlayLines = append(m.overlayLines, "", errorStyle.Render("Failed: "+msg.err.Error())) } else { @@ -203,9 +215,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case watcherStartedMsg: m.watchers[msg.name] = msg.handle + m.telemetry.watcherStarted(msg.name) return m, m.instance.AddStreamingSource(msg.name, msg.lines) case watcherRunningMsg: + if msg.err != nil { + if tags, ok := m.telemetry.watcherEndTags(msg.name, "prep_failed"); ok { + trackEvent(eventDevWatcher, tags) + } + } _, exists := m.watchers[msg.name] switch msg.name { case watcherAdmin: @@ -249,9 +267,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case watcherStorefront: m.overview.sfWatchRunning = false } + if tags, ok := m.telemetry.watcherEndTags(msg.source, "crashed"); ok { + trackEvent(eventDevWatcher, tags) + } delete(m.watchers, msg.source) return m.updateChildren(msg) + case setupHealthLoadedMsg: + if len(msg.checks) > 0 && m.telemetry.healthOnce() { + trackEvent(eventDevHealth, healthTags(msg.checks)) + } + return m.updateFallback(msg) + case paletteResultMsg: return m.handlePaletteResult(msg) @@ -360,6 +387,11 @@ func (m Model) handleStopConfirmResult(msg stopConfirmResultMsg) (tea.Model, tea if msg.Cancel { return m, nil } + if msg.Stop { + m.telemetry.setExitChoice("stop_containers") + } else { + m.telemetry.setExitChoice("keep_running") + } m.shutdown() if msg.Stop { m.phase = phaseStopping @@ -418,6 +450,9 @@ func (m Model) updateChildren(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) handleConfigRestartDone(msg configRestartDoneMsg) (tea.Model, tea.Cmd) { + if tags, ok := m.telemetry.configRestartTags(msg.err); ok { + trackEvent(eventDevDockerStart, tags) + } m.configTab.restarting = false if msg.err != nil { m.configTab.err = msg.err diff --git a/internal/devtui/model_commands.go b/internal/devtui/model_commands.go index d7786888..347258ab 100644 --- a/internal/devtui/model_commands.go +++ b/internal/devtui/model_commands.go @@ -190,6 +190,7 @@ func runComposeCommand(ctx context.Context, projectRoot string, args []string, r } func (m *Model) startContainers() tea.Cmd { + m.telemetry.beginDockerStart() ch, outputCmd, doneCmd := runComposeCommand( context.Background(), m.projectRoot, @@ -201,6 +202,7 @@ func (m *Model) startContainers() tea.Cmd { } func (m *Model) restartContainersForConfig() tea.Cmd { + m.telemetry.beginConfigRestart() projectRoot := m.projectRoot cfg := m.config return func() tea.Msg { diff --git a/internal/devtui/model_update.go b/internal/devtui/model_update.go index c9d6f372..c5de58b5 100644 --- a/internal/devtui/model_update.go +++ b/internal/devtui/model_update.go @@ -2,7 +2,6 @@ package devtui import ( "context" - "strconv" "time" "charm.land/bubbles/v2/textinput" @@ -12,7 +11,6 @@ import ( "github.com/shopware/shopware-cli/internal/envfile" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/shop" - "github.com/shopware/shopware-cli/internal/tracking" ) func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { @@ -35,6 +33,12 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "l": m.dockerShowLogs = !m.dockerShowLogs case keyQ, keyCtrlC: + if m.phase == phaseStarting { + if tags, ok := m.telemetry.dockerStartTags(nil); ok { + tags["result"] = "cancelled" + trackEventNow(eventDevDockerStart, tags) + } + } return m, tea.Quit } return m, nil @@ -45,6 +49,11 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "l": m.installProg.showLogs = !m.installProg.showLogs case keyQ, keyCtrlC: + if m.telemetry.installOnce() { + tags := m.telemetry.installTags("cancelled", m.install) + tags["abandoned_at"] = "installing" + trackEventNow(eventDevInstall, tags) + } return m, tea.Quit } return m, nil @@ -59,6 +68,9 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if msg.String() == keyQ || msg.String() == keyCtrlC { + if tags, ok := m.telemetry.taskTags("cancelled"); ok { + trackEventNow(eventDevAction, tags) + } return m, tea.Quit } return m, nil @@ -84,18 +96,23 @@ func (m Model) updateDashboardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case key1: m.activeTab = tabOverview + m.telemetry.markTab(m.activeTab) return m, nil case key2: m.activeTab = tabInstance + m.telemetry.markTab(m.activeTab) return m, nil case key3: m.activeTab = tabConfig + m.telemetry.markTab(m.activeTab) return m, nil case keyTab: m.activeTab = (m.activeTab + 1) % activeTab(len(tabNames)) + m.telemetry.markTab(m.activeTab) return m, nil case keyShiftTab: m.activeTab = (m.activeTab - 1 + activeTab(len(tabNames))) % activeTab(len(tabNames)) + m.telemetry.markTab(m.activeTab) return m, nil } @@ -154,15 +171,21 @@ func (m Model) updateConfigTab(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) executeCommand(id string) (tea.Model, tea.Cmd) { switch id { - case "open-shop": - return m, openInBrowser(m.overview.shopURL) - case "open-admin": + case "open-shop", "open-admin": + m.telemetry.countAction() + trackEvent(eventDevAction, map[string]string{"action": id}) + if id == "open-shop" { + return m, openInBrowser(m.overview.shopURL) + } return m, openInBrowser(m.overview.adminURL) case "cache-clear": + m.telemetry.beginTask(id) return m, m.runCacheClear() case "admin-build": + m.telemetry.beginTask(id) return m, m.runAdminBuild() case "sf-build": + m.telemetry.beginTask(id) return m, m.runStorefrontBuild() case "admin-watch-start": if !m.overview.adminWatchRunning && !m.overview.adminWatchStarting { @@ -183,10 +206,13 @@ func (m Model) executeCommand(id string) (tea.Model, tea.Cmd) { } case "tab-instance": m.activeTab = tabInstance + m.telemetry.markTab(m.activeTab) case "tab-overview": m.activeTab = tabOverview + m.telemetry.markTab(m.activeTab) case "tab-config": m.activeTab = tabConfig + m.telemetry.markTab(m.activeTab) case "quit": if m.dockerMode { m.modal = newStopConfirm() @@ -211,6 +237,9 @@ func (m Model) openSalesChannelPicker() (tea.Model, tea.Cmd) { } func (m *Model) stopWatcher(name string) tea.Cmd { + if tags, ok := m.telemetry.watcherEndTags(name, "user_stopped"); ok { + trackEvent(eventDevWatcher, tags) + } m.instance.RemoveSource(name) h := m.watchers[name] @@ -228,11 +257,20 @@ func (m *Model) stopWatcher(name string) tea.Cmd { } func (m Model) updateMigrationWizard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // Enter on the welcome screen's "Quit" button exits the wizard from inside + // migrationWizard.update, so detect it here before the state advances. + welcomeQuit := m.migrationWizard.step == migrationStepWelcome && + !m.migrationWizard.confirmYes && msg.String() == keyEnter + newGuide, cmd := m.migrationWizard.update(msg) m.migrationWizard = newGuide // Ctrl+C on any step quits the app - if msg.String() == keyCtrlC { + if welcomeQuit || msg.String() == keyCtrlC { + // The done screen already sent a completed/failed event for this run. + if m.migrationWizard.step != migrationStepDone { + trackEventNow(eventDevMigrationWizard, migrationWizardTags("cancelled", m.migrationWizard)) + } return m, tea.Quit } @@ -242,6 +280,7 @@ func (m Model) updateMigrationWizard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.migrationWizard.confirmYes { return m.saveMigrationWizard() } + trackEventNow(eventDevMigrationWizard, migrationWizardTags("cancelled", m.migrationWizard)) return m, tea.Quit } @@ -259,6 +298,7 @@ func (m Model) saveMigrationWizard() (tea.Model, tea.Cmd) { if err := shop.WriteConfig(m.config, m.projectRoot); err != nil { m.migrationWizard.err = err m.migrationWizard.step = migrationStepDone + trackEvent(eventDevMigrationWizard, migrationWizardTags("failed", m.migrationWizard)) return m, nil } @@ -266,22 +306,14 @@ func (m Model) saveMigrationWizard() (tea.Model, tea.Cmd) { if err != nil { m.migrationWizard.err = err m.migrationWizard.step = migrationStepDone + trackEvent(eventDevMigrationWizard, migrationWizardTags("failed", m.migrationWizard)) return m, nil } m.migrationWizard.deploymentHelperAdded = changed m.migrationWizard.step = migrationStepDone - duration := time.Since(m.migrationWizard.startedAt) - phpVersion := m.migrationWizard.phpVersions[m.migrationWizard.phpCursor] - return m, func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - tracking.Track(ctx, "migration_wizard_completed", map[string]string{ - "took": strconv.FormatInt(int64(duration.Seconds()), 10), - "php_version": phpVersion, - }) - return nil - } + trackEvent(eventDevMigrationWizard, migrationWizardTags("completed", m.migrationWizard)) + return m, nil } // mergeLocalProfilerSecrets copies profiler credential fields from the diff --git a/internal/devtui/telemetry.go b/internal/devtui/telemetry.go new file mode 100644 index 00000000..c6f1dc9f --- /dev/null +++ b/internal/devtui/telemetry.go @@ -0,0 +1,377 @@ +package devtui + +import ( + "context" + "slices" + "strconv" + "strings" + "time" + + "github.com/shopware/shopware-cli/internal/tracking" +) + +// Event names sent by the dev TUI. All are documented in docs/TELEMETRY.md; +// new events must be added there as well. +const ( + eventDevSession = "project.dev.session" + eventDevInstall = "project.dev.install" + eventDevMigrationWizard = "project.dev.migration_wizard" + eventDevDockerStart = "project.dev.docker_start" + eventDevAction = "project.dev.action" + eventDevWatcher = "project.dev.watcher" + eventDevHealth = "project.dev.health" +) + +// telemetryState accumulates anonymous usage data for one TUI session. It is +// held by pointer on Model so Bubble Tea's value copies all share it. Tests +// construct Model directly without it, so every method is nil-safe. +type telemetryState struct { + sessionStart time.Time + executor string + + tabsVisited map[string]struct{} + watchersUsed map[string]struct{} + actionCount int + exitChoice string + sessionSent bool + + installStart time.Time + installReported bool + dockerStart time.Time + restartStart time.Time + watcherStarts map[string]time.Time + taskAction string + taskStart time.Time + healthSent bool +} + +func newTelemetryState(dockerMode bool) *telemetryState { + executorType := "local" + if dockerMode { + executorType = "docker" + } + return &telemetryState{ + sessionStart: time.Now(), + executor: executorType, + tabsVisited: map[string]struct{}{"overview": {}}, + watchersUsed: map[string]struct{}{}, + watcherStarts: map[string]time.Time{}, + } +} + +// trackEvent sends one anonymous usage event without blocking the UI: the +// send (including DNS resolution) runs in a goroutine with a short timeout. +func trackEvent(name string, tags map[string]string) { + go func() { + trackEventNow(name, tags) + }() +} + +// trackEventNow sends synchronously. Quit paths must use it — a goroutine +// started right before tea.Quit would race the process exit. +func trackEventNow(name string, tags map[string]string) { + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + tracking.Track(ctx, name, tags) +} + +func (t *telemetryState) markTab(tab activeTab) { + if t == nil || int(tab) >= len(tabNames) { + return + } + t.tabsVisited[strings.ToLower(tabNames[tab])] = struct{}{} +} + +func (t *telemetryState) countAction() { + if t == nil { + return + } + t.actionCount++ +} + +func (t *telemetryState) setExitChoice(choice string) { + if t == nil { + return + } + t.exitChoice = choice +} + +// sessionTags builds the project.dev.session event and marks it sent, so the +// event fires at most once even when several quit paths run. +func (t *telemetryState) sessionTags() (map[string]string, bool) { + if t == nil || t.sessionSent { + return nil, false + } + t.sessionSent = true + + exit := t.exitChoice + if exit == "" { + exit = "quit" + } + tags := map[string]string{ + "executor": t.executor, + "duration_ms": durationMS(time.Since(t.sessionStart)), + "tabs_visited": joinSet(t.tabsVisited), + "actions": strconv.Itoa(t.actionCount), + "exit": exit, + } + if len(t.watchersUsed) > 0 { + tags["watchers_used"] = joinSet(t.watchersUsed) + } + return tags, true +} + +func (t *telemetryState) beginInstall() { + if t == nil { + return + } + t.installStart = time.Now() +} + +// installOnce reports whether an install outcome should still be sent and +// latches, so quitting the failure screen doesn't add a second event on top +// of the already-reported failure. +func (t *telemetryState) installOnce() bool { + if t == nil || t.installReported { + return false + } + t.installReported = true + return true +} + +// installTags builds the project.dev.install event. The wizard's choices are +// only included once made (an event for a run cancelled on the language step +// carries no language tag). Credentials are never sent — only whether the +// defaults were changed. +func (t *telemetryState) installTags(result string, w installWizard) map[string]string { + tags := map[string]string{"result": result} + if t != nil && !t.installStart.IsZero() { + tags["duration_ms"] = durationMS(time.Since(t.installStart)) + } + if w.language != "" { + tags["language"] = w.language + } + if w.currency != "" { + tags["currency"] = w.currency + } + if w.step == installStepCredentials || result == "success" || result == "failure" { + custom := w.username.Value() != defaultUsername || w.password.Value() != "shopware" + tags["custom_credentials"] = strconv.FormatBool(custom) + } + return tags +} + +func installStepTagName(step installStep) string { + switch step { + case installStepAsk: + return "ask" + case installStepLanguage: + return "language" + case installStepCurrency: + return "currency" + case installStepCredentials: + return "credentials" + } + return "unknown" +} + +// migrationWizardTags builds the project.dev.migration_wizard event. +// duration_ms is only present once the user has left the welcome screen +// (startedAt is set on the welcome confirm). +func migrationWizardTags(result string, sg migrationWizard) map[string]string { + tags := map[string]string{"result": result} + if !sg.startedAt.IsZero() { + tags["duration_ms"] = durationMS(time.Since(sg.startedAt)) + } + switch result { + case "cancelled": + tags["abandoned_at"] = migrationStepTagName(sg.step) + case "completed", "failed": + if sg.phpCursor >= 0 && sg.phpCursor < len(sg.phpVersions) { + tags["php_version"] = sg.phpVersions[sg.phpCursor] + } + } + if result == "completed" { + tags["deployment_helper_added"] = strconv.FormatBool(sg.deploymentHelperAdded) + } + return tags +} + +func migrationStepTagName(step migrationStep) string { + switch step { + case migrationStepWelcome: + return "welcome" + case migrationStepAdminUser: + return "admin_user" + case migrationStepDockerPHP: + return "docker_php" + case migrationStepReview: + return "review" + case migrationStepDone: + return "done" + } + return "unknown" +} + +func (t *telemetryState) beginDockerStart() { + if t == nil { + return + } + t.dockerStart = time.Now() +} + +func (t *telemetryState) dockerStartTags(err error) (map[string]string, bool) { + if t == nil || t.dockerStart.IsZero() { + return nil, false + } + started := t.dockerStart + t.dockerStart = time.Time{} + return map[string]string{ + "trigger": "initial", + "result": resultTag(err), + "duration_ms": durationMS(time.Since(started)), + }, true +} + +func (t *telemetryState) beginConfigRestart() { + if t == nil { + return + } + t.restartStart = time.Now() +} + +func (t *telemetryState) configRestartTags(err error) (map[string]string, bool) { + if t == nil || t.restartStart.IsZero() { + return nil, false + } + started := t.restartStart + t.restartStart = time.Time{} + return map[string]string{ + "trigger": "config_change", + "result": resultTag(err), + "duration_ms": durationMS(time.Since(started)), + }, true +} + +func (t *telemetryState) beginTask(action string) { + if t == nil { + return + } + t.actionCount++ + t.taskAction = action + t.taskStart = time.Now() +} + +func (t *telemetryState) taskTags(result string) (map[string]string, bool) { + if t == nil || t.taskAction == "" { + return nil, false + } + tags := map[string]string{ + "action": t.taskAction, + "result": result, + "duration_ms": durationMS(time.Since(t.taskStart)), + } + t.taskAction = "" + return tags, true +} + +func (t *telemetryState) watcherStarted(name string) { + if t == nil { + return + } + if _, ok := t.watcherStarts[name]; ok { + return + } + t.watcherStarts[name] = time.Now() + t.watchersUsed[watcherTagName(name)] = struct{}{} +} + +// watcherEndTags builds the project.dev.watcher event for one watcher run and +// forgets its start time, so follow-up messages for the same run (a +// logDoneMsg after a user stop, a watcherRunningMsg error after a stop during +// preparation) don't produce a second event. +func (t *telemetryState) watcherEndTags(name, result string) (map[string]string, bool) { + if t == nil { + return nil, false + } + started, ok := t.watcherStarts[name] + if !ok { + return nil, false + } + delete(t.watcherStarts, name) + return map[string]string{ + "watcher": watcherTagName(name), + "result": result, + "uptime_ms": durationMS(time.Since(started)), + }, true +} + +// installFailedStep names the last deployment-helper step that had started +// when the install failed. Failures before the first recognized step report +// the first step. +func installFailedStep(currentStep int) string { + if currentStep >= len(installStepPatterns) { + currentStep = len(installStepPatterns) - 1 + } + return installStepPatterns[currentStep].pattern +} + +func watcherTagName(name string) string { + if name == watcherStorefront { + return "storefront" + } + return "admin" +} + +// healthOnce reports whether the health event should be sent and latches, so +// re-running the checks (e.g. after a config-change restart) doesn't send a +// second snapshot per session. +func (t *telemetryState) healthOnce() bool { + if t == nil || t.healthSent { + return false + } + t.healthSent = true + return true +} + +// healthTags flattens the setup-health report into one tag per check, keyed +// by the check name ("PHP version" → php_version) with its level as value. +func healthTags(checks []healthCheck) map[string]string { + tags := make(map[string]string, len(checks)) + for _, c := range checks { + key := strings.ReplaceAll(strings.ToLower(c.Name), " ", "_") + tags[key] = c.Level.tagValue() + } + return tags +} + +func (l healthLevel) tagValue() string { + switch l { + case healthWarn: + return "warn" + case healthCritical: + return "critical" + default: + return "ok" + } +} + +func resultTag(err error) string { + if err != nil { + return "failure" + } + return "success" +} + +func durationMS(d time.Duration) string { + return strconv.FormatInt(d.Milliseconds(), 10) +} + +func joinSet(set map[string]struct{}) string { + values := make([]string, 0, len(set)) + for v := range set { + values = append(values, v) + } + slices.Sort(values) + return strings.Join(values, ",") +} diff --git a/internal/devtui/telemetry_test.go b/internal/devtui/telemetry_test.go new file mode 100644 index 00000000..e9e35b8b --- /dev/null +++ b/internal/devtui/telemetry_test.go @@ -0,0 +1,232 @@ +package devtui + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestMain disables telemetry for the whole package: tests drive Model.Update +// through real tracking call sites, and no test run should emit usage events. +func TestMain(m *testing.M) { + os.Setenv("DO_NOT_TRACK", "1") + os.Exit(m.Run()) +} + +func TestInstallTagsOnlyIncludesMadeChoices(t *testing.T) { + tel := newTelemetryState(true) + + w := installWizard{credentialStep: newInstallCredentialStep(), step: installStepLanguage} + tags := tel.installTags("cancelled", w) + + assert.Equal(t, "cancelled", tags["result"]) + assert.NotContains(t, tags, "language") + assert.NotContains(t, tags, "currency") + assert.NotContains(t, tags, "custom_credentials") + assert.NotContains(t, tags, "duration_ms") +} + +func TestInstallTagsNeverContainCredentialValues(t *testing.T) { + tel := newTelemetryState(true) + tel.beginInstall() + + w := installWizard{credentialStep: newInstallCredentialStep(), step: installStepCredentials, language: "de-DE", currency: "EUR"} + w.username.SetValue("hidden-admin-name") + w.password.SetValue("super-secret-password") + tags := tel.installTags("success", w) + + assert.Equal(t, "de-DE", tags["language"]) + assert.Equal(t, "EUR", tags["currency"]) + assert.Equal(t, "true", tags["custom_credentials"]) + assert.Contains(t, tags, "duration_ms") + for _, v := range tags { + assert.NotContains(t, v, "hidden-admin-name") + assert.NotContains(t, v, "super-secret-password") + } +} + +func TestInstallTagsDefaultCredentials(t *testing.T) { + w := installWizard{credentialStep: newInstallCredentialStep(), step: installStepCredentials} + w.username.SetValue("admin") + w.password.SetValue("shopware") + + tags := (*telemetryState)(nil).installTags("failure", w) + assert.Equal(t, "false", tags["custom_credentials"]) +} + +func TestInstallFailedStepClampsToLastPattern(t *testing.T) { + assert.Equal(t, "system:install", installFailedStep(0)) + assert.Equal(t, "plugin:refresh", installFailedStep(len(installStepPatterns)+5)) +} + +func TestMigrationWizardTagsCancelledOnWelcome(t *testing.T) { + sg := migrationWizard{step: migrationStepWelcome, phpVersions: []string{"8.2"}} + tags := migrationWizardTags("cancelled", sg) + + assert.Equal(t, "cancelled", tags["result"]) + assert.Equal(t, "welcome", tags["abandoned_at"]) + assert.NotContains(t, tags, "duration_ms") + assert.NotContains(t, tags, "php_version") +} + +func TestMigrationWizardTagsCompleted(t *testing.T) { + sg := migrationWizard{ + step: migrationStepDone, + phpVersions: []string{"8.2", "8.3"}, + phpCursor: 1, + startedAt: time.Now().Add(-2 * time.Second), + deploymentHelperAdded: true, + } + tags := migrationWizardTags("completed", sg) + + assert.Equal(t, "completed", tags["result"]) + assert.Equal(t, "8.3", tags["php_version"]) + assert.Equal(t, "true", tags["deployment_helper_added"]) + assert.Contains(t, tags, "duration_ms") + assert.NotContains(t, tags, "abandoned_at") +} + +func TestWatcherEndTagsFireOncePerRun(t *testing.T) { + tel := newTelemetryState(true) + tel.watcherStarted(watcherAdmin) + + tags, ok := tel.watcherEndTags(watcherAdmin, "user_stopped") + assert.True(t, ok) + assert.Equal(t, "admin", tags["watcher"]) + assert.Equal(t, "user_stopped", tags["result"]) + assert.Contains(t, tags, "uptime_ms") + + // A trailing logDoneMsg for the same run must not produce a second event. + _, ok = tel.watcherEndTags(watcherAdmin, "crashed") + assert.False(t, ok) +} + +func TestWatcherEndTagsWithoutStart(t *testing.T) { + tel := newTelemetryState(false) + _, ok := tel.watcherEndTags(watcherStorefront, "crashed") + assert.False(t, ok) + + var nilTel *telemetryState + _, ok = nilTel.watcherEndTags(watcherAdmin, "crashed") + assert.False(t, ok) +} + +func TestSessionTagsSentOnce(t *testing.T) { + tel := newTelemetryState(true) + tel.markTab(tabConfig) + tel.markTab(tabInstance) + tel.countAction() + tel.watcherStarted(watcherStorefront) + tel.setExitChoice("keep_running") + + tags, ok := tel.sessionTags() + assert.True(t, ok) + assert.Equal(t, "docker", tags["executor"]) + assert.Equal(t, "config,instance,overview", tags["tabs_visited"]) + assert.Equal(t, "1", tags["actions"]) + assert.Equal(t, "storefront", tags["watchers_used"]) + assert.Equal(t, "keep_running", tags["exit"]) + + _, ok = tel.sessionTags() + assert.False(t, ok) +} + +func TestSessionTagsDefaults(t *testing.T) { + tel := newTelemetryState(false) + tags, ok := tel.sessionTags() + assert.True(t, ok) + assert.Equal(t, "local", tags["executor"]) + assert.Equal(t, "overview", tags["tabs_visited"]) + assert.Equal(t, "quit", tags["exit"]) + assert.NotContains(t, tags, "watchers_used") +} + +func TestTaskTagsRoundTrip(t *testing.T) { + tel := newTelemetryState(true) + + _, ok := tel.taskTags("success") + assert.False(t, ok, "no task began") + + tel.beginTask("cache-clear") + tags, ok := tel.taskTags("failure") + assert.True(t, ok) + assert.Equal(t, "cache-clear", tags["action"]) + assert.Equal(t, "failure", tags["result"]) + assert.Contains(t, tags, "duration_ms") + + _, ok = tel.taskTags("success") + assert.False(t, ok, "task already reported") +} + +func TestDockerStartTagsRequireBegin(t *testing.T) { + tel := newTelemetryState(true) + _, ok := tel.dockerStartTags(nil) + assert.False(t, ok) + + tel.beginDockerStart() + tags, ok := tel.dockerStartTags(assert.AnError) + assert.True(t, ok) + assert.Equal(t, "initial", tags["trigger"]) + assert.Equal(t, "failure", tags["result"]) + + tel.beginConfigRestart() + tags, ok = tel.configRestartTags(nil) + assert.True(t, ok) + assert.Equal(t, "config_change", tags["trigger"]) + assert.Equal(t, "success", tags["result"]) +} + +func TestHealthTagsFlattenChecks(t *testing.T) { + checks := []healthCheck{ + {Name: "PHP version", Level: healthCritical}, + {Name: "Memory limit", Level: healthOK}, + {Name: "Admin Worker", Level: healthWarn}, + {Name: "Flow Builder log level", Level: healthWarn}, + } + + tags := healthTags(checks) + assert.Equal(t, map[string]string{ + "php_version": "critical", + "memory_limit": "ok", + "admin_worker": "warn", + "flow_builder_log_level": "warn", + }, tags) +} + +func TestInstallOnceLatches(t *testing.T) { + tel := newTelemetryState(true) + assert.True(t, tel.installOnce()) + // Quitting the failure screen must not report the install a second time. + assert.False(t, tel.installOnce()) + assert.False(t, (*telemetryState)(nil).installOnce()) +} + +func TestHealthOnceLatches(t *testing.T) { + tel := newTelemetryState(true) + assert.True(t, tel.healthOnce()) + assert.False(t, tel.healthOnce()) + assert.False(t, (*telemetryState)(nil).healthOnce()) +} + +func TestNilTelemetryStateIsSafe(t *testing.T) { + var tel *telemetryState + tel.markTab(tabConfig) + tel.countAction() + tel.setExitChoice("quit") + tel.beginInstall() + tel.beginDockerStart() + tel.beginConfigRestart() + tel.beginTask("cache-clear") + tel.watcherStarted(watcherAdmin) + + _, ok := tel.sessionTags() + assert.False(t, ok) + _, ok = tel.taskTags("success") + assert.False(t, ok) + _, ok = tel.dockerStartTags(nil) + assert.False(t, ok) + _, ok = tel.configRestartTags(nil) + assert.False(t, ok) +} From 4556be13a791cc0458b0acfcc1f2e7e07fb08a77 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 6 Jul 2026 09:44:23 +0200 Subject: [PATCH 2/5] fix: resolve golangci-lint issues Check the os.Setenv error in TestMain, make the healthLevel switch exhaustive, and extract watcher message handling from Model.Update into updateWatcherMsg to bring its cyclomatic complexity back under the limit. Co-Authored-By: Claude Fable 5 --- internal/devtui/model.go | 63 +++++++++++++++++++------------ internal/devtui/telemetry.go | 2 + internal/devtui/telemetry_test.go | 4 +- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 2e9a4915..7d943875 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -213,6 +213,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case configRestartDoneMsg: return m.handleConfigRestartDone(msg) + case watcherStartedMsg, watcherRunningMsg, stopWatcherRequestMsg, + startStorefrontWatchRequestMsg, watcherStoppedMsg, logDoneMsg: + return m.updateWatcherMsg(msg) + + case setupHealthLoadedMsg: + if len(msg.checks) > 0 && m.telemetry.healthOnce() { + trackEvent(eventDevHealth, healthTags(msg.checks)) + } + return m.updateFallback(msg) + + case paletteResultMsg: + return m.handlePaletteResult(msg) + + case pickerResultMsg: + if _, ok := msg.Key.(salesChannelPickerKey); ok { + break + } + return m.handlePickerResult(msg) + + case salesChannelPickerResultMsg: + return m.handleSalesChannelPickerResult(msg) + + case stopConfirmResultMsg: + return m.handleStopConfirmResult(msg) + + case tea.KeyPressMsg: + return m.updateKeyPress(msg) + } + + return m.updateFallback(msg) +} + +// updateWatcherMsg handles the watcher lifecycle messages: start, prep +// done/failed, stop requests, stopped, and the log stream ending (the watcher +// process exiting on its own). +func (m Model) updateWatcherMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { case watcherStartedMsg: m.watchers[msg.name] = msg.handle m.telemetry.watcherStarted(msg.name) @@ -272,33 +309,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } delete(m.watchers, msg.source) return m.updateChildren(msg) - - case setupHealthLoadedMsg: - if len(msg.checks) > 0 && m.telemetry.healthOnce() { - trackEvent(eventDevHealth, healthTags(msg.checks)) - } - return m.updateFallback(msg) - - case paletteResultMsg: - return m.handlePaletteResult(msg) - - case pickerResultMsg: - if _, ok := msg.Key.(salesChannelPickerKey); ok { - break - } - return m.handlePickerResult(msg) - - case salesChannelPickerResultMsg: - return m.handleSalesChannelPickerResult(msg) - - case stopConfirmResultMsg: - return m.handleStopConfirmResult(msg) - - case tea.KeyPressMsg: - return m.updateKeyPress(msg) } - return m.updateFallback(msg) + return m, nil } // updateFallback handles non-key messages that aren't matched by Update's diff --git a/internal/devtui/telemetry.go b/internal/devtui/telemetry.go index c6f1dc9f..e935be70 100644 --- a/internal/devtui/telemetry.go +++ b/internal/devtui/telemetry.go @@ -347,6 +347,8 @@ func healthTags(checks []healthCheck) map[string]string { func (l healthLevel) tagValue() string { switch l { + case healthOK: + return "ok" case healthWarn: return "warn" case healthCritical: diff --git a/internal/devtui/telemetry_test.go b/internal/devtui/telemetry_test.go index e9e35b8b..60e76310 100644 --- a/internal/devtui/telemetry_test.go +++ b/internal/devtui/telemetry_test.go @@ -11,7 +11,9 @@ import ( // TestMain disables telemetry for the whole package: tests drive Model.Update // through real tracking call sites, and no test run should emit usage events. func TestMain(m *testing.M) { - os.Setenv("DO_NOT_TRACK", "1") + if err := os.Setenv("DO_NOT_TRACK", "1"); err != nil { + panic(err) + } os.Exit(m.Run()) } From 54c96302bf09c07cee5ce4219dfce54386b64e47 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 6 Jul 2026 10:00:51 +0200 Subject: [PATCH 3/5] fix(devtui): report in-flight config restarts on quit, extract tag constants Address PR review feedback: - A config-change container restart still in flight when the user leaves the dashboard is now reported as a cancelled docker_start event from shutdown() instead of being silently dropped (matching the initial-start quit path). - Extract the result / watcher-end / exit tag string values into constants in telemetry.go so call sites can't drift apart. Tests keep asserting the literal wire values on purpose. Co-Authored-By: Claude Fable 5 --- internal/devtui/install_wizard.go | 4 ++-- internal/devtui/lifecycle.go | 4 ++-- internal/devtui/model.go | 17 ++++++++++---- internal/devtui/model_update.go | 18 +++++++------- internal/devtui/telemetry.go | 39 +++++++++++++++++++++++++------ 5 files changed, 57 insertions(+), 25 deletions(-) diff --git a/internal/devtui/install_wizard.go b/internal/devtui/install_wizard.go index 9abaae8d..a9fa7ae9 100644 --- a/internal/devtui/install_wizard.go +++ b/internal/devtui/install_wizard.go @@ -111,7 +111,7 @@ var installStepPatterns = []struct { func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if k := msg.String(); k == keyQ || k == keyCtrlC { if m.telemetry.installOnce() { - tags := m.telemetry.installTags("cancelled", m.install) + tags := m.telemetry.installTags(resultCancelled, m.install) tags["abandoned_at"] = installStepTagName(m.install.step) trackEventNow(eventDevInstall, tags) } @@ -147,7 +147,7 @@ func (m Model) updateInstallStepAsk(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if m.telemetry.installOnce() { - trackEvent(eventDevInstall, m.telemetry.installTags("skipped", m.install)) + trackEvent(eventDevInstall, m.telemetry.installTags(resultSkipped, m.install)) } m.phase = phaseDashboard return m, m.startDashboard() diff --git a/internal/devtui/lifecycle.go b/internal/devtui/lifecycle.go index 656915d5..5c7a22bd 100644 --- a/internal/devtui/lifecycle.go +++ b/internal/devtui/lifecycle.go @@ -78,7 +78,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { case shopwareInstallDoneMsg: if msg.err != nil { if m.telemetry.installOnce() { - tags := m.telemetry.installTags("failure", m.install) + tags := m.telemetry.installTags(resultFailure, m.install) tags["failed_step"] = installFailedStep(m.installProg.currentStep) trackEvent(eventDevInstall, tags) } @@ -90,7 +90,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { m.installProg.done = true m.installProg.currentStep = len(installStepPatterns) if m.telemetry.installOnce() { - trackEvent(eventDevInstall, m.telemetry.installTags("success", m.install)) + trackEvent(eventDevInstall, m.telemetry.installTags(resultSuccess, m.install)) } username := m.install.username.Value() diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 7d943875..2db8c440 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -163,13 +163,20 @@ func (m *Model) shutdown() { defer cancel() for name, h := range m.watchers { - if tags, ok := m.telemetry.watcherEndTags(name, "session_end"); ok { + if tags, ok := m.telemetry.watcherEndTags(name, watcherEndSessionEnd); ok { trackEventNow(eventDevWatcher, tags) } h.stop(ctx) delete(m.watchers, name) } + // A config-change container restart may still be in flight when the user + // leaves the dashboard; report it as cancelled instead of dropping it. + if tags, ok := m.telemetry.configRestartTags(nil); ok { + tags["result"] = resultCancelled + trackEventNow(eventDevDockerStart, tags) + } + if tags, ok := m.telemetry.sessionTags(); ok { trackEventNow(eventDevSession, tags) } @@ -257,7 +264,7 @@ func (m Model) updateWatcherMsg(msg tea.Msg) (tea.Model, tea.Cmd) { case watcherRunningMsg: if msg.err != nil { - if tags, ok := m.telemetry.watcherEndTags(msg.name, "prep_failed"); ok { + if tags, ok := m.telemetry.watcherEndTags(msg.name, watcherEndPrepFailed); ok { trackEvent(eventDevWatcher, tags) } } @@ -304,7 +311,7 @@ func (m Model) updateWatcherMsg(msg tea.Msg) (tea.Model, tea.Cmd) { case watcherStorefront: m.overview.sfWatchRunning = false } - if tags, ok := m.telemetry.watcherEndTags(msg.source, "crashed"); ok { + if tags, ok := m.telemetry.watcherEndTags(msg.source, watcherEndCrashed); ok { trackEvent(eventDevWatcher, tags) } delete(m.watchers, msg.source) @@ -401,9 +408,9 @@ func (m Model) handleStopConfirmResult(msg stopConfirmResultMsg) (tea.Model, tea return m, nil } if msg.Stop { - m.telemetry.setExitChoice("stop_containers") + m.telemetry.setExitChoice(exitStopContainers) } else { - m.telemetry.setExitChoice("keep_running") + m.telemetry.setExitChoice(exitKeepRunning) } m.shutdown() if msg.Stop { diff --git a/internal/devtui/model_update.go b/internal/devtui/model_update.go index c5de58b5..8be0bc95 100644 --- a/internal/devtui/model_update.go +++ b/internal/devtui/model_update.go @@ -35,7 +35,7 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case keyQ, keyCtrlC: if m.phase == phaseStarting { if tags, ok := m.telemetry.dockerStartTags(nil); ok { - tags["result"] = "cancelled" + tags["result"] = resultCancelled trackEventNow(eventDevDockerStart, tags) } } @@ -50,7 +50,7 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.installProg.showLogs = !m.installProg.showLogs case keyQ, keyCtrlC: if m.telemetry.installOnce() { - tags := m.telemetry.installTags("cancelled", m.install) + tags := m.telemetry.installTags(resultCancelled, m.install) tags["abandoned_at"] = "installing" trackEventNow(eventDevInstall, tags) } @@ -68,7 +68,7 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if msg.String() == keyQ || msg.String() == keyCtrlC { - if tags, ok := m.telemetry.taskTags("cancelled"); ok { + if tags, ok := m.telemetry.taskTags(resultCancelled); ok { trackEventNow(eventDevAction, tags) } return m, tea.Quit @@ -237,7 +237,7 @@ func (m Model) openSalesChannelPicker() (tea.Model, tea.Cmd) { } func (m *Model) stopWatcher(name string) tea.Cmd { - if tags, ok := m.telemetry.watcherEndTags(name, "user_stopped"); ok { + if tags, ok := m.telemetry.watcherEndTags(name, watcherEndUserStopped); ok { trackEvent(eventDevWatcher, tags) } m.instance.RemoveSource(name) @@ -269,7 +269,7 @@ func (m Model) updateMigrationWizard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if welcomeQuit || msg.String() == keyCtrlC { // The done screen already sent a completed/failed event for this run. if m.migrationWizard.step != migrationStepDone { - trackEventNow(eventDevMigrationWizard, migrationWizardTags("cancelled", m.migrationWizard)) + trackEventNow(eventDevMigrationWizard, migrationWizardTags(resultCancelled, m.migrationWizard)) } return m, tea.Quit } @@ -280,7 +280,7 @@ func (m Model) updateMigrationWizard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.migrationWizard.confirmYes { return m.saveMigrationWizard() } - trackEventNow(eventDevMigrationWizard, migrationWizardTags("cancelled", m.migrationWizard)) + trackEventNow(eventDevMigrationWizard, migrationWizardTags(resultCancelled, m.migrationWizard)) return m, tea.Quit } @@ -298,7 +298,7 @@ func (m Model) saveMigrationWizard() (tea.Model, tea.Cmd) { if err := shop.WriteConfig(m.config, m.projectRoot); err != nil { m.migrationWizard.err = err m.migrationWizard.step = migrationStepDone - trackEvent(eventDevMigrationWizard, migrationWizardTags("failed", m.migrationWizard)) + trackEvent(eventDevMigrationWizard, migrationWizardTags(resultFailed, m.migrationWizard)) return m, nil } @@ -306,13 +306,13 @@ func (m Model) saveMigrationWizard() (tea.Model, tea.Cmd) { if err != nil { m.migrationWizard.err = err m.migrationWizard.step = migrationStepDone - trackEvent(eventDevMigrationWizard, migrationWizardTags("failed", m.migrationWizard)) + trackEvent(eventDevMigrationWizard, migrationWizardTags(resultFailed, m.migrationWizard)) return m, nil } m.migrationWizard.deploymentHelperAdded = changed m.migrationWizard.step = migrationStepDone - trackEvent(eventDevMigrationWizard, migrationWizardTags("completed", m.migrationWizard)) + trackEvent(eventDevMigrationWizard, migrationWizardTags(resultCompleted, m.migrationWizard)) return m, nil } diff --git a/internal/devtui/telemetry.go b/internal/devtui/telemetry.go index e935be70..13c4cc56 100644 --- a/internal/devtui/telemetry.go +++ b/internal/devtui/telemetry.go @@ -22,6 +22,31 @@ const ( eventDevHealth = "project.dev.health" ) +// Values of the "result" tag shared by the events above. +const ( + resultSuccess = "success" + resultFailure = "failure" + resultCancelled = "cancelled" + resultSkipped = "skipped" + resultCompleted = "completed" + resultFailed = "failed" +) + +// Values of the watcher event's "result" tag: how a watcher run ended. +const ( + watcherEndPrepFailed = "prep_failed" + watcherEndCrashed = "crashed" + watcherEndUserStopped = "user_stopped" + watcherEndSessionEnd = "session_end" +) + +// Values of the session event's "exit" tag. +const ( + exitStopContainers = "stop_containers" + exitKeepRunning = "keep_running" + exitQuit = "quit" +) + // telemetryState accumulates anonymous usage data for one TUI session. It is // held by pointer on Model so Bubble Tea's value copies all share it. Tests // construct Model directly without it, so every method is nil-safe. @@ -106,7 +131,7 @@ func (t *telemetryState) sessionTags() (map[string]string, bool) { exit := t.exitChoice if exit == "" { - exit = "quit" + exit = exitQuit } tags := map[string]string{ "executor": t.executor, @@ -154,7 +179,7 @@ func (t *telemetryState) installTags(result string, w installWizard) map[string] if w.currency != "" { tags["currency"] = w.currency } - if w.step == installStepCredentials || result == "success" || result == "failure" { + if w.step == installStepCredentials || result == resultSuccess || result == resultFailure { custom := w.username.Value() != defaultUsername || w.password.Value() != "shopware" tags["custom_credentials"] = strconv.FormatBool(custom) } @@ -184,14 +209,14 @@ func migrationWizardTags(result string, sg migrationWizard) map[string]string { tags["duration_ms"] = durationMS(time.Since(sg.startedAt)) } switch result { - case "cancelled": + case resultCancelled: tags["abandoned_at"] = migrationStepTagName(sg.step) - case "completed", "failed": + case resultCompleted, resultFailed: if sg.phpCursor >= 0 && sg.phpCursor < len(sg.phpVersions) { tags["php_version"] = sg.phpVersions[sg.phpCursor] } } - if result == "completed" { + if result == resultCompleted { tags["deployment_helper_added"] = strconv.FormatBool(sg.deploymentHelperAdded) } return tags @@ -360,9 +385,9 @@ func (l healthLevel) tagValue() string { func resultTag(err error) string { if err != nil { - return "failure" + return resultFailure } - return "success" + return resultSuccess } func durationMS(d time.Duration) string { From 22e5ce2b7dbf4ed443778450b1b2f0deddd1f47a Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 7 Jul 2026 08:47:21 +0200 Subject: [PATCH 4/5] refactor(tracking): centralize event name and tag key constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all telemetry event names, tag keys, and shared result values into internal/tracking (tracking.Event*, tracking.Tag*, tracking.Result*) so every Track caller — cmd/root.go, project create/upgrade-check, and the dev TUI — shares one vocabulary instead of scattered string literals. The devtui package keeps only its domain-specific tag values (watcher end reasons, session exit choices). Tests still assert the raw wire strings on purpose. Co-Authored-By: Claude Fable 5 --- cmd/project/project_create_scaffold.go | 16 ++--- cmd/project/project_upgrade_check.go | 8 +-- cmd/root.go | 20 +++--- internal/devtui/install_wizard.go | 9 +-- internal/devtui/lifecycle.go | 11 +-- internal/devtui/model.go | 19 +++--- internal/devtui/model_update.go | 29 ++++---- internal/devtui/telemetry.go | 92 +++++++++++--------------- internal/tracking/events.go | 86 ++++++++++++++++++++++++ 9 files changed, 181 insertions(+), 109 deletions(-) create mode 100644 internal/tracking/events.go diff --git a/cmd/project/project_create_scaffold.go b/cmd/project/project_create_scaffold.go index 183b8757..2dd8e7b7 100644 --- a/cmd/project/project_create_scaffold.go +++ b/cmd/project/project_create_scaffold.go @@ -32,14 +32,14 @@ var gitlabCITemplate string var shopwarePaasAppTemplate string func scaffoldProject(ctx context.Context, opts *createOptions, chosenVersion string) error { - go tracking.Track(ctx, "project.create", map[string]string{ - "version": opts.selectedVersion, - "deployment": opts.selectedDeployment, - "ci": opts.selectedCI, - "docker": fmt.Sprintf("%v", opts.useDocker), - "with_elasticsearch": fmt.Sprintf("%v", opts.withElasticsearch), - "with_amqp": fmt.Sprintf("%v", opts.withAMQP), - "interactive": fmt.Sprintf("%v", opts.interactive), + go tracking.Track(ctx, tracking.EventProjectCreate, map[string]string{ + tracking.TagVersion: opts.selectedVersion, + tracking.TagDeployment: opts.selectedDeployment, + tracking.TagCI: opts.selectedCI, + tracking.TagDocker: fmt.Sprintf("%v", opts.useDocker), + tracking.TagWithElasticsearch: fmt.Sprintf("%v", opts.withElasticsearch), + tracking.TagWithAMQP: fmt.Sprintf("%v", opts.withAMQP), + tracking.TagInteractive: fmt.Sprintf("%v", opts.interactive), }) if err := os.MkdirAll(opts.projectFolder, os.ModePerm); err != nil { diff --git a/cmd/project/project_upgrade_check.go b/cmd/project/project_upgrade_check.go index c9758a2c..75c9613f 100644 --- a/cmd/project/project_upgrade_check.go +++ b/cmd/project/project_upgrade_check.go @@ -167,10 +167,10 @@ var projectUpgradeCheckCmd = &cobra.Command{ } trackCtx, trackCancel := context.WithTimeout(context.WithoutCancel(cmd.Context()), 300*time.Millisecond) defer trackCancel() - tracking.Track(trackCtx, "project.upgrade_check", map[string]string{ - "from_version": shopwareVersion.String(), - "target_version": selectedVersion, - "has_blockers": strconv.FormatBool(hasBlockers), + tracking.Track(trackCtx, tracking.EventProjectUpgradeCheck, map[string]string{ + tracking.TagFromVersion: shopwareVersion.String(), + tracking.TagTargetVersion: selectedVersion, + tracking.TagHasBlockers: strconv.FormatBool(hasBlockers), }) return nil diff --git a/cmd/root.go b/cmd/root.go index ce4f5e30..144d4b9c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,12 +59,12 @@ func run(ctx context.Context) int { err := rootCmd.ExecuteContext(ctx) if cmd, _, findErr := rootCmd.Find(os.Args[1:]); findErr == nil && cmd != rootCmd && cmd.RunE != nil { - result := "success" + result := tracking.ResultSuccess if err != nil { if errors.Is(err, context.Canceled) { - result = "cancelled" + result = tracking.ResultCancelled } else { - result = "failure" + result = tracking.ResultFailure } } name := strings.TrimPrefix(cmd.CommandPath(), "shopware-cli ") @@ -72,13 +72,13 @@ func run(ctx context.Context) int { name = strings.ReplaceAll(name, "-", "_") trackCtx, trackCancel := context.WithTimeout(context.WithoutCancel(ctx), 300*time.Millisecond) defer trackCancel() - tracking.Track(trackCtx, "command", map[string]string{ - "command_name": name, - "result": result, - "duration_ms": strconv.FormatInt(time.Since(start).Milliseconds(), 10), - "cli_version": version, - "os": runtime.GOOS, - "is_tui": strconv.FormatBool(system.IsInteractionEnabled(ctx)), + tracking.Track(trackCtx, tracking.EventCommand, map[string]string{ + tracking.TagCommandName: name, + tracking.TagResult: result, + tracking.TagDurationMS: strconv.FormatInt(time.Since(start).Milliseconds(), 10), + tracking.TagCLIVersion: version, + tracking.TagOS: runtime.GOOS, + tracking.TagIsTUI: strconv.FormatBool(system.IsInteractionEnabled(ctx)), }) } diff --git a/internal/devtui/install_wizard.go b/internal/devtui/install_wizard.go index a9fa7ae9..87f59289 100644 --- a/internal/devtui/install_wizard.go +++ b/internal/devtui/install_wizard.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/shopware/shopware-cli/internal/tracking" "github.com/shopware/shopware-cli/internal/tui" ) @@ -111,9 +112,9 @@ var installStepPatterns = []struct { func (m Model) updateInstallPrompt(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if k := msg.String(); k == keyQ || k == keyCtrlC { if m.telemetry.installOnce() { - tags := m.telemetry.installTags(resultCancelled, m.install) - tags["abandoned_at"] = installStepTagName(m.install.step) - trackEventNow(eventDevInstall, tags) + tags := m.telemetry.installTags(tracking.ResultCancelled, m.install) + tags[tracking.TagAbandonedAt] = installStepTagName(m.install.step) + trackEventNow(tracking.EventDevInstall, tags) } return m, tea.Quit } @@ -147,7 +148,7 @@ func (m Model) updateInstallStepAsk(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if m.telemetry.installOnce() { - trackEvent(eventDevInstall, m.telemetry.installTags(resultSkipped, m.install)) + trackEvent(tracking.EventDevInstall, m.telemetry.installTags(tracking.ResultSkipped, m.install)) } m.phase = phaseDashboard return m, m.startDashboard() diff --git a/internal/devtui/lifecycle.go b/internal/devtui/lifecycle.go index 5c7a22bd..4d1c4fab 100644 --- a/internal/devtui/lifecycle.go +++ b/internal/devtui/lifecycle.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/tracking" ) func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -47,7 +48,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { case dockerStartedMsg: if tags, ok := m.telemetry.dockerStartTags(msg.err); ok { - trackEvent(eventDevDockerStart, tags) + trackEvent(tracking.EventDevDockerStart, tags) } if msg.err != nil { m.dockerShowLogs = true @@ -78,9 +79,9 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { case shopwareInstallDoneMsg: if msg.err != nil { if m.telemetry.installOnce() { - tags := m.telemetry.installTags(resultFailure, m.install) - tags["failed_step"] = installFailedStep(m.installProg.currentStep) - trackEvent(eventDevInstall, tags) + tags := m.telemetry.installTags(tracking.ResultFailure, m.install) + tags[tracking.TagFailedStep] = installFailedStep(m.installProg.currentStep) + trackEvent(tracking.EventDevInstall, tags) } m.installProg.showLogs = true m.overlayLines = append(m.overlayLines, "", errorStyle.Render("Installation failed: "+msg.err.Error())) @@ -90,7 +91,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd) { m.installProg.done = true m.installProg.currentStep = len(installStepPatterns) if m.telemetry.installOnce() { - trackEvent(eventDevInstall, m.telemetry.installTags(resultSuccess, m.install)) + trackEvent(tracking.EventDevInstall, m.telemetry.installTags(tracking.ResultSuccess, m.install)) } username := m.install.username.Value() diff --git a/internal/devtui/model.go b/internal/devtui/model.go index 2db8c440..cd8f7740 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -11,6 +11,7 @@ import ( "github.com/shopware/shopware-cli/internal/envfile" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/tracking" ) type activeTab int @@ -164,7 +165,7 @@ func (m *Model) shutdown() { for name, h := range m.watchers { if tags, ok := m.telemetry.watcherEndTags(name, watcherEndSessionEnd); ok { - trackEventNow(eventDevWatcher, tags) + trackEventNow(tracking.EventDevWatcher, tags) } h.stop(ctx) delete(m.watchers, name) @@ -173,12 +174,12 @@ func (m *Model) shutdown() { // A config-change container restart may still be in flight when the user // leaves the dashboard; report it as cancelled instead of dropping it. if tags, ok := m.telemetry.configRestartTags(nil); ok { - tags["result"] = resultCancelled - trackEventNow(eventDevDockerStart, tags) + tags[tracking.TagResult] = tracking.ResultCancelled + trackEventNow(tracking.EventDevDockerStart, tags) } if tags, ok := m.telemetry.sessionTags(); ok { - trackEventNow(eventDevSession, tags) + trackEventNow(tracking.EventDevSession, tags) } } @@ -208,7 +209,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.taskDone = true m.taskErr = msg.err if tags, ok := m.telemetry.taskTags(resultTag(msg.err)); ok { - trackEvent(eventDevAction, tags) + trackEvent(tracking.EventDevAction, tags) } if msg.err != nil { m.overlayLines = append(m.overlayLines, "", errorStyle.Render("Failed: "+msg.err.Error())) @@ -226,7 +227,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case setupHealthLoadedMsg: if len(msg.checks) > 0 && m.telemetry.healthOnce() { - trackEvent(eventDevHealth, healthTags(msg.checks)) + trackEvent(tracking.EventDevHealth, healthTags(msg.checks)) } return m.updateFallback(msg) @@ -265,7 +266,7 @@ func (m Model) updateWatcherMsg(msg tea.Msg) (tea.Model, tea.Cmd) { case watcherRunningMsg: if msg.err != nil { if tags, ok := m.telemetry.watcherEndTags(msg.name, watcherEndPrepFailed); ok { - trackEvent(eventDevWatcher, tags) + trackEvent(tracking.EventDevWatcher, tags) } } _, exists := m.watchers[msg.name] @@ -312,7 +313,7 @@ func (m Model) updateWatcherMsg(msg tea.Msg) (tea.Model, tea.Cmd) { m.overview.sfWatchRunning = false } if tags, ok := m.telemetry.watcherEndTags(msg.source, watcherEndCrashed); ok { - trackEvent(eventDevWatcher, tags) + trackEvent(tracking.EventDevWatcher, tags) } delete(m.watchers, msg.source) return m.updateChildren(msg) @@ -471,7 +472,7 @@ func (m Model) updateChildren(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) handleConfigRestartDone(msg configRestartDoneMsg) (tea.Model, tea.Cmd) { if tags, ok := m.telemetry.configRestartTags(msg.err); ok { - trackEvent(eventDevDockerStart, tags) + trackEvent(tracking.EventDevDockerStart, tags) } m.configTab.restarting = false if msg.err != nil { diff --git a/internal/devtui/model_update.go b/internal/devtui/model_update.go index 8be0bc95..91362d06 100644 --- a/internal/devtui/model_update.go +++ b/internal/devtui/model_update.go @@ -11,6 +11,7 @@ import ( "github.com/shopware/shopware-cli/internal/envfile" "github.com/shopware/shopware-cli/internal/executor" "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/tracking" ) func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { @@ -35,8 +36,8 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case keyQ, keyCtrlC: if m.phase == phaseStarting { if tags, ok := m.telemetry.dockerStartTags(nil); ok { - tags["result"] = resultCancelled - trackEventNow(eventDevDockerStart, tags) + tags[tracking.TagResult] = tracking.ResultCancelled + trackEventNow(tracking.EventDevDockerStart, tags) } } return m, tea.Quit @@ -50,9 +51,9 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.installProg.showLogs = !m.installProg.showLogs case keyQ, keyCtrlC: if m.telemetry.installOnce() { - tags := m.telemetry.installTags(resultCancelled, m.install) - tags["abandoned_at"] = "installing" - trackEventNow(eventDevInstall, tags) + tags := m.telemetry.installTags(tracking.ResultCancelled, m.install) + tags[tracking.TagAbandonedAt] = "installing" + trackEventNow(tracking.EventDevInstall, tags) } return m, tea.Quit } @@ -68,8 +69,8 @@ func (m Model) updateKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if msg.String() == keyQ || msg.String() == keyCtrlC { - if tags, ok := m.telemetry.taskTags(resultCancelled); ok { - trackEventNow(eventDevAction, tags) + if tags, ok := m.telemetry.taskTags(tracking.ResultCancelled); ok { + trackEventNow(tracking.EventDevAction, tags) } return m, tea.Quit } @@ -173,7 +174,7 @@ func (m Model) executeCommand(id string) (tea.Model, tea.Cmd) { switch id { case "open-shop", "open-admin": m.telemetry.countAction() - trackEvent(eventDevAction, map[string]string{"action": id}) + trackEvent(tracking.EventDevAction, map[string]string{tracking.TagAction: id}) if id == "open-shop" { return m, openInBrowser(m.overview.shopURL) } @@ -238,7 +239,7 @@ func (m Model) openSalesChannelPicker() (tea.Model, tea.Cmd) { func (m *Model) stopWatcher(name string) tea.Cmd { if tags, ok := m.telemetry.watcherEndTags(name, watcherEndUserStopped); ok { - trackEvent(eventDevWatcher, tags) + trackEvent(tracking.EventDevWatcher, tags) } m.instance.RemoveSource(name) @@ -269,7 +270,7 @@ func (m Model) updateMigrationWizard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if welcomeQuit || msg.String() == keyCtrlC { // The done screen already sent a completed/failed event for this run. if m.migrationWizard.step != migrationStepDone { - trackEventNow(eventDevMigrationWizard, migrationWizardTags(resultCancelled, m.migrationWizard)) + trackEventNow(tracking.EventDevMigrationWizard, migrationWizardTags(tracking.ResultCancelled, m.migrationWizard)) } return m, tea.Quit } @@ -280,7 +281,7 @@ func (m Model) updateMigrationWizard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.migrationWizard.confirmYes { return m.saveMigrationWizard() } - trackEventNow(eventDevMigrationWizard, migrationWizardTags(resultCancelled, m.migrationWizard)) + trackEventNow(tracking.EventDevMigrationWizard, migrationWizardTags(tracking.ResultCancelled, m.migrationWizard)) return m, tea.Quit } @@ -298,7 +299,7 @@ func (m Model) saveMigrationWizard() (tea.Model, tea.Cmd) { if err := shop.WriteConfig(m.config, m.projectRoot); err != nil { m.migrationWizard.err = err m.migrationWizard.step = migrationStepDone - trackEvent(eventDevMigrationWizard, migrationWizardTags(resultFailed, m.migrationWizard)) + trackEvent(tracking.EventDevMigrationWizard, migrationWizardTags(tracking.ResultFailed, m.migrationWizard)) return m, nil } @@ -306,13 +307,13 @@ func (m Model) saveMigrationWizard() (tea.Model, tea.Cmd) { if err != nil { m.migrationWizard.err = err m.migrationWizard.step = migrationStepDone - trackEvent(eventDevMigrationWizard, migrationWizardTags(resultFailed, m.migrationWizard)) + trackEvent(tracking.EventDevMigrationWizard, migrationWizardTags(tracking.ResultFailed, m.migrationWizard)) return m, nil } m.migrationWizard.deploymentHelperAdded = changed m.migrationWizard.step = migrationStepDone - trackEvent(eventDevMigrationWizard, migrationWizardTags(resultCompleted, m.migrationWizard)) + trackEvent(tracking.EventDevMigrationWizard, migrationWizardTags(tracking.ResultCompleted, m.migrationWizard)) return m, nil } diff --git a/internal/devtui/telemetry.go b/internal/devtui/telemetry.go index 13c4cc56..40b42304 100644 --- a/internal/devtui/telemetry.go +++ b/internal/devtui/telemetry.go @@ -10,27 +10,9 @@ import ( "github.com/shopware/shopware-cli/internal/tracking" ) -// Event names sent by the dev TUI. All are documented in docs/TELEMETRY.md; -// new events must be added there as well. -const ( - eventDevSession = "project.dev.session" - eventDevInstall = "project.dev.install" - eventDevMigrationWizard = "project.dev.migration_wizard" - eventDevDockerStart = "project.dev.docker_start" - eventDevAction = "project.dev.action" - eventDevWatcher = "project.dev.watcher" - eventDevHealth = "project.dev.health" -) - -// Values of the "result" tag shared by the events above. -const ( - resultSuccess = "success" - resultFailure = "failure" - resultCancelled = "cancelled" - resultSkipped = "skipped" - resultCompleted = "completed" - resultFailed = "failed" -) +// Event names and tag keys live in the tracking package (tracking.EventDev*, +// tracking.Tag*) so every Track caller shares one vocabulary; the constants +// below are values specific to the dev TUI's events. // Values of the watcher event's "result" tag: how a watcher run ended. const ( @@ -134,14 +116,14 @@ func (t *telemetryState) sessionTags() (map[string]string, bool) { exit = exitQuit } tags := map[string]string{ - "executor": t.executor, - "duration_ms": durationMS(time.Since(t.sessionStart)), - "tabs_visited": joinSet(t.tabsVisited), - "actions": strconv.Itoa(t.actionCount), - "exit": exit, + tracking.TagExecutor: t.executor, + tracking.TagDurationMS: durationMS(time.Since(t.sessionStart)), + tracking.TagTabsVisited: joinSet(t.tabsVisited), + tracking.TagActions: strconv.Itoa(t.actionCount), + tracking.TagExit: exit, } if len(t.watchersUsed) > 0 { - tags["watchers_used"] = joinSet(t.watchersUsed) + tags[tracking.TagWatchersUsed] = joinSet(t.watchersUsed) } return tags, true } @@ -169,19 +151,19 @@ func (t *telemetryState) installOnce() bool { // carries no language tag). Credentials are never sent — only whether the // defaults were changed. func (t *telemetryState) installTags(result string, w installWizard) map[string]string { - tags := map[string]string{"result": result} + tags := map[string]string{tracking.TagResult: result} if t != nil && !t.installStart.IsZero() { - tags["duration_ms"] = durationMS(time.Since(t.installStart)) + tags[tracking.TagDurationMS] = durationMS(time.Since(t.installStart)) } if w.language != "" { - tags["language"] = w.language + tags[tracking.TagLanguage] = w.language } if w.currency != "" { - tags["currency"] = w.currency + tags[tracking.TagCurrency] = w.currency } - if w.step == installStepCredentials || result == resultSuccess || result == resultFailure { + if w.step == installStepCredentials || result == tracking.ResultSuccess || result == tracking.ResultFailure { custom := w.username.Value() != defaultUsername || w.password.Value() != "shopware" - tags["custom_credentials"] = strconv.FormatBool(custom) + tags[tracking.TagCustomCredentials] = strconv.FormatBool(custom) } return tags } @@ -204,20 +186,20 @@ func installStepTagName(step installStep) string { // duration_ms is only present once the user has left the welcome screen // (startedAt is set on the welcome confirm). func migrationWizardTags(result string, sg migrationWizard) map[string]string { - tags := map[string]string{"result": result} + tags := map[string]string{tracking.TagResult: result} if !sg.startedAt.IsZero() { - tags["duration_ms"] = durationMS(time.Since(sg.startedAt)) + tags[tracking.TagDurationMS] = durationMS(time.Since(sg.startedAt)) } switch result { - case resultCancelled: - tags["abandoned_at"] = migrationStepTagName(sg.step) - case resultCompleted, resultFailed: + case tracking.ResultCancelled: + tags[tracking.TagAbandonedAt] = migrationStepTagName(sg.step) + case tracking.ResultCompleted, tracking.ResultFailed: if sg.phpCursor >= 0 && sg.phpCursor < len(sg.phpVersions) { - tags["php_version"] = sg.phpVersions[sg.phpCursor] + tags[tracking.TagPHPVersion] = sg.phpVersions[sg.phpCursor] } } - if result == resultCompleted { - tags["deployment_helper_added"] = strconv.FormatBool(sg.deploymentHelperAdded) + if result == tracking.ResultCompleted { + tags[tracking.TagDeploymentHelperAdded] = strconv.FormatBool(sg.deploymentHelperAdded) } return tags } @@ -252,9 +234,9 @@ func (t *telemetryState) dockerStartTags(err error) (map[string]string, bool) { started := t.dockerStart t.dockerStart = time.Time{} return map[string]string{ - "trigger": "initial", - "result": resultTag(err), - "duration_ms": durationMS(time.Since(started)), + tracking.TagTrigger: "initial", + tracking.TagResult: resultTag(err), + tracking.TagDurationMS: durationMS(time.Since(started)), }, true } @@ -272,9 +254,9 @@ func (t *telemetryState) configRestartTags(err error) (map[string]string, bool) started := t.restartStart t.restartStart = time.Time{} return map[string]string{ - "trigger": "config_change", - "result": resultTag(err), - "duration_ms": durationMS(time.Since(started)), + tracking.TagTrigger: "config_change", + tracking.TagResult: resultTag(err), + tracking.TagDurationMS: durationMS(time.Since(started)), }, true } @@ -292,9 +274,9 @@ func (t *telemetryState) taskTags(result string) (map[string]string, bool) { return nil, false } tags := map[string]string{ - "action": t.taskAction, - "result": result, - "duration_ms": durationMS(time.Since(t.taskStart)), + tracking.TagAction: t.taskAction, + tracking.TagResult: result, + tracking.TagDurationMS: durationMS(time.Since(t.taskStart)), } t.taskAction = "" return tags, true @@ -325,9 +307,9 @@ func (t *telemetryState) watcherEndTags(name, result string) (map[string]string, } delete(t.watcherStarts, name) return map[string]string{ - "watcher": watcherTagName(name), - "result": result, - "uptime_ms": durationMS(time.Since(started)), + tracking.TagWatcher: watcherTagName(name), + tracking.TagResult: result, + tracking.TagUptimeMS: durationMS(time.Since(started)), }, true } @@ -385,9 +367,9 @@ func (l healthLevel) tagValue() string { func resultTag(err error) string { if err != nil { - return resultFailure + return tracking.ResultFailure } - return resultSuccess + return tracking.ResultSuccess } func durationMS(d time.Duration) string { diff --git a/internal/tracking/events.go b/internal/tracking/events.go new file mode 100644 index 00000000..5f5dbb69 --- /dev/null +++ b/internal/tracking/events.go @@ -0,0 +1,86 @@ +package tracking + +// Event names passed to Track. The "shopware_cli." prefix is added by Track +// itself. Every event and its tags are documented in docs/TELEMETRY.md; new +// events must be added there as well. +const ( + // EventCommand is sent after (almost) any sub-command finishes (cmd/root.go). + EventCommand = "command" + // EventProjectCreate is sent when a new Shopware project is scaffolded. + EventProjectCreate = "project.create" + // EventProjectUpgradeCheck is sent when an upgrade compatibility check runs. + EventProjectUpgradeCheck = "project.upgrade_check" + + // The project.dev.* events are sent by the interactive dev TUI (internal/devtui). + EventDevSession = "project.dev.session" + EventDevInstall = "project.dev.install" + EventDevMigrationWizard = "project.dev.migration_wizard" + EventDevDockerStart = "project.dev.docker_start" + EventDevAction = "project.dev.action" + EventDevWatcher = "project.dev.watcher" + EventDevHealth = "project.dev.health" +) + +// Tag keys used by the events above. The project.dev.health event derives its +// keys from the setup-health check names at runtime and has no constants here. +const ( + // EventCommand + TagCommandName = "command_name" + TagResult = "result" + TagDurationMS = "duration_ms" + TagCLIVersion = "cli_version" + TagOS = "os" + TagIsTUI = "is_tui" + + // EventProjectCreate + TagVersion = "version" + TagDeployment = "deployment" + TagCI = "ci" + TagDocker = "docker" + TagWithElasticsearch = "with_elasticsearch" + TagWithAMQP = "with_amqp" + TagInteractive = "interactive" + + // EventProjectUpgradeCheck + TagFromVersion = "from_version" + TagTargetVersion = "target_version" + TagHasBlockers = "has_blockers" + + // EventDevInstall + TagAbandonedAt = "abandoned_at" + TagFailedStep = "failed_step" + TagLanguage = "language" + TagCurrency = "currency" + TagCustomCredentials = "custom_credentials" + + // EventDevMigrationWizard + TagPHPVersion = "php_version" + TagDeploymentHelperAdded = "deployment_helper_added" + + // EventDevDockerStart + TagTrigger = "trigger" + + // EventDevAction + TagAction = "action" + + // EventDevWatcher + TagWatcher = "watcher" + TagUptimeMS = "uptime_ms" + + // EventDevSession + TagExecutor = "executor" + TagTabsVisited = "tabs_visited" + TagActions = "actions" + TagWatchersUsed = "watchers_used" + TagExit = "exit" +) + +// Values of the TagResult tag shared across events. +const ( + ResultSuccess = "success" + ResultFailure = "failure" + ResultCancelled = "cancelled" + ResultSkipped = "skipped" + ResultCompleted = "completed" + ResultFailed = "failed" +) From ab62c627d3e8c853e7d42df6ac3a5020850ec9aa Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 7 Jul 2026 09:41:00 +0200 Subject: [PATCH 5/5] refactor(devtui): align telemetry tags with ClickHouse materialized columns The analytics events table materializes result and duration_ms columns from tags, so events benefit from reusing those keys: - watcher: uptime_ms -> duration_ms - session: exit -> result (stop_containers / keep_running / quit) - health: instead of one event with dynamic per-check keys (whose php_version key collided with the migration wizard's php_version value tag), send one event per check tagged check + result Co-Authored-By: Claude Fable 5 --- docs/TELEMETRY.md | 31 ++++++++++++++----------------- internal/devtui/model.go | 4 +++- internal/devtui/telemetry.go | 28 ++++++++++++++++------------ internal/devtui/telemetry_test.go | 22 +++++++++++----------- internal/tracking/events.go | 13 ++++++++----- 5 files changed, 52 insertions(+), 46 deletions(-) diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 25e6895e..76c0f430 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -204,26 +204,23 @@ their outcome and runtime. Sent once per watcher run, when the watcher ends. -| Tag | Meaning | Example | -|-------------|-------------------------------------------------------------|------------| -| `watcher` | Which watcher ran | `admin` / `storefront` | -| `result` | How the run ended: preparation failed, the dev-server process exited on its own, the user stopped it, or the TUI session ended | `prep_failed` / `crashed` / `user_stopped` / `session_end` | -| `uptime_ms` | How long the watcher ran | `1830211` | +| Tag | Meaning | Example | +|---------------|-------------------------------------------------------------|------------| +| `watcher` | Which watcher ran | `admin` / `storefront` | +| `result` | How the run ended: preparation failed, the dev-server process exited on its own, the user stopped it, or the TUI session ended | `prep_failed` / `crashed` / `user_stopped` / `session_end` | +| `duration_ms` | How long the watcher ran | `1830211` | ### `shopware_cli.project.dev.health` — setup health snapshot Sent once per session when the Overview tab's setup-health report first -loads: one tag per check, with the check's level as the value. This tells us -how common misconfigurations are in real projects — and which checks are -worth turning into automatic fixes. Only the level is sent, never the -underlying values. - -| Tag | Meaning | Example | -|--------------------------|--------------------------------------------|---------| -| `php_version` | Running PHP version vs. Shopware constraint| `ok` / `warn` / `critical` | -| `memory_limit` | PHP memory_limit vs. recommendation | `ok` / `warn` | -| `admin_worker` | Browser admin worker disabled? | `ok` / `warn` | -| `flow_builder_log_level` | Flow Builder log level vs. recommendation | `ok` / `warn` | +loads: one event per check. This tells us how common misconfigurations are in +real projects — and which checks are worth turning into automatic fixes. Only +the check's level is sent, never the underlying values. + +| Tag | Meaning | Example | +|----------|-----------------------------------------------|---------| +| `check` | Which check ran (`php_version`, `memory_limit`, `admin_worker`, `flow_builder_log_level`) | `admin_worker` | +| `result` | The check's level | `ok` / `warn` / `critical` | ### `shopware_cli.project.dev.session` — dashboard session shape @@ -238,7 +235,7 @@ inside the TUI). | `tabs_visited` | Which tabs were opened (sorted, comma-separated) | `config,instance,overview` | | `actions` | Number of palette actions invoked | `3` | | `watchers_used` | Which watchers were started (omitted if none) | `admin,storefront` | -| `exit` | How the session ended: via the "stop containers?" dialog or a plain quit | `stop_containers` / `keep_running` / `quit` | +| `result` | How the session ended: via the "stop containers?" dialog or a plain quit | `stop_containers` / `keep_running` / `quit` | ## What we explicitly do **not** collect diff --git a/internal/devtui/model.go b/internal/devtui/model.go index cd8f7740..c3ff07b4 100644 --- a/internal/devtui/model.go +++ b/internal/devtui/model.go @@ -227,7 +227,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case setupHealthLoadedMsg: if len(msg.checks) > 0 && m.telemetry.healthOnce() { - trackEvent(tracking.EventDevHealth, healthTags(msg.checks)) + for _, tags := range healthEventTags(msg.checks) { + trackEvent(tracking.EventDevHealth, tags) + } } return m.updateFallback(msg) diff --git a/internal/devtui/telemetry.go b/internal/devtui/telemetry.go index 40b42304..44ea41e4 100644 --- a/internal/devtui/telemetry.go +++ b/internal/devtui/telemetry.go @@ -22,7 +22,7 @@ const ( watcherEndSessionEnd = "session_end" ) -// Values of the session event's "exit" tag. +// Values of the session event's "result" tag: how the session ended. const ( exitStopContainers = "stop_containers" exitKeepRunning = "keep_running" @@ -120,7 +120,7 @@ func (t *telemetryState) sessionTags() (map[string]string, bool) { tracking.TagDurationMS: durationMS(time.Since(t.sessionStart)), tracking.TagTabsVisited: joinSet(t.tabsVisited), tracking.TagActions: strconv.Itoa(t.actionCount), - tracking.TagExit: exit, + tracking.TagResult: exit, } if len(t.watchersUsed) > 0 { tags[tracking.TagWatchersUsed] = joinSet(t.watchersUsed) @@ -307,9 +307,9 @@ func (t *telemetryState) watcherEndTags(name, result string) (map[string]string, } delete(t.watcherStarts, name) return map[string]string{ - tracking.TagWatcher: watcherTagName(name), - tracking.TagResult: result, - tracking.TagUptimeMS: durationMS(time.Since(started)), + tracking.TagWatcher: watcherTagName(name), + tracking.TagResult: result, + tracking.TagDurationMS: durationMS(time.Since(started)), }, true } @@ -341,15 +341,19 @@ func (t *telemetryState) healthOnce() bool { return true } -// healthTags flattens the setup-health report into one tag per check, keyed -// by the check name ("PHP version" → php_version) with its level as value. -func healthTags(checks []healthCheck) map[string]string { - tags := make(map[string]string, len(checks)) +// healthEventTags turns the setup-health report into one event per check, +// tagged with the check name ("PHP version" → php_version) and its level as +// the result. Reusing the shared check/result keys keeps the events +// aggregatable in ClickHouse without per-check schema knowledge. +func healthEventTags(checks []healthCheck) []map[string]string { + events := make([]map[string]string, 0, len(checks)) for _, c := range checks { - key := strings.ReplaceAll(strings.ToLower(c.Name), " ", "_") - tags[key] = c.Level.tagValue() + events = append(events, map[string]string{ + tracking.TagCheck: strings.ReplaceAll(strings.ToLower(c.Name), " ", "_"), + tracking.TagResult: c.Level.tagValue(), + }) } - return tags + return events } func (l healthLevel) tagValue() string { diff --git a/internal/devtui/telemetry_test.go b/internal/devtui/telemetry_test.go index 60e76310..7be111dc 100644 --- a/internal/devtui/telemetry_test.go +++ b/internal/devtui/telemetry_test.go @@ -98,7 +98,7 @@ func TestWatcherEndTagsFireOncePerRun(t *testing.T) { assert.True(t, ok) assert.Equal(t, "admin", tags["watcher"]) assert.Equal(t, "user_stopped", tags["result"]) - assert.Contains(t, tags, "uptime_ms") + assert.Contains(t, tags, "duration_ms") // A trailing logDoneMsg for the same run must not produce a second event. _, ok = tel.watcherEndTags(watcherAdmin, "crashed") @@ -129,7 +129,7 @@ func TestSessionTagsSentOnce(t *testing.T) { assert.Equal(t, "config,instance,overview", tags["tabs_visited"]) assert.Equal(t, "1", tags["actions"]) assert.Equal(t, "storefront", tags["watchers_used"]) - assert.Equal(t, "keep_running", tags["exit"]) + assert.Equal(t, "keep_running", tags["result"]) _, ok = tel.sessionTags() assert.False(t, ok) @@ -141,7 +141,7 @@ func TestSessionTagsDefaults(t *testing.T) { assert.True(t, ok) assert.Equal(t, "local", tags["executor"]) assert.Equal(t, "overview", tags["tabs_visited"]) - assert.Equal(t, "quit", tags["exit"]) + assert.Equal(t, "quit", tags["result"]) assert.NotContains(t, tags, "watchers_used") } @@ -180,7 +180,7 @@ func TestDockerStartTagsRequireBegin(t *testing.T) { assert.Equal(t, "success", tags["result"]) } -func TestHealthTagsFlattenChecks(t *testing.T) { +func TestHealthEventTagsOnePerCheck(t *testing.T) { checks := []healthCheck{ {Name: "PHP version", Level: healthCritical}, {Name: "Memory limit", Level: healthOK}, @@ -188,13 +188,13 @@ func TestHealthTagsFlattenChecks(t *testing.T) { {Name: "Flow Builder log level", Level: healthWarn}, } - tags := healthTags(checks) - assert.Equal(t, map[string]string{ - "php_version": "critical", - "memory_limit": "ok", - "admin_worker": "warn", - "flow_builder_log_level": "warn", - }, tags) + events := healthEventTags(checks) + assert.ElementsMatch(t, []map[string]string{ + {"check": "php_version", "result": "critical"}, + {"check": "memory_limit", "result": "ok"}, + {"check": "admin_worker", "result": "warn"}, + {"check": "flow_builder_log_level", "result": "warn"}, + }, events) } func TestInstallOnceLatches(t *testing.T) { diff --git a/internal/tracking/events.go b/internal/tracking/events.go index 5f5dbb69..b02b7f67 100644 --- a/internal/tracking/events.go +++ b/internal/tracking/events.go @@ -21,8 +21,10 @@ const ( EventDevHealth = "project.dev.health" ) -// Tag keys used by the events above. The project.dev.health event derives its -// keys from the setup-health check names at runtime and has no constants here. +// Tag keys used by the events above. Keys are shared across events wherever +// the semantic matches (TagResult, TagDurationMS) — the ClickHouse events +// table materializes columns from these shared keys, so a new event reusing +// them is aggregatable without schema changes. const ( // EventCommand TagCommandName = "command_name" @@ -64,15 +66,16 @@ const ( TagAction = "action" // EventDevWatcher - TagWatcher = "watcher" - TagUptimeMS = "uptime_ms" + TagWatcher = "watcher" + + // EventDevHealth + TagCheck = "check" // EventDevSession TagExecutor = "executor" TagTabsVisited = "tabs_visited" TagActions = "actions" TagWatchersUsed = "watchers_used" - TagExit = "exit" ) // Values of the TagResult tag shared across events.