From 168b720baa40e611e095b3b00fe6afab562ae89d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 15:14:34 -0500 Subject: [PATCH 1/7] feat(telemetry): track per-user workspace count and improve web UI analytics Add a workspace_count gauge emitted from the CLI on create/delete so the number of workspaces a user manages is tracked, keyed on the existing pseudonymous machine-id distinct ID. The CLI is the single source of truth; desktop-initiated create/delete already shell out to it. Attach a hashed workspace_ref to desktop lifecycle events for per-workspace correlation without leaking raw names, and stop workspace_rename from sending the raw workspace id. Add renderer session tracking (session_start/session_end with idle-aware duration) and engagement events (ide_open, terminal_open, context_switch, settings_changed), plus human-readable screen names on page_view. Make analytics log messages vendor-opaque (no provider name in user-facing strings). --- cmd/workspace/delete.go | 14 ++- cmd/workspace/up/up.go | 22 +++- desktop/src/main/__tests__/cli.test.ts | 37 ++++-- desktop/src/main/__tests__/updater.test.ts | 77 ++++++++++--- desktop/src/main/analytics.ts | 20 +++- desktop/src/main/ipc.ts | 109 +++++++++++++----- desktop/src/renderer/src/App.svelte | 53 ++++++++- desktop/src/renderer/src/lib/analytics.ts | 74 ++++++++++++ .../components/provider/ProviderCard.svelte | 7 +- .../renderer/src/pages/ContextsPage.svelte | 2 + .../renderer/src/pages/SettingsPage.svelte | 3 + .../renderer/src/pages/TerminalsPage.svelte | 3 + .../src/pages/WorkspaceDetailPage.svelte | 12 +- pkg/telemetry/analytics/client.go | 12 +- pkg/telemetry/collect.go | 33 +++++- pkg/telemetry/noop.go | 1 + pkg/workspace/list.go | 9 ++ 17 files changed, 403 insertions(+), 85 deletions(-) create mode 100644 desktop/src/renderer/src/lib/analytics.ts diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index b3efbb5be..1ee407c4b 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -68,10 +69,19 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { ctx := cobraCmd.Context() if len(args) <= 1 { - return cmd.deleteSingle(ctx, devsyConfig, args) + err = cmd.deleteSingle(ctx, devsyConfig, args) + } else { + err = cmd.deleteMultiple(ctx, devsyConfig, args) } - return cmd.deleteMultiple(ctx, devsyConfig, args) + // Emit even when a delete failed; the gauge reflects what remains on disk. + if count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext); countErr != nil { + log.Debugf("skipping workspace count gauge: %v", countErr) + } else { + telemetry.FromContext(ctx).RecordWorkspaceGauge(count) + } + + return err } func (cmd *DeleteCmd) loadConfig() (*config.Config, error) { diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 8da2ceb0c..97f2b8fa9 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -20,6 +20,7 @@ import ( provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/util" + "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -93,7 +94,20 @@ func RunFromOptions(ctx context.Context, g *flags.GlobalFlags, opts Options) err return fmt.Errorf("extra devcontainer file is only supported with local provider") } telemetry.FromContext(ctx).SetClient(client) - return cmd.Run(ctx, devsyConfig, client, args) + if err := cmd.Run(ctx, devsyConfig, client, args); err != nil { + return err + } + recordWorkspaceGauge(ctx, devsyConfig) + return nil +} + +func recordWorkspaceGauge(ctx context.Context, devsyConfig *config.Config) { + count, err := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext) + if err != nil { + log.Debugf("skipping workspace count gauge: %v", err) + return + } + telemetry.FromContext(ctx).RecordWorkspaceGauge(count) } func buildUpCmd(g *flags.GlobalFlags, opts Options) *UpCmd { @@ -298,7 +312,11 @@ func (cmd *UpCmd) execute(cobraCmd *cobra.Command, args []string) error { } telemetry.FromContext(cobraCmd.Context()).SetClient(client) - return cmd.Run(ctx, devsyConfig, client, args) + if err := cmd.Run(ctx, devsyConfig, client, args); err != nil { + return err + } + recordWorkspaceGauge(ctx, devsyConfig) + return nil } // workspaceContext holds the result of workspace preparation. diff --git a/desktop/src/main/__tests__/cli.test.ts b/desktop/src/main/__tests__/cli.test.ts index 1577a7977..24b89dfbc 100644 --- a/desktop/src/main/__tests__/cli.test.ts +++ b/desktop/src/main/__tests__/cli.test.ts @@ -31,11 +31,23 @@ describe("CliRunner", () => { }, ) - const result = await cli.run<{ id: string }[]>(["workspace", "list", "--skip-pro"]) + const result = await cli.run<{ id: string }[]>([ + "workspace", + "list", + "--skip-pro", + ]) expect(result).toEqual([{ id: "ws-1" }]) expect(mockExecFile).toHaveBeenCalledWith( "/usr/local/bin/devsy", - ["workspace", "list", "--skip-pro", "--result-format", "json", "--log-output", "json"], + [ + "workspace", + "list", + "--skip-pro", + "--result-format", + "json", + "--log-output", + "json", + ], expect.objectContaining({ env: expect.any(Object) }), expect.any(Function), ) @@ -57,7 +69,9 @@ describe("CliRunner", () => { }, ) - await expect(cli.run(["workspace", "list"])).rejects.toThrow("workspace not found") + await expect(cli.run(["workspace", "list"])).rejects.toThrow( + "workspace not found", + ) }) it("extracts cliError from a zap JSON stderr line and attaches it to the thrown Error", async () => { @@ -68,9 +82,11 @@ describe("CliRunner", () => { code: "AWS_PROFILE_MISSING", message: "AWS credentials are not configured.", hint: "Set AWS_PROFILE or create ~/.aws/credentials.", - docUrl: "https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html", + docUrl: + "https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html", provider: "aws", - cause: "init: exit status 1: failed to get shared config profile, default", + cause: + "init: exit status 1: failed to get shared config profile, default", } const stderrLine = JSON.stringify({ level: "error", @@ -91,7 +107,7 @@ describe("CliRunner", () => { ) const rejection = await cli - .run(["provider", "set", "aws"]) + .run(["provider", "set", "aws"]) .catch((e) => e as Error & { cliError?: typeof cliErrorPayload }) expect(rejection).toBeInstanceOf(Error) expect(rejection.cliError).toEqual(cliErrorPayload) @@ -130,7 +146,14 @@ describe("CliRunner", () => { await jsCli.run(["list"]) expect(mockExecFile).toHaveBeenCalledWith( "node", - ["/tmp/mock.cjs", "list", "--result-format", "json", "--log-output", "json"], + [ + "/tmp/mock.cjs", + "list", + "--result-format", + "json", + "--log-output", + "json", + ], expect.objectContaining({ env: expect.any(Object) }), expect.any(Function), ) diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index 1f7f1a161..2698bab4b 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" const electronUpdaterMock = { autoUpdater: { @@ -76,37 +76,56 @@ describe("updater", () => { "update-status", expect.objectContaining({ state: "downloading", - progress: { percent: 42, bytesPerSecond: 1000, transferred: 100, total: 200 }, + progress: { + percent: 42, + bytesPerSecond: 1000, + transferred: 100, + total: 200, + }, }), ) }) it("respects autoDownload setting on update-available", async () => { - const { initAutoUpdater, setAutoDownloadEnabled } = await import("../updater.js") + const { initAutoUpdater, setAutoDownloadEnabled } = await import( + "../updater.js" + ) const send = vi.fn() const win = { isDestroyed: () => false, webContents: { send } } as never await initAutoUpdater(() => win) setAutoDownloadEnabled(false) - electronUpdaterMock.autoUpdater.emit("update-available", { version: "9.9.9" }) - expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() + electronUpdaterMock.autoUpdater.emit("update-available", { + version: "9.9.9", + }) + expect( + electronUpdaterMock.autoUpdater.downloadUpdate, + ).not.toHaveBeenCalled() }) it("sets app.isQuitting before quitAndInstall so the window can close", async () => { const electron = await import("electron") - ;(electron.app as { isQuitting: boolean }).isQuitting = false + ;( + electron.app as typeof electron.app & { isQuitting?: boolean } + ).isQuitting = false let quittingWhenInstalled: boolean | undefined electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => { - quittingWhenInstalled = (electron.app as { isQuitting: boolean }).isQuitting + quittingWhenInstalled = ( + electron.app as typeof electron.app & { isQuitting?: boolean } + ).isQuitting }) const { installUpdate } = await import("../updater.js") await installUpdate() expect(quittingWhenInstalled).toBe(true) - expect(electronUpdaterMock.autoUpdater.quitAndInstall).toHaveBeenCalledTimes(1) + expect( + electronUpdaterMock.autoUpdater.quitAndInstall, + ).toHaveBeenCalledTimes(1) }) it("swallows a channel-missing rejection from check_for_updates", async () => { electronUpdaterMock.autoUpdater.checkForUpdates.mockRejectedValueOnce( - new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404'), + new Error( + 'Cannot find channel "latest-mac.yml" update info: HttpError: 404', + ), ) const { checkForUpdates } = await import("../updater.js") await expect(checkForUpdates()).resolves.toBeUndefined() @@ -126,8 +145,12 @@ describe("updater", () => { const send = vi.fn() const win = { isDestroyed: () => false, webContents: { send } } as never await initAutoUpdater(() => win) - electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "9.9.9" }) - expect((electron.dialog.showMessageBox as ReturnType)).not.toHaveBeenCalled() + electronUpdaterMock.autoUpdater.emit("update-downloaded", { + version: "9.9.9", + }) + expect( + electron.dialog.showMessageBox as ReturnType, + ).not.toHaveBeenCalled() }) it("checks at boot and again on the recheck interval", async () => { @@ -138,16 +161,24 @@ describe("updater", () => { const win = { isDestroyed: () => false, webContents: { send } } as never await initAutoUpdater(() => win) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).not.toHaveBeenCalled() + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(10_000) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2) + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).toHaveBeenCalledTimes(2) stopAutoUpdater() await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2) + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } @@ -164,7 +195,9 @@ describe("updater", () => { // Quit before the 10s boot delay elapses. stopAutoUpdater() await vi.advanceTimersByTimeAsync(10_000) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).not.toHaveBeenCalled() + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).not.toHaveBeenCalled() } finally { vi.useRealTimers() } @@ -179,11 +212,17 @@ describe("updater", () => { await initAutoUpdater(() => win) await vi.advanceTimersByTimeAsync(10_000) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).toHaveBeenCalledTimes(1) - electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "9.9.9" }) + electronUpdaterMock.autoUpdater.emit("update-downloaded", { + version: "9.9.9", + }) await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) - expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + expect( + electronUpdaterMock.autoUpdater.checkForUpdates, + ).toHaveBeenCalledTimes(1) stopAutoUpdater() } finally { diff --git a/desktop/src/main/analytics.ts b/desktop/src/main/analytics.ts index 97fe372c0..2d6c526af 100644 --- a/desktop/src/main/analytics.ts +++ b/desktop/src/main/analytics.ts @@ -18,12 +18,28 @@ export function getAnalyticsDistinctId(): string { return distinctId || getDistinctId() } +// Stable key for correlating a workspace's events; never the raw name, which +// can leak repo names or paths. Returns "" when analytics is off so callers +// don't pay to derive the machine id for a ref that would be discarded. +export function hashWorkspaceRef(workspaceId: string): string { + if (!client) return "" + return createHmac("sha256", getAnalyticsDistinctId()) + .update(workspaceId) + .digest("hex") + .slice(0, 16) +} + +// machineIdSync spawns a subprocess; cache so it runs at most once per process. +let cachedDistinctId = "" + function getDistinctId(): string { + if (cachedDistinctId) return cachedDistinctId const id = machineIdSync() const home = homedir() const mac = createHmac("sha256", id) mac.update(home) - return mac.digest("hex") + cachedDistinctId = mac.digest("hex") + return cachedDistinctId } function isTelemetryDisabled(): boolean { @@ -33,7 +49,7 @@ function isTelemetryDisabled(): boolean { export function initAnalytics(): void { if (isTelemetryDisabled()) return if (!DEVSY_POSTHOG_API_KEY) { - console.warn("[telemetry] PostHog API key not configured; analytics disabled") + console.warn("[telemetry] analytics disabled: API key not configured") return } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 90ad010d1..73df625fb 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -7,7 +7,7 @@ import { promisify } from "node:util" import type { BrowserWindow } from "electron" import { app, dialog, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" -import { trackEvent } from "./analytics.js" +import { hashWorkspaceRef, trackEvent } from "./analytics.js" import { loadCatalog } from "./image-catalog.js" import type { CliRunner } from "./cli.js" import type { LogStore } from "./log-store.js" @@ -104,7 +104,11 @@ function createLogSink( function post( done: boolean, - extra?: { message?: string; level?: "info" | "warn" | "error"; cliError?: CLIError }, + extra?: { + message?: string + level?: "info" | "warn" | "error" + cliError?: CLIError + }, ): void { if (timer) { clearTimeout(timer) @@ -144,7 +148,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { runInitialProviderUpdateCheck: () => void } { const { cli, state, logStore, pty } = deps - const tunnelProcesses = new Map() + const tunnelProcesses = new Map< + string, + import("node:child_process").ChildProcess + >() /** * Terminate every desktop-spawned process tied to a workspace and wait for @@ -179,9 +186,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { providers.map(async (p) => { const version = typeof p.version === "string" ? p.version : "" try { - const versions = await cli.run>( - ["provider", "versions", p.name, "--json", "--no-cache"], - ) + const versions = await cli.run< + Array<{ tag: string; current?: boolean }> + >(["provider", "versions", p.name, "--json", "--no-cache"]) const list = versions ?? [] const current = list.find((v) => v.current)?.tag ?? version const latest = list[0]?.tag ?? "" @@ -235,19 +242,26 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_rename", - async ( - _event, - args: { workspaceId: string; newWorkspaceId: string }, - ) => { - trackEvent("workspace_rename", { workspaceId: args.workspaceId }) - await cli.runRaw(["workspace", "rename", args.workspaceId, args.newWorkspaceId]) + async (_event, args: { workspaceId: string; newWorkspaceId: string }) => { + trackEvent("workspace_rename", { + workspace_ref: hashWorkspaceRef(args.workspaceId), + }) + await cli.runRaw([ + "workspace", + "rename", + args.workspaceId, + args.newWorkspaceId, + ]) }, ) ipcMain.handle( "workspace_set_ide", async (_event, args: { workspaceId: string; ide: string }) => { - trackEvent("workspace_set_ide", { ide: args.ide }) + trackEvent("workspace_set_ide", { + ide: args.ide, + workspace_ref: hashWorkspaceRef(args.workspaceId), + }) await cli.runRaw(["workspace", "set-ide", args.workspaceId, args.ide]) }, ) @@ -383,7 +397,13 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "provider_set_version", async (_event, args: { name: string; tag: string }) => { - await cli.runRaw(["provider", "set-source", args.name, "--version", args.tag]) + await cli.runRaw([ + "provider", + "set-source", + args.name, + "--version", + args.tag, + ]) }, ) @@ -523,17 +543,20 @@ export function registerIpcHandlers(deps: IpcDependencies): { return cli.runRaw(["--version"]) }) - ipcMain.handle( - "devsy_upgrade", - async (_event, args: { version: string }) => { - return cli.runRaw(["feature", "upgrade", "--version", args.version]) - }, - ) + ipcMain.handle("devsy_upgrade", async (_event, args: { version: string }) => { + return cli.runRaw(["feature", "upgrade", "--version", args.version]) + }) ipcMain.handle( "devsy_upgrade_dry_run", async (_event, args: { version: string }) => { - return cli.runRaw(["feature", "upgrade", "--version", args.version, "--dry-run"]) + return cli.runRaw([ + "feature", + "upgrade", + "--version", + args.version, + "--dry-run", + ]) }, ) @@ -587,14 +610,18 @@ export function registerIpcHandlers(deps: IpcDependencies): { platform?: string }, ) => { - trackEvent("workspace_create", { provider: args.provider }) + trackEvent("workspace_create", { + provider: args.provider, + workspace_ref: hashWorkspaceRef(args.workspaceId ?? args.source), + }) const cliArgs = ["workspace", "up", args.source] if (args.workspaceId) cliArgs.push("--id", args.workspaceId) if (args.provider) cliArgs.push("--provider", args.provider) if (args.ide) cliArgs.push("--ide", args.ide) if (args.ideLaunch) cliArgs.push("--ide-launch", args.ideLaunch) if (args.debug) cliArgs.push("--debug") - if (args.workspaceFolder) cliArgs.push("--workspace-folder", args.workspaceFolder) + if (args.workspaceFolder) + cliArgs.push("--workspace-folder", args.workspaceFolder) if (args.devcontainerPath) cliArgs.push("--devcontainer-path", args.devcontainerPath) if (args.prebuildRepository) @@ -654,10 +681,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_stop", async (_event, args: { workspaceId: string; debug?: boolean }) => { - trackEvent("workspace_stop") + trackEvent("workspace_stop", { + workspace_ref: hashWorkspaceRef(args.workspaceId), + }) await quiesceWorkspace(args.workspaceId) const cmdId = crypto.randomUUID() - const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) + const logPath = logStore.createLogFile( + state.workspaceContext(args.workspaceId), + args.workspaceId, + ) const sink = createLogSink( deps.getMainWindow, cmdId, @@ -688,7 +720,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_delete", async (_event, args: { workspaceId: string; debug?: boolean }) => { - trackEvent("workspace_delete") + trackEvent("workspace_delete", { + workspace_ref: hashWorkspaceRef(args.workspaceId), + }) // Replaces the old `devsy down` command which the CLI overhaul removed: // before invoking delete, terminate every desktop-spawned child tied to // this workspace and wait for them to actually exit. Otherwise late @@ -696,7 +730,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { // an ENOENT crash in the main process. await quiesceWorkspace(args.workspaceId) const cmdId = crypto.randomUUID() - const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) + const logPath = logStore.createLogFile( + state.workspaceContext(args.workspaceId), + args.workspaceId, + ) const sink = createLogSink( deps.getMainWindow, cmdId, @@ -728,9 +765,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_rebuild", async (_event, args: { workspaceId: string; debug?: boolean }) => { - trackEvent("workspace_rebuild") + trackEvent("workspace_rebuild", { + workspace_ref: hashWorkspaceRef(args.workspaceId), + }) const cmdId = crypto.randomUUID() - const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) + const logPath = logStore.createLogFile( + state.workspaceContext(args.workspaceId), + args.workspaceId, + ) const sink = createLogSink( deps.getMainWindow, cmdId, @@ -761,9 +803,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_reset", async (_event, args: { workspaceId: string; debug?: boolean }) => { - trackEvent("workspace_reset") + trackEvent("workspace_reset", { + workspace_ref: hashWorkspaceRef(args.workspaceId), + }) const cmdId = crypto.randomUUID() - const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) + const logPath = logStore.createLogFile( + state.workspaceContext(args.workspaceId), + args.workspaceId, + ) const sink = createLogSink( deps.getMainWindow, cmdId, diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index 043b9439c..3e3c05cbe 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -13,15 +13,26 @@ import { initWorkspaces, destroyWorkspaces } from "$lib/stores/workspaces.js" import { initProviders, destroyProviders } from "$lib/stores/providers.js" import { initMachines, destroyMachines } from "$lib/stores/machines.js" import { initContexts, destroyContexts } from "$lib/stores/contexts.js" -import { initSettings, syncAutoUpdateFromMain, autoUpdate } from "$lib/stores/settings.js" +import { + initSettings, + syncAutoUpdateFromMain, + autoUpdate, +} from "$lib/stores/settings.js" import { terminalCount } from "$lib/stores/terminals.js" import { togglePalette } from "$lib/stores/command-palette.js" import { appReady, analyticsTrack } from "$lib/ipc/commands.js" +import { initSessionTracking } from "$lib/analytics.js" import { location } from "$lib/router.js" import UpdateBadge from "$lib/components/update/UpdateBadge.svelte" import UpdateDialog from "$lib/components/update/UpdateDialog.svelte" -import { initUpdateStore, disposeUpdateStore } from "$lib/stores/updates.svelte.js" -import { initUpdateToasts, bindDialogOpener } from "$lib/components/update/update-toasts.js" +import { + initUpdateStore, + disposeUpdateStore, +} from "$lib/stores/updates.svelte.js" +import { + initUpdateToasts, + bindDialogOpener, +} from "$lib/components/update/update-toasts.js" import DashboardPage from "./pages/DashboardPage.svelte" import WorkspacesPage from "./pages/WorkspacesPage.svelte" @@ -87,14 +98,37 @@ function handleKeydown(e: KeyboardEvent) { } let unsubLocation: (() => void) | undefined +let stopSessionTracking: (() => void) | undefined function normalizeAnalyticsPath(path: string): string { - if (/^\/workspaces\/[^/]+$/.test(path)) return "/workspaces/:id" - if (/^\/providers\/[^/]+$/.test(path)) return "/providers/:id" + // Match id segments but not the static sub-routes that share the prefix. + if (path !== "/workspaces/new" && /^\/workspaces\/[^/]+$/.test(path)) + return "/workspaces/:id" + if (path !== "/providers/add" && /^\/providers\/[^/]+$/.test(path)) + return "/providers/:id" if (/^\/machines\/[^/]+$/.test(path)) return "/machines/:id" return path } +function screenName(path: string): string { + const names: Record = { + "/": "dashboard", + "/workspaces": "workspaces", + "/workspaces/new": "workspace_new", + "/workspaces/:id": "workspace_detail", + "/providers": "providers", + "/providers/add": "provider_add", + "/providers/:id": "provider_detail", + "/machines": "machines", + "/machines/:id": "machine_detail", + "/contexts": "contexts", + "/settings": "settings", + "/ssh-keys": "ssh_keys", + "/terminals": "terminals", + } + return names[path] ?? path +} + onMount(async () => { initWorkspaces() initProviders() @@ -102,8 +136,14 @@ onMount(async () => { initContexts() destroySettings = initSettings() + stopSessionTracking = initSessionTracking() + unsubLocation = location.subscribe((path) => { - analyticsTrack("page_view", { path: normalizeAnalyticsPath(path) }) + const normalized = normalizeAnalyticsPath(path) + analyticsTrack("page_view", { + path: normalized, + screen: screenName(normalized), + }) }) // Signal the backend that the frontend is ready @@ -124,6 +164,7 @@ onMount(async () => { onDestroy(() => { unsubscribeToasts?.() disposeUpdateStore() + stopSessionTracking?.() unsubLocation?.() destroyWorkspaces() destroyProviders() diff --git a/desktop/src/renderer/src/lib/analytics.ts b/desktop/src/renderer/src/lib/analytics.ts new file mode 100644 index 000000000..dd50af4ad --- /dev/null +++ b/desktop/src/renderer/src/lib/analytics.ts @@ -0,0 +1,74 @@ +import { analyticsTrack } from "$lib/ipc/commands.js" + +// A hidden window ends its session after this, so a backgrounded window +// doesn't inflate session duration. +const IDLE_TIMEOUT_MS = 5 * 60 * 1000 + +let sessionStartedAt = 0 +// When the window is hidden, the moment it went hidden; 0 while visible. Used +// as the session end so the idle grace and background time aren't counted. +let hiddenAt = 0 +let idleTimer: ReturnType | null = null +let sessionActive = false + +function nowMs(): number { + return performance.now() +} + +function startSession(): void { + if (sessionActive) return + sessionActive = true + sessionStartedAt = nowMs() + analyticsTrack("session_start") +} + +function endSession(): void { + if (!sessionActive) return + sessionActive = false + const endedAt = hiddenAt || nowMs() + analyticsTrack("session_end", { + duration_ms: Math.round(endedAt - sessionStartedAt), + }) +} + +function clearIdleTimer(): void { + if (idleTimer !== null) { + clearTimeout(idleTimer) + idleTimer = null + } +} + +function handleVisibilityChange(): void { + if (document.visibilityState === "visible") { + clearIdleTimer() + hiddenAt = 0 + startSession() + return + } + // Grace period so quick tab-outs don't fragment one session into many. + hiddenAt = nowMs() + clearIdleTimer() + idleTimer = setTimeout(endSession, IDLE_TIMEOUT_MS) +} + +// Returns a teardown function. Call once on app mount. +export function initSessionTracking(): () => void { + startSession() + document.addEventListener("visibilitychange", handleVisibilityChange) + window.addEventListener("pagehide", endSession) + + return () => { + clearIdleTimer() + document.removeEventListener("visibilitychange", handleVisibilityChange) + window.removeEventListener("pagehide", endSession) + endSession() + } +} + +// Keep feature names low-cardinality; put variable detail in properties. +export function trackEngagement( + feature: string, + properties?: Record, +): void { + analyticsTrack("engagement", { feature, ...properties }) +} diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte index 9c5002d71..aa7a06572 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte @@ -6,10 +6,7 @@ import { providerVersions } from "$lib/stores/providerVersions.js" import { initializingProviders } from "$lib/stores/providers.js" import type { Provider } from "$lib/types/index.js" -let { - provider, - onopen, -}: { provider: Provider; onopen?: () => void } = $props() +let { provider, onopen }: { provider: Provider; onopen?: () => void } = $props() let isInitializing = $derived($initializingProviders.has(provider.name)) @@ -30,7 +27,7 @@ function sourceDisplay(p: Provider): string { + > {/if}
diff --git a/desktop/src/renderer/src/pages/ContextsPage.svelte b/desktop/src/renderer/src/pages/ContextsPage.svelte index 3f777453d..32e7115a8 100644 --- a/desktop/src/renderer/src/pages/ContextsPage.svelte +++ b/desktop/src/renderer/src/pages/ContextsPage.svelte @@ -16,6 +16,7 @@ import { import { contextUse, contextCreate } from "$lib/ipc/commands.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import { trackEngagement } from "$lib/analytics.js" let selectedContext = $state(null) let sheetOpen = $state(false) @@ -57,6 +58,7 @@ async function handleUse(e: Event, name: string) { e.stopPropagation() try { await contextUse(name) + trackEngagement("context_switch") toasts.success(`Switched to context "${name}"`) } catch (err) { toasts.error(`Failed to switch context: ${extractErrorMessage(err)}`) diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index c9063033b..b35e0bcac 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -33,6 +33,7 @@ import UpdatesPanel from "$lib/components/update/UpdatesPanel.svelte" import { Skeleton } from "$lib/components/ui/skeleton/index.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import { trackEngagement } from "$lib/analytics.js" // ── Theme ─────────────────────────────────────────────────────────── @@ -148,6 +149,8 @@ onMount(() => { function saveLocal(key: keyof LocalOptions, value: string | boolean) { saveLocalOption(key, value) ;(local as unknown as Record)[key] = value + // Key only, never the value: values can be paths, URLs, or free text. + trackEngagement("settings_changed", { setting: key }) } function toggleLocal(key: keyof LocalOptions) { diff --git a/desktop/src/renderer/src/pages/TerminalsPage.svelte b/desktop/src/renderer/src/pages/TerminalsPage.svelte index a60e45c52..7f5087f89 100644 --- a/desktop/src/renderer/src/pages/TerminalsPage.svelte +++ b/desktop/src/renderer/src/pages/TerminalsPage.svelte @@ -26,6 +26,7 @@ import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js" import { Separator } from "$lib/components/ui/separator/index.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import { trackEngagement } from "$lib/analytics.js" import { onMount } from "svelte" let activeSessionId: string | undefined = $state() @@ -59,6 +60,7 @@ async function createShell() { const id = await terminalCreate(80, 24) const count = $terminals.filter((t) => t.type === "shell").length + 1 addTerminal({ id, label: `Shell ${count}`, type: "shell" }) + trackEngagement("terminal_open", { type: "shell" }) activeSessionId = id } catch (e) { console.error("Failed to create terminal:", e) @@ -69,6 +71,7 @@ async function createSsh(workspaceId: string) { try { const id = await terminalCreateSsh(workspaceId, 80, 24) addTerminal({ id, label: `SSH: ${workspaceId}`, type: "ssh", workspaceId }) + trackEngagement("terminal_open", { type: "ssh" }) activeSessionId = id toasts.success(`Connected to ${workspaceId}`) } catch (e) { diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 6d3634931..060a1e730 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -46,9 +46,14 @@ import { workspaceLogDelete, } from "$lib/ipc/commands.js" import { onCommandProgress } from "$lib/ipc/events.js" -import { loadLocalOptions, getWorkspaceFolder, setWorkspaceFolder } from "$lib/stores/settings.js" +import { + loadLocalOptions, + getWorkspaceFolder, + setWorkspaceFolder, +} from "$lib/stores/settings.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import { trackEngagement } from "$lib/analytics.js" import type { LogEntry } from "$lib/types/index.js" import type { UnlistenFn } from "$lib/ipc/types.js" import { formatTimestamp } from "$lib/utils/time.js" @@ -184,7 +189,8 @@ onMount(async () => { try { unlisten = await onCommandProgress((progress) => { if (commandId && progress.commandId === commandId) { - const incoming = progress.lines ?? (progress.message ? [progress.message] : []) + const incoming = + progress.lines ?? (progress.message ? [progress.message] : []) if (incoming.length > 0) { pendingLines.push(...incoming) if (flushHandle === null) { @@ -298,6 +304,7 @@ async function handleConnect() { type: "ssh", workspaceId: id, }) + trackEngagement("terminal_open", { type: "ssh" }) activeTab = "terminal" toasts.success(`Connected to ${id}`) } catch (err) { @@ -368,6 +375,7 @@ async function handleStart() { async function handleOpenIde() { const ide = currentIde const folder = customFolder || undefined + trackEngagement("ide_open", { ide }) startStreamingOp("Open IDE") try { commandId = await workspaceUp({ diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 241a2a5fc..898502cd6 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -21,7 +21,7 @@ var Dry = false func NewClient() Client { if posthogAPIKey == "" { - log.Debugf("PostHog API key not configured; analytics disabled") + log.Debugf("analytics disabled: API key not configured") return NewNoopClient() } @@ -29,7 +29,7 @@ func NewClient() Client { Endpoint: posthogEndpoint, }) if err != nil { - log.Debugf("failed to create PostHog client: %v", err) + log.Debugf("failed to initialize analytics client: %v", err) return NewNoopClient() } @@ -69,7 +69,7 @@ func (c *client) RecordEvent(event Event) { Event: eventType, Properties: properties, }); err != nil { - log.Debugf("error enqueuing PostHog event: %v", err) + log.Debugf("error recording analytics event: %v", err) } } @@ -97,17 +97,17 @@ func (c *client) Flush() { if Dry { return } - // posthog-go's Close drains the queue but can only be called once. + // The underlying client's Close drains the queue but can only be called once. c.closeOnce.Do(func() { done := make(chan error, 1) go func() { done <- c.phClient.Close() }() select { case err := <-done: if err != nil { - log.Debugf("error flushing PostHog client: %v", err) + log.Debugf("error flushing analytics client: %v", err) } case <-time.After(flushTimeout): - log.Debugf("PostHog flush timed out after %s; dropping queued events", flushTimeout) + log.Debugf("analytics flush timed out after %s; dropping queued events", flushTimeout) } }) } diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index 750ba8e73..3df9196f3 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -38,6 +38,9 @@ const ( type CLICollector interface { RecordCLI(err error) + + RecordWorkspaceGauge(count int) + SetClient(client devsyclient.BaseWorkspaceClient) // Flush makes sure all events are sent to the backend @@ -162,18 +165,42 @@ func (d *cliCollector) RecordCLI(err error) { eventType = config.BinaryName + "_cli_runner" } - // build the event and record + d.recordEvent(eventType, eventProperties, userProperties) +} + +func (d *cliCollector) RecordWorkspaceGauge(count int) { + timezone, _ := time.Now().Zone() + d.recordEvent( + config.BinaryName+"_workspace_count", + map[string]any{ + "count": count, + "version": version.GetVersion(), + "desktop": os.Getenv(config.EnvUI) == config.BoolTrue, + }, + map[string]any{ + "os_name": runtime.GOOS, + "os_arch": runtime.GOARCH, + "timezone": timezone, + }, + ) +} + +func (d *cliCollector) recordEvent( + eventType string, + eventProperties, userProperties map[string]any, +) { + machineID := GetMachineID() eventPropertiesRaw, _ := json.Marshal(eventProperties) userPropertiesRaw, _ := json.Marshal(userProperties) d.analyticsClient.RecordEvent(analytics.Event{ "event": { "type": eventType, - "machine_id": GetMachineID(), + "machine_id": machineID, "properties": string(eventPropertiesRaw), "timestamp": time.Now().Unix(), }, "user": { - "machine_id": GetMachineID(), + "machine_id": machineID, "properties": string(userPropertiesRaw), "timestamp": time.Now().Unix(), }, diff --git a/pkg/telemetry/noop.go b/pkg/telemetry/noop.go index e92efad5a..ba745dd85 100644 --- a/pkg/telemetry/noop.go +++ b/pkg/telemetry/noop.go @@ -5,6 +5,7 @@ import "github.com/devsy-org/devsy/pkg/client" type noopCollector struct{} func (n *noopCollector) RecordCLI(err error) {} +func (n *noopCollector) RecordWorkspaceGauge(count int) {} func (n *noopCollector) SetClient(client client.BaseWorkspaceClient) {} func (n *noopCollector) Flush() {} diff --git a/pkg/workspace/list.go b/pkg/workspace/list.go index 5dadb3cbe..5a11f0803 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -146,6 +146,15 @@ func ListLocalWorkspaces( return retWorkspaces, nil } +// CountLocalWorkspaces counts on-disk workspaces without any network calls. +func CountLocalWorkspaces(contextName string) (int, error) { + workspaces, err := ListLocalWorkspaces(contextName, false) + if err != nil { + return 0, err + } + return len(workspaces), nil +} + type listProWorkspacesResult struct { workspaces []*providerpkg.Workspace err error From 5bdcff843356e41f05134014690e0220dafb38af Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 15:19:45 -0500 Subject: [PATCH 2/7] style(workspace): satisfy golines on delete gauge --- cmd/workspace/delete.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index 1ee407c4b..7b7af0b62 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -75,7 +75,8 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { } // Emit even when a delete failed; the gauge reflects what remains on disk. - if count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext); countErr != nil { + count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext) + if countErr != nil { log.Debugf("skipping workspace count gauge: %v", countErr) } else { telemetry.FromContext(ctx).RecordWorkspaceGauge(count) From 3b14483b47bb289d86a16dbef361bf63aa34f58f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 15:53:32 -0500 Subject: [PATCH 3/7] fix(port): skip unbindable ports instead of aborting the scan FindAvailablePort bailed out whenever IsAvailable returned an error, so a port already in use (net.Listen fails with address-in-use) aborted the whole search instead of moving to the next candidate. Under CI port contention this flaked TestParseAddressAndPort_Empty. Treat a probe error as 'not this port' and keep scanning. --- pkg/port/port.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pkg/port/port.go b/pkg/port/port.go index 0bf26a774..808aae7c6 100644 --- a/pkg/port/port.go +++ b/pkg/port/port.go @@ -9,14 +9,11 @@ import ( func FindAvailablePort(start int) (int, error) { for i := start; i < start+1000; i++ { - available, err := IsAvailable("localhost:" + strconv.Itoa(i)) - if err != nil { - return 0, err - } else if !available { - continue + // A probe error means this port can't be bound (e.g. already in use), + // so keep scanning rather than aborting the whole search. + if available, err := IsAvailable("localhost:" + strconv.Itoa(i)); err == nil && available { + return i, nil } - - return i, nil } return 0, fmt.Errorf("couldn't find an available port") From 0da07d0bbe6e79f7b3c7b4a6e48e04719c498dd6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 17:06:21 -0500 Subject: [PATCH 4/7] fix(telemetry): flatten event properties so they reach the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordEvent stored each property map as a JSON string under the same "properties" key in both the event and user payloads. buildProperties merges those two maps into one flat property set, so the user "properties" string overwrote the event "properties" string — dropping every event-specific field (command, version, is_ci, count) and sending the rest as an opaque JSON blob instead of queryable columns. Flatten both property maps directly into their payloads and exclude reserved routing keys (type, machine_id, timestamp) when building properties. Add regression tests for the merge and reserved-key handling. --- pkg/telemetry/analytics/client.go | 10 ++++- pkg/telemetry/analytics/client_test.go | 61 ++++++++++++++++++++++++++ pkg/telemetry/collect.go | 35 +++++++++------ 3 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 pkg/telemetry/analytics/client_test.go diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 898502cd6..572fe18fc 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -73,18 +73,24 @@ func (c *client) RecordEvent(event Event) { } } +// Reserved keys carry event routing/identity, not analytics properties, so +// they are excluded from the flattened property set. +func isReservedKey(k string) bool { + return k == "type" || k == "machine_id" || k == "timestamp" +} + func buildProperties(event Event) posthog.Properties { properties := posthog.NewProperties() for k, v := range event["event"] { - if k == "machine_id" || k == "timestamp" { + if isReservedKey(k) { continue } properties.Set(k, v) } for k, v := range event["user"] { - if k == "machine_id" || k == "timestamp" { + if isReservedKey(k) { continue } properties.Set(k, v) diff --git a/pkg/telemetry/analytics/client_test.go b/pkg/telemetry/analytics/client_test.go new file mode 100644 index 000000000..170f92954 --- /dev/null +++ b/pkg/telemetry/analytics/client_test.go @@ -0,0 +1,61 @@ +package analytics + +import "testing" + +func TestBuildProperties_MergesEventAndUserWithoutCollision(t *testing.T) { + event := Event{ + "event": { + "type": "devsy_workspace_count", + "machine_id": "m", + "timestamp": int64(1), + "count": 7, + "version": "1.2.3", + }, + "user": { + "machine_id": "m", + "timestamp": int64(1), + "os_name": "darwin", + "os_arch": "arm64", + }, + } + + got := buildProperties(event) + + want := map[string]any{ + "count": 7, + "version": "1.2.3", + "os_name": "darwin", + "os_arch": "arm64", + } + for k, v := range want { + if got[k] != v { + t.Errorf("property %q = %v, want %v", k, got[k], v) + } + } +} + +func TestBuildProperties_ExcludesReservedKeys(t *testing.T) { + event := Event{ + "event": { + "type": "devsy_cli", + "machine_id": "m", + "timestamp": int64(1), + "command": "up", + }, + "user": { + "machine_id": "m", + "timestamp": int64(1), + }, + } + + got := buildProperties(event) + + for _, reserved := range []string{"type", "machine_id", "timestamp"} { + if _, ok := got[reserved]; ok { + t.Errorf("reserved key %q should not appear in properties", reserved) + } + } + if got["command"] != "up" { + t.Errorf("command = %v, want up", got["command"]) + } +} diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index 3df9196f3..21b9891f8 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -2,7 +2,6 @@ package telemetry import ( "context" - "encoding/json" "os" "runtime" "time" @@ -190,20 +189,28 @@ func (d *cliCollector) recordEvent( eventProperties, userProperties map[string]any, ) { machineID := GetMachineID() - eventPropertiesRaw, _ := json.Marshal(eventProperties) - userPropertiesRaw, _ := json.Marshal(userProperties) + timestamp := time.Now().Unix() + + // Flatten properties into each payload so the backend receives queryable + // fields. Reserved keys are set last so a stray property can't shadow them. + eventPayload := map[string]any{} + for k, v := range eventProperties { + eventPayload[k] = v + } + eventPayload["type"] = eventType + eventPayload["machine_id"] = machineID + eventPayload["timestamp"] = timestamp + + userPayload := map[string]any{} + for k, v := range userProperties { + userPayload[k] = v + } + userPayload["machine_id"] = machineID + userPayload["timestamp"] = timestamp + d.analyticsClient.RecordEvent(analytics.Event{ - "event": { - "type": eventType, - "machine_id": machineID, - "properties": string(eventPropertiesRaw), - "timestamp": time.Now().Unix(), - }, - "user": { - "machine_id": machineID, - "properties": string(userPropertiesRaw), - "timestamp": time.Now().Unix(), - }, + "event": eventPayload, + "user": userPayload, }) } From 43661ae96a9cf126b58c7e69b6f5bcac07d9766f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 17:38:18 -0500 Subject: [PATCH 5/7] style(telemetry): extract reserved payload key constants and use maps.Copy Address goconst (repeated "type"/"machine_id"/"timestamp" literals) and modernize (map copy loop) lint findings: add KeyType/KeyMachineID/KeyTimestamp constants in the analytics package, use them across producer, consumer, and tests, and copy property maps with maps.Copy. --- pkg/telemetry/analytics/client.go | 6 +++--- pkg/telemetry/analytics/client_test.go | 22 +++++++++++----------- pkg/telemetry/analytics/types.go | 8 ++++++++ pkg/telemetry/collect.go | 19 ++++++++----------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 572fe18fc..42977a6dd 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -47,8 +47,8 @@ func (c *client) RecordEvent(event Event) { return } - machineID, _ := eventData["machine_id"].(string) - eventType, _ := eventData["type"].(string) + machineID, _ := eventData[KeyMachineID].(string) + eventType, _ := eventData[KeyType].(string) properties := buildProperties(event) if Dry { @@ -76,7 +76,7 @@ func (c *client) RecordEvent(event Event) { // Reserved keys carry event routing/identity, not analytics properties, so // they are excluded from the flattened property set. func isReservedKey(k string) bool { - return k == "type" || k == "machine_id" || k == "timestamp" + return k == KeyType || k == KeyMachineID || k == KeyTimestamp } func buildProperties(event Event) posthog.Properties { diff --git a/pkg/telemetry/analytics/client_test.go b/pkg/telemetry/analytics/client_test.go index 170f92954..b1aaf630a 100644 --- a/pkg/telemetry/analytics/client_test.go +++ b/pkg/telemetry/analytics/client_test.go @@ -5,15 +5,15 @@ import "testing" func TestBuildProperties_MergesEventAndUserWithoutCollision(t *testing.T) { event := Event{ "event": { - "type": "devsy_workspace_count", - "machine_id": "m", - "timestamp": int64(1), + KeyType: "devsy_workspace_count", + KeyMachineID: "m", + KeyTimestamp: int64(1), "count": 7, "version": "1.2.3", }, "user": { - "machine_id": "m", - "timestamp": int64(1), + KeyMachineID: "m", + KeyTimestamp: int64(1), "os_name": "darwin", "os_arch": "arm64", }, @@ -37,20 +37,20 @@ func TestBuildProperties_MergesEventAndUserWithoutCollision(t *testing.T) { func TestBuildProperties_ExcludesReservedKeys(t *testing.T) { event := Event{ "event": { - "type": "devsy_cli", - "machine_id": "m", - "timestamp": int64(1), + KeyType: "devsy_cli", + KeyMachineID: "m", + KeyTimestamp: int64(1), "command": "up", }, "user": { - "machine_id": "m", - "timestamp": int64(1), + KeyMachineID: "m", + KeyTimestamp: int64(1), }, } got := buildProperties(event) - for _, reserved := range []string{"type", "machine_id", "timestamp"} { + for _, reserved := range []string{KeyType, KeyMachineID, KeyTimestamp} { if _, ok := got[reserved]; ok { t.Errorf("reserved key %q should not appear in properties", reserved) } diff --git a/pkg/telemetry/analytics/types.go b/pkg/telemetry/analytics/types.go index ac7e5034b..495c9bc06 100644 --- a/pkg/telemetry/analytics/types.go +++ b/pkg/telemetry/analytics/types.go @@ -1,5 +1,13 @@ package analytics +// Reserved payload keys that carry event routing/identity rather than +// analytics properties. +const ( + KeyType = "type" + KeyMachineID = "machine_id" + KeyTimestamp = "timestamp" +) + type Event map[string]map[string]any type Client interface { diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index 21b9891f8..49ab8c496 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "maps" "os" "runtime" "time" @@ -194,19 +195,15 @@ func (d *cliCollector) recordEvent( // Flatten properties into each payload so the backend receives queryable // fields. Reserved keys are set last so a stray property can't shadow them. eventPayload := map[string]any{} - for k, v := range eventProperties { - eventPayload[k] = v - } - eventPayload["type"] = eventType - eventPayload["machine_id"] = machineID - eventPayload["timestamp"] = timestamp + maps.Copy(eventPayload, eventProperties) + eventPayload[analytics.KeyType] = eventType + eventPayload[analytics.KeyMachineID] = machineID + eventPayload[analytics.KeyTimestamp] = timestamp userPayload := map[string]any{} - for k, v := range userProperties { - userPayload[k] = v - } - userPayload["machine_id"] = machineID - userPayload["timestamp"] = timestamp + maps.Copy(userPayload, userProperties) + userPayload[analytics.KeyMachineID] = machineID + userPayload[analytics.KeyTimestamp] = timestamp d.analyticsClient.RecordEvent(analytics.Event{ "event": eventPayload, From 037c583e27e48e53b2b77aa512ba9e8ed7476a23 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 18:41:44 -0500 Subject: [PATCH 6/7] chore: cleanup comments --- cmd/workspace/delete.go | 2 -- desktop/src/main/analytics.ts | 3 --- desktop/src/renderer/src/lib/analytics.ts | 2 +- .../renderer/src/pages/SettingsPage.svelte | 19 ------------------- pkg/port/port.go | 2 -- pkg/telemetry/analytics/client.go | 3 +-- pkg/telemetry/analytics/types.go | 3 +-- pkg/telemetry/collect.go | 6 ------ pkg/workspace/list.go | 1 - 9 files changed, 3 insertions(+), 38 deletions(-) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index 7b7af0b62..c9506666a 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -10,7 +10,6 @@ import ( "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" - "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -74,7 +73,6 @@ func (cmd *DeleteCmd) Run(cobraCmd *cobra.Command, args []string) error { err = cmd.deleteMultiple(ctx, devsyConfig, args) } - // Emit even when a delete failed; the gauge reflects what remains on disk. count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext) if countErr != nil { log.Debugf("skipping workspace count gauge: %v", countErr) diff --git a/desktop/src/main/analytics.ts b/desktop/src/main/analytics.ts index 2d6c526af..00055166d 100644 --- a/desktop/src/main/analytics.ts +++ b/desktop/src/main/analytics.ts @@ -18,9 +18,6 @@ export function getAnalyticsDistinctId(): string { return distinctId || getDistinctId() } -// Stable key for correlating a workspace's events; never the raw name, which -// can leak repo names or paths. Returns "" when analytics is off so callers -// don't pay to derive the machine id for a ref that would be discarded. export function hashWorkspaceRef(workspaceId: string): string { if (!client) return "" return createHmac("sha256", getAnalyticsDistinctId()) diff --git a/desktop/src/renderer/src/lib/analytics.ts b/desktop/src/renderer/src/lib/analytics.ts index dd50af4ad..099d75256 100644 --- a/desktop/src/renderer/src/lib/analytics.ts +++ b/desktop/src/renderer/src/lib/analytics.ts @@ -1,7 +1,7 @@ import { analyticsTrack } from "$lib/ipc/commands.js" // A hidden window ends its session after this, so a backgrounded window -// doesn't inflate session duration. +// does not inflate session duration. const IDLE_TIMEOUT_MS = 5 * 60 * 1000 let sessionStartedAt = 0 diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index b35e0bcac..052262268 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -35,8 +35,6 @@ import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" import { trackEngagement } from "$lib/analytics.js" -// ── Theme ─────────────────────────────────────────────────────────── - const THEMES: { value: Theme; label: string }[] = [ { value: "light", label: "Light" }, { value: "dark", label: "Dark" }, @@ -48,16 +46,12 @@ function setTheme(value: Theme) { applyTheme(value) } -// ── Color Scheme ──────────────────────────────────────────────────── - const COLOR_SCHEMES: { value: ColorScheme; label: string; swatch: string }[] = [ { value: "default", label: "White", swatch: "bg-foreground" }, { value: "emerald", label: "Emerald", swatch: "bg-emerald-600" }, { value: "purple", label: "Purple", swatch: "bg-purple-600" }, ] -// ── UI Scale ──────────────────────────────────────────────────────── - const UI_SCALES: { value: UIScale; label: string }[] = [ { value: "xs", label: "Extra Small" }, { value: "sm", label: "Small" }, @@ -71,8 +65,6 @@ function setUIScale(value: UIScale) { applyUIScale(value) } -// ── IDE Options ───────────────────────────────────────────────────── - const IDE_OPTIONS = [ { value: "none", label: "None" }, { value: "vscode", label: "VS Code" }, @@ -101,8 +93,6 @@ const IDE_OPTIONS = [ { value: "rstudio", label: "RStudio Server" }, ] -// ── State ─────────────────────────────────────────────────────────── - let activeTab = $state("general") let loading = $state(true) let saving = $state(false) @@ -117,7 +107,6 @@ let filteredIdes = $derived( : IDE_OPTIONS, ) -// Local-only options (not stored in Devsy CLI) let local = $state({ debugFlag: false, sshKeyPath: "", @@ -129,8 +118,6 @@ let local = $state({ experimentalMultiDevcontainer: false, }) -// ── Keyboard shortcuts ────────────────────────────────────────────── - const shortcuts = [ { keys: "Cmd/Ctrl + K", action: "Open command palette" }, { keys: "Cmd/Ctrl + N", action: "New workspace" }, @@ -138,8 +125,6 @@ const shortcuts = [ { keys: "Escape", action: "Close dialogs and palette" }, ] -// ── Load / Save ───────────────────────────────────────────────────── - onMount(() => { local = loadLocalOptions() localOptionsStore.set(local) @@ -149,7 +134,6 @@ onMount(() => { function saveLocal(key: keyof LocalOptions, value: string | boolean) { saveLocalOption(key, value) ;(local as unknown as Record)[key] = value - // Key only, never the value: values can be paths, URLs, or free text. trackEngagement("settings_changed", { setting: key }) } @@ -170,7 +154,6 @@ function toggleLocal(key: keyof LocalOptions) { Experimental - {#if loading}
@@ -281,7 +264,6 @@ function toggleLocal(key: keyof LocalOptions) { {/if} -
@@ -346,7 +328,6 @@ function toggleLocal(key: keyof LocalOptions) {
-
diff --git a/pkg/port/port.go b/pkg/port/port.go index 808aae7c6..3c09976fd 100644 --- a/pkg/port/port.go +++ b/pkg/port/port.go @@ -9,8 +9,6 @@ import ( func FindAvailablePort(start int) (int, error) { for i := start; i < start+1000; i++ { - // A probe error means this port can't be bound (e.g. already in use), - // so keep scanning rather than aborting the whole search. if available, err := IsAvailable("localhost:" + strconv.Itoa(i)); err == nil && available { return i, nil } diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 42977a6dd..432a848e0 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -73,8 +73,7 @@ func (c *client) RecordEvent(event Event) { } } -// Reserved keys carry event routing/identity, not analytics properties, so -// they are excluded from the flattened property set. +// Exclude reserved keys for event routing/identity. func isReservedKey(k string) bool { return k == KeyType || k == KeyMachineID || k == KeyTimestamp } diff --git a/pkg/telemetry/analytics/types.go b/pkg/telemetry/analytics/types.go index 495c9bc06..b7de7173c 100644 --- a/pkg/telemetry/analytics/types.go +++ b/pkg/telemetry/analytics/types.go @@ -1,7 +1,6 @@ package analytics -// Reserved payload keys that carry event routing/identity rather than -// analytics properties. +// Reserved routing/identity keys. const ( KeyType = "type" KeyMachineID = "machine_id" diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index 49ab8c496..15b3e1feb 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -38,12 +38,8 @@ const ( type CLICollector interface { RecordCLI(err error) - RecordWorkspaceGauge(count int) - SetClient(client devsyclient.BaseWorkspaceClient) - - // Flush makes sure all events are sent to the backend Flush() } @@ -192,8 +188,6 @@ func (d *cliCollector) recordEvent( machineID := GetMachineID() timestamp := time.Now().Unix() - // Flatten properties into each payload so the backend receives queryable - // fields. Reserved keys are set last so a stray property can't shadow them. eventPayload := map[string]any{} maps.Copy(eventPayload, eventProperties) eventPayload[analytics.KeyType] = eventType diff --git a/pkg/workspace/list.go b/pkg/workspace/list.go index 5a11f0803..a0cde653e 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -146,7 +146,6 @@ func ListLocalWorkspaces( return retWorkspaces, nil } -// CountLocalWorkspaces counts on-disk workspaces without any network calls. func CountLocalWorkspaces(contextName string) (int, error) { workspaces, err := ListLocalWorkspaces(contextName, false) if err != nil { From b043b082652162cb74872300923e108717951976 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 20 Jul 2026 18:51:31 -0500 Subject: [PATCH 7/7] fix: add telemetry package --- cmd/workspace/delete.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index c9506666a..defa6b283 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" )