diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index b3efbb5be..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" ) @@ -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) + count, countErr := workspace.CountLocalWorkspaces(devsyConfig.DefaultContext) + if 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..00055166d 100644 --- a/desktop/src/main/analytics.ts +++ b/desktop/src/main/analytics.ts @@ -18,12 +18,25 @@ export function getAnalyticsDistinctId(): string { return distinctId || getDistinctId() } +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 +46,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..099d75256 --- /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 +// does not 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..052262268 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -33,8 +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" - -// ── Theme ─────────────────────────────────────────────────────────── +import { trackEngagement } from "$lib/analytics.js" const THEMES: { value: Theme; label: string }[] = [ { value: "light", label: "Light" }, @@ -47,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" }, @@ -70,8 +65,6 @@ function setUIScale(value: UIScale) { applyUIScale(value) } -// ── IDE Options ───────────────────────────────────────────────────── - const IDE_OPTIONS = [ { value: "none", label: "None" }, { value: "vscode", label: "VS Code" }, @@ -100,8 +93,6 @@ const IDE_OPTIONS = [ { value: "rstudio", label: "RStudio Server" }, ] -// ── State ─────────────────────────────────────────────────────────── - let activeTab = $state("general") let loading = $state(true) let saving = $state(false) @@ -116,7 +107,6 @@ let filteredIdes = $derived( : IDE_OPTIONS, ) -// Local-only options (not stored in Devsy CLI) let local = $state({ debugFlag: false, sshKeyPath: "", @@ -128,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" }, @@ -137,8 +125,6 @@ const shortcuts = [ { keys: "Escape", action: "Close dialogs and palette" }, ] -// ── Load / Save ───────────────────────────────────────────────────── - onMount(() => { local = loadLocalOptions() localOptionsStore.set(local) @@ -148,6 +134,7 @@ onMount(() => { function saveLocal(key: keyof LocalOptions, value: string | boolean) { saveLocalOption(key, value) ;(local as unknown as Record)[key] = value + trackEngagement("settings_changed", { setting: key }) } function toggleLocal(key: keyof LocalOptions) { @@ -167,7 +154,6 @@ function toggleLocal(key: keyof LocalOptions) { Experimental - {#if loading}
@@ -278,7 +264,6 @@ function toggleLocal(key: keyof LocalOptions) { {/if} -
@@ -343,7 +328,6 @@ 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/port/port.go b/pkg/port/port.go index 0bf26a774..3c09976fd 100644 --- a/pkg/port/port.go +++ b/pkg/port/port.go @@ -9,14 +9,9 @@ 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 + 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") diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 241a2a5fc..432a848e0 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() } @@ -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 { @@ -69,22 +69,27 @@ 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) } } +// Exclude reserved keys for event routing/identity. +func isReservedKey(k string) bool { + return k == KeyType || k == KeyMachineID || k == KeyTimestamp +} + 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) @@ -97,17 +102,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/analytics/client_test.go b/pkg/telemetry/analytics/client_test.go new file mode 100644 index 000000000..b1aaf630a --- /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": { + KeyType: "devsy_workspace_count", + KeyMachineID: "m", + KeyTimestamp: int64(1), + "count": 7, + "version": "1.2.3", + }, + "user": { + KeyMachineID: "m", + KeyTimestamp: 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": { + KeyType: "devsy_cli", + KeyMachineID: "m", + KeyTimestamp: int64(1), + "command": "up", + }, + "user": { + KeyMachineID: "m", + KeyTimestamp: int64(1), + }, + } + + got := buildProperties(event) + + for _, reserved := range []string{KeyType, KeyMachineID, KeyTimestamp} { + 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/analytics/types.go b/pkg/telemetry/analytics/types.go index ac7e5034b..b7de7173c 100644 --- a/pkg/telemetry/analytics/types.go +++ b/pkg/telemetry/analytics/types.go @@ -1,5 +1,12 @@ package analytics +// Reserved routing/identity keys. +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 750ba8e73..15b3e1feb 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -2,7 +2,7 @@ package telemetry import ( "context" - "encoding/json" + "maps" "os" "runtime" "time" @@ -38,9 +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() } @@ -162,21 +161,47 @@ func (d *cliCollector) RecordCLI(err error) { eventType = config.BinaryName + "_cli_runner" } - // build the event and record - eventPropertiesRaw, _ := json.Marshal(eventProperties) - userPropertiesRaw, _ := json.Marshal(userProperties) - d.analyticsClient.RecordEvent(analytics.Event{ - "event": { - "type": eventType, - "machine_id": GetMachineID(), - "properties": string(eventPropertiesRaw), - "timestamp": time.Now().Unix(), + 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, }, - "user": { - "machine_id": GetMachineID(), - "properties": string(userPropertiesRaw), - "timestamp": time.Now().Unix(), + 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() + timestamp := time.Now().Unix() + + eventPayload := map[string]any{} + maps.Copy(eventPayload, eventProperties) + eventPayload[analytics.KeyType] = eventType + eventPayload[analytics.KeyMachineID] = machineID + eventPayload[analytics.KeyTimestamp] = timestamp + + userPayload := map[string]any{} + maps.Copy(userPayload, userProperties) + userPayload[analytics.KeyMachineID] = machineID + userPayload[analytics.KeyTimestamp] = timestamp + + d.analyticsClient.RecordEvent(analytics.Event{ + "event": eventPayload, + "user": userPayload, }) } 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..a0cde653e 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -146,6 +146,14 @@ func ListLocalWorkspaces( return retWorkspaces, nil } +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