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..a9fa7ae9 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(resultCancelled, 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(resultSkipped, 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..5c7a22bd 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(resultFailure, 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(resultSuccess, 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..2db8c440 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,23 @@ func (m *Model) shutdown() { defer cancel() for name, h := range m.watchers { + 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) + } } func (m *Model) startDashboard() tea.Cmd { @@ -191,6 +207,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 { @@ -201,11 +220,54 @@ 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) return m, m.instance.AddStreamingSource(msg.name, msg.lines) case watcherRunningMsg: + if msg.err != nil { + if tags, ok := m.telemetry.watcherEndTags(msg.name, watcherEndPrepFailed); ok { + trackEvent(eventDevWatcher, tags) + } + } _, exists := m.watchers[msg.name] switch msg.name { case watcherAdmin: @@ -249,29 +311,14 @@ 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, watcherEndCrashed); ok { + trackEvent(eventDevWatcher, tags) + } delete(m.watchers, msg.source) return m.updateChildren(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 @@ -360,6 +407,11 @@ func (m Model) handleStopConfirmResult(msg stopConfirmResultMsg) (tea.Model, tea if msg.Cancel { return m, nil } + if msg.Stop { + m.telemetry.setExitChoice(exitStopContainers) + } else { + m.telemetry.setExitChoice(exitKeepRunning) + } m.shutdown() if msg.Stop { m.phase = phaseStopping @@ -418,6 +470,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..8be0bc95 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"] = resultCancelled + 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(resultCancelled, 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(resultCancelled); 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, watcherEndUserStopped); 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(resultCancelled, 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(resultCancelled, 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(resultFailed, 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(resultFailed, 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(resultCompleted, 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..13c4cc56 --- /dev/null +++ b/internal/devtui/telemetry.go @@ -0,0 +1,404 @@ +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" +) + +// 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. +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 = 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, + } + 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 == resultSuccess || result == resultFailure { + 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 resultCancelled: + tags["abandoned_at"] = migrationStepTagName(sg.step) + case resultCompleted, resultFailed: + if sg.phpCursor >= 0 && sg.phpCursor < len(sg.phpVersions) { + tags["php_version"] = sg.phpVersions[sg.phpCursor] + } + } + if result == resultCompleted { + 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 healthOK: + return "ok" + case healthWarn: + return "warn" + case healthCritical: + return "critical" + default: + return "ok" + } +} + +func resultTag(err error) string { + if err != nil { + return resultFailure + } + return resultSuccess +} + +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..60e76310 --- /dev/null +++ b/internal/devtui/telemetry_test.go @@ -0,0 +1,234 @@ +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) { + if err := os.Setenv("DO_NOT_TRACK", "1"); err != nil { + panic(err) + } + 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) +}