From 36e5144587330ad53eb82177d151f2658a958977 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 16 May 2026 10:35:40 -0500 Subject: [PATCH 1/3] feat(telemetry): replace Loft analytics with PostHog and add Netlify update hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A: Desktop update hosting on Netlify via dl.devsy.sh - Configure electron-builder for dual publish (generic + github) - Add Netlify site config (sites/dl-devsy-sh/) - Add CI step to deploy update metadata (latest*.yml) to Netlify Part B: Replace Loft analytics with PostHog - Replace analytics.loft.rocks HTTP client with PostHog Go SDK - Remove custom double-buffering (PostHog SDK handles batching) - Add posthog-node to desktop app with machine ID hashing - Add IPC bridge for renderer → main analytics tracking - Instrument app lifecycle, update lifecycle, workspace CRUD, provider actions, and page navigation events - Update telemetry documentation for PostHog BREAKING CHANGE: analytics.loft.rocks endpoint removed --- .github/workflows/release.yml | 11 ++ desktop/electron-builder.yml | 9 +- desktop/package-lock.json | 36 ++++ desktop/package.json | 1 + desktop/src/main/analytics.ts | 59 +++++++ desktop/src/main/index.ts | 6 + desktop/src/main/ipc.ts | 19 ++ desktop/src/main/machine-id.ts | 37 ++++ desktop/src/main/updater.ts | 13 ++ desktop/src/renderer/src/App.svelte | 10 +- desktop/src/renderer/src/lib/ipc/commands.ts | 8 + desktop/src/renderer/src/lib/ipc/mock.ts | 3 + docs/pages/other-topics/telemetry.mdx | 45 ++--- go.mod | 3 + go.sum | 6 + pkg/telemetry/analytics/buffer.go | 62 ------- pkg/telemetry/analytics/client.go | 173 +++++-------------- pkg/telemetry/analytics/types.go | 4 - sites/dl-devsy-sh/netlify.toml | 8 + sites/dl-devsy-sh/public/desktop/.gitkeep | 0 20 files changed, 296 insertions(+), 217 deletions(-) create mode 100644 desktop/src/main/analytics.ts create mode 100644 desktop/src/main/machine-id.ts delete mode 100644 pkg/telemetry/analytics/buffer.go create mode 100644 sites/dl-devsy-sh/netlify.toml create mode 100644 sites/dl-devsy-sh/public/desktop/.gitkeep diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d6c5881c..794b66cf8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,6 +167,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} + - name: deploy update metadata to Netlify + if: runner.os == 'Linux' + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + run: | + npm install -g netlify-cli + mkdir -p netlify-deploy/desktop + cp desktop/release/latest*.yml netlify-deploy/desktop/ 2>/dev/null || true + netlify deploy --prod --dir=netlify-deploy --site="$NETLIFY_SITE_ID" + - name: upload cli assets uses: softprops/action-gh-release@v3 if: runner.os == 'Linux' diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index b4054f3a0..ccf78dc45 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -95,6 +95,9 @@ protocols: - devsy publish: - provider: github - owner: devsy-org - repo: devsy + - provider: generic + url: https://dl.devsy.sh/desktop + useMultipleRangeRequest: false + - provider: github + owner: devsy-org + repo: devsy diff --git a/desktop/package-lock.json b/desktop/package-lock.json index d3b1f2a48..7116020c8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -12,6 +12,7 @@ "chokidar": "^4.0.0", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", + "posthog-node": "^5.34.2", "svelte-spa-router": "^5.0.1" }, "devDependencies": { @@ -1674,6 +1675,21 @@ "node": ">=18" } }, + "node_modules/@posthog/core": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.2.tgz", + "integrity": "sha512-DYhR0Sl7pVdUXa+C9poCVjTj3D6SI9P7RLhIhr74YyHeHuCGL/MZsDEWcz3ul3qHDIhZU9myIUjID890QiQw+g==", + "license": "MIT", + "dependencies": { + "@posthog/types": "1.373.5" + } + }, + "node_modules/@posthog/types": { + "version": "1.373.5", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.373.5.tgz", + "integrity": "sha512-K7STCnRG/WBE1q0BwEkIcrJB5OqECaymsQj6Hp4Ntvaek4dqHkZGfp6hxwIPqQPjlOXwidwPLo+XGsn+CoZUyw==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", @@ -7265,6 +7281,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-node": { + "version": "5.34.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.34.2.tgz", + "integrity": "sha512-lGp7zyyvzNZqrto3CPq3nQzVn9ZUg5tApE8jNh12Cu8kOqT9KTABZ8kMgiybfzxVttSdE3m25RsBPLBHkxCWDg==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.29.2" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/desktop/package.json b/desktop/package.json index aaade3dbd..7671faab1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -24,6 +24,7 @@ "chokidar": "^4.0.0", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", + "posthog-node": "^5.34.2", "svelte-spa-router": "^5.0.1" }, "devDependencies": { diff --git a/desktop/src/main/analytics.ts b/desktop/src/main/analytics.ts new file mode 100644 index 000000000..880154897 --- /dev/null +++ b/desktop/src/main/analytics.ts @@ -0,0 +1,59 @@ +import { createHash, createHmac } from "node:crypto" +import { homedir, platform, arch } from "node:os" +import { PostHog } from "posthog-node" +import { app } from "electron" +import { machineIdSync } from "./machine-id.js" + +// Replace with a real PostHog project API key. +const POSTHOG_API_KEY = "phc_PLACEHOLDER" +const POSTHOG_HOST = "https://us.i.posthog.com" + +let client: PostHog | null = null +let distinctId = "" + +function getDistinctId(): string { + const id = machineIdSync() + const home = homedir() + const mac = createHmac("sha256", id) + mac.update(home) + return mac.digest("hex") +} + +function isTelemetryDisabled(): boolean { + return process.env.DEVSY_DISABLE_TELEMETRY === "true" +} + +export function initAnalytics(): void { + if (isTelemetryDisabled()) return + + distinctId = getDistinctId() + client = new PostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + flushAt: 20, + flushInterval: 30_000, + }) +} + +export function trackEvent( + name: string, + properties?: Record, +): void { + if (!client || isTelemetryDisabled()) return + + client.capture({ + distinctId, + event: name, + properties: { + app_version: app.getVersion(), + os_name: platform(), + os_arch: arch(), + ...properties, + }, + }) +} + +export async function shutdownAnalytics(): Promise { + if (!client) return + await client.shutdown() + client = null +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index d503c194f..9e14cab52 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,5 +1,6 @@ import { join } from "node:path" import { app, BrowserWindow, session } from "electron" +import { initAnalytics, shutdownAnalytics, trackEvent } from "./analytics.js" import { CliRunner } from "./cli.js" import { registerIpcHandlers } from "./ipc.js" import { LogStore } from "./log-store.js" @@ -90,6 +91,9 @@ function createWindow(): void { } app.whenReady().then(() => { + initAnalytics() + trackEvent("app_open") + // Register devsy:// as the default protocol handler for this app. app.setAsDefaultProtocolClient(PROTOCOL) @@ -136,6 +140,8 @@ app.whenReady().then(() => { app.on("before-quit", () => { ;(app as typeof app & { isQuitting?: boolean }).isQuitting = true + trackEvent("app_close") + shutdownAnalytics().catch(() => {}) ptyManager.destroyAll() }) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 60c739749..b3ecccea7 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -6,6 +6,7 @@ import { join } from "node:path" import { promisify } from "node:util" import type { BrowserWindow } from "electron" import { ipcMain } from "electron" +import { trackEvent } from "./analytics.js" import type { CliRunner } from "./cli.js" import type { LogStore } from "./log-store.js" import type { PtyManager } from "./pty.js" @@ -71,6 +72,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ipcMain.handle( "provider_add", async (_event, args: { name: string; source?: string }) => { + trackEvent("provider_add") const src = args.source ?? args.name const cliArgs = ["provider", "add", src, "--use=false"] if (args.source) { @@ -81,6 +83,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ) ipcMain.handle("provider_delete", async (_event, args: { name: string }) => { + trackEvent("provider_remove") await cli.runRaw(["provider", "delete", args.name]) }) @@ -261,6 +264,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { debug?: boolean }, ) => { + trackEvent("workspace_create", { provider: args.provider }) const cliArgs = ["up", args.source] if (args.workspaceId) cliArgs.push("--id", args.workspaceId) if (args.provider) cliArgs.push("--provider", args.provider) @@ -304,6 +308,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ipcMain.handle( "workspace_stop", async (_event, args: { workspaceId: string; debug?: boolean }) => { + trackEvent("workspace_stop") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(args.workspaceId) const win = deps.getMainWindow() @@ -343,6 +348,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ipcMain.handle( "workspace_delete", async (_event, args: { workspaceId: string; debug?: boolean }) => { + trackEvent("workspace_delete") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(args.workspaceId) const win = deps.getMainWindow() @@ -383,6 +389,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ipcMain.handle( "workspace_rebuild", async (_event, args: { workspaceId: string; debug?: boolean }) => { + trackEvent("workspace_rebuild") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(args.workspaceId) const win = deps.getMainWindow() @@ -422,6 +429,7 @@ export function registerIpcHandlers(deps: IpcDependencies): void { ipcMain.handle( "workspace_reset", async (_event, args: { workspaceId: string; debug?: boolean }) => { + trackEvent("workspace_reset") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(args.workspaceId) const win = deps.getMainWindow() @@ -636,4 +644,15 @@ export function registerIpcHandlers(deps: IpcDependencies): void { return key }, ) + + // ── Analytics ── + ipcMain.handle( + "analytics_track", + async ( + _event, + args: { name: string; properties?: Record }, + ) => { + trackEvent(args.name, args.properties) + }, + ) } diff --git a/desktop/src/main/machine-id.ts b/desktop/src/main/machine-id.ts new file mode 100644 index 000000000..d42d93673 --- /dev/null +++ b/desktop/src/main/machine-id.ts @@ -0,0 +1,37 @@ +import { execSync } from "node:child_process" +import { createHash } from "node:crypto" +import { platform } from "node:os" + +export function machineIdSync(): string { + try { + const os = platform() + let raw: string + + if (os === "linux") { + raw = execSync("cat /etc/machine-id || cat /var/lib/dbus/machine-id", { + encoding: "utf-8", + timeout: 3000, + }).trim() + } else if (os === "darwin") { + const output = execSync( + "ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/'", + { encoding: "utf-8", timeout: 3000 }, + ) + const match = output.match(/"([^"]+)"$/) + raw = match?.[1] ?? "unknown" + } else if (os === "win32") { + const output = execSync( + "reg query HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid", + { encoding: "utf-8", timeout: 3000 }, + ) + const match = output.match(/REG_SZ\s+(.+)/) + raw = match?.[1]?.trim() ?? "unknown" + } else { + raw = "unknown" + } + + return createHash("sha256").update(raw).digest("hex") + } catch { + return "unknown" + } +} diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index f5f5cd172..cd2297cc4 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -1,4 +1,5 @@ import { dialog, type BrowserWindow } from "electron" +import { trackEvent } from "./analytics.js" export async function initAutoUpdater( getMainWindow: () => BrowserWindow | null, @@ -8,7 +9,17 @@ export async function initAutoUpdater( autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true + autoUpdater.on("checking-for-update", () => { + trackEvent("update_check") + }) + + autoUpdater.on("update-available", (info) => { + trackEvent("update_available", { version: info.version }) + }) + autoUpdater.on("update-downloaded", (info) => { + trackEvent("update_downloaded", { version: info.version }) + const win = getMainWindow() if (!win) return @@ -23,12 +34,14 @@ export async function initAutoUpdater( }) .then(({ response }) => { if (response === 0) { + trackEvent("update_installed", { version: info.version }) autoUpdater.quitAndInstall() } }) }) autoUpdater.on("error", (err) => { + trackEvent("update_error", { error_type: err.name }) console.error("Auto-update error:", err.message) }) diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index be068f05f..e2c4b894d 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -16,7 +16,8 @@ import { initContexts, destroyContexts } from "$lib/stores/contexts.js" import { initSettings } from "$lib/stores/settings.js" import { terminalCount } from "$lib/stores/terminals.js" import { togglePalette } from "$lib/stores/command-palette.js" -import { appReady } from "$lib/ipc/commands.js" +import { appReady, analyticsTrack } from "$lib/ipc/commands.js" +import { location } from "$lib/router.js" import DashboardPage from "./pages/DashboardPage.svelte" import WorkspacesPage from "./pages/WorkspacesPage.svelte" @@ -79,6 +80,8 @@ function handleKeydown(e: KeyboardEvent) { } } +let unsubLocation: (() => void) | undefined + onMount(() => { initWorkspaces() initProviders() @@ -86,6 +89,10 @@ onMount(() => { initContexts() destroySettings = initSettings() + unsubLocation = location.subscribe((path) => { + analyticsTrack("page_view", { path }) + }) + // Signal the backend that the frontend is ready appReady().catch((err) => { console.warn("[Devsy] appReady failed:", err) @@ -93,6 +100,7 @@ onMount(() => { }) onDestroy(() => { + unsubLocation?.() destroyWorkspaces() destroyProviders() destroyMachines() diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index f74786e41..abdeb2635 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -242,3 +242,11 @@ export async function sshKeyGenerate(params: { }): Promise { return invoke("ssh_key_generate", params) } + +// Analytics +export function analyticsTrack( + name: string, + properties?: Record, +): void { + invoke("analytics_track", { name, properties }).catch(() => {}) +} diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index 74b74e690..f792af0b1 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -261,6 +261,9 @@ const COMMANDS: Record = { terminal_resize: () => undefined, terminal_close: () => undefined, terminal_list: () => [], + + // Analytics + analytics_track: () => undefined, } // ── Mock invoke ────────────────────────────────────────────────────── diff --git a/docs/pages/other-topics/telemetry.mdx b/docs/pages/other-topics/telemetry.mdx index 02f51f019..bca054fb2 100644 --- a/docs/pages/other-topics/telemetry.mdx +++ b/docs/pages/other-topics/telemetry.mdx @@ -14,34 +14,35 @@ To move forward quickly, and ensure long-term maintainability, we will be making ### What are we collecting and how -When a user executes a non-trivial CLI command, one that loads the configuration, the CLI will send an event about the command and basic information about the execution environment (Operating system, CPU architecture, Devsy version, etc.). Another event is sent if a command fails, or when it takes longer than 10 seconds to complete. The events are sent to our self-hosted backend. +Telemetry data is sent to [PostHog](https://posthog.com), a product analytics platform. We use PostHog's official SDKs in both the CLI and the desktop application. + +**CLI telemetry:** When a user executes a non-trivial CLI command, one that loads the configuration, the CLI will send an event about the command and basic information about the execution environment (Operating system, CPU architecture, Devsy version, etc.). Another event is sent if a command fails, or when it takes longer than 10 seconds to complete. + +**Desktop telemetry:** The desktop application tracks application lifecycle events (open/close), update lifecycle (check/download/install), workspace actions (create/start/stop/delete), provider actions (add/remove), and page navigation. No workspace names, URLs, credentials, or file contents are ever collected. + We want to emphasize that we are **not interested in collecting data about individuals** that are using Devsy, we are collecting **only data about how it is being used**. This should be evident from the code that implements telemetry on the CLI side, which is of course open source and included in the same source code repository. ### Telemetry payload example -Below you can find an example of the payload that Devsy CLI would send to our telemetry backend. +Below you can find an example of the payload that Devsy CLI would send to PostHog. -```yaml +```json { - "event":{ - "type":"devsy_cli", # type of event - "machine_id":"3ed2c7...ee308e6", # securely hashed machine ID to de-duplicate information received from the same user - "timestamp":1683878643781772, - "properties": { - "command":"devsy provider delete", # the CLI command that was executed - "provider":"kubernetes", # the default provider - "source_type":"git:", # the workspace source type (git, image, local, container, unknown) - "ide":"vscode", # the IDE used to open a workspace - "desktop":"true", # whether this cli command has been executed by Devsy Desktop or is a direct CLI invocation - "version":"v0.5.29", # the CLI version - "error":"provider 'docker' does not exist" # an error that occurred during command execution - } - }, - "user":{ - "machine_id":"3ed2c7...ee308e6", # securely hashed machine ID to de-duplicate information received from the same user - "arch":"amd64", # CPU architecture - "os":"linux", # Operating system - }, + "event": "devsy_cli", + "distinct_id": "3ed2c7...ee308e6", + "properties": { + "type": "devsy_cli", + "command": "devsy provider delete", + "provider": "kubernetes", + "source_type": "git:", + "ide": "vscode", + "desktop": "true", + "version": "v0.5.29", + "error": "provider 'docker' does not exist", + "os_name": "linux", + "os_arch": "amd64", + "timezone": "UTC" + } } ``` diff --git a/go.mod b/go.mod index c7165f465..721401be5 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pkg/sftp v1.13.10 + github.com/posthog/posthog-go v1.12.5 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -171,6 +172,7 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -187,6 +189,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/in-toto/attestation v1.1.2 // indirect github.com/in-toto/in-toto-golang v0.10.0 // indirect diff --git a/go.sum b/go.sum index e424dd34f..951a87564 100644 --- a/go.sum +++ b/go.sum @@ -344,6 +344,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= @@ -410,6 +412,8 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -572,6 +576,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQraJ8o= +github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= diff --git a/pkg/telemetry/analytics/buffer.go b/pkg/telemetry/analytics/buffer.go deleted file mode 100644 index 365720e3c..000000000 --- a/pkg/telemetry/analytics/buffer.go +++ /dev/null @@ -1,62 +0,0 @@ -package analytics - -import "sync" - -func newEventBuffer(size int) *eventBuffer { - return &eventBuffer{ - bufferSize: size, - buffer: make([]Event, 0, size), - fullChan: make(chan struct{}), - } -} - -type eventBuffer struct { - m sync.Mutex - bufferSize int - buffer []Event - - fullOnce sync.Once - fullChan chan struct{} -} - -func (e *eventBuffer) Drain() []Event { - e.m.Lock() - defer e.m.Unlock() - - e.close() - return e.buffer -} - -func (e *eventBuffer) Full() <-chan struct{} { - return e.fullChan -} - -func (e *eventBuffer) IsFull() bool { - e.m.Lock() - defer e.m.Unlock() - - return len(e.buffer) >= e.bufferSize -} - -func (e *eventBuffer) Append(ev Event) bool { - e.m.Lock() - defer e.m.Unlock() - - wasAdded := false - if len(e.buffer) < e.bufferSize { - e.buffer = append(e.buffer, ev) - wasAdded = true - } - - if len(e.buffer) >= e.bufferSize { - e.close() - } - - return wasAdded -} - -func (e *eventBuffer) close() { - e.fullOnce.Do(func() { - close(e.fullChan) - }) -} diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 4fa9886f4..2a94006c7 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -1,164 +1,87 @@ package analytics import ( - "bytes" - "encoding/json" - "io" - "net/http" - "sync" - "time" - "github.com/devsy-org/devsy/pkg/log" + "github.com/posthog/posthog-go" ) const ( - defaultEndpoint = "https://analytics.loft.rocks/v1/insert" - - eventsCountThreshold = 100 + // Replace with a real PostHog project API key. + posthogAPIKey = "phc_PLACEHOLDER" - maxUploadInterval = 5 * time.Minute - minUploadInterval = 30 * time.Second + posthogEndpoint = "https://us.i.posthog.com" ) var Dry = false func NewClient() Client { - c := &client{ - endpoint: defaultEndpoint, - - buffer: newEventBuffer(eventsCountThreshold), - overflow: newEventBuffer(eventsCountThreshold), - - events: make(chan Event, 100), - httpClient: http.Client{Timeout: 3 * time.Second}, + phClient, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{ + Endpoint: posthogEndpoint, + }) + if err != nil { + log.Debugf("failed to create PostHog client: %v", err) + return NewNoopClient() } - go c.loop() - - return c + return &client{phClient: phClient} } type client struct { - buffer *eventBuffer - overflow *eventBuffer - droppedEvents int - bufferMutex sync.Mutex - - events chan Event - - endpoint string - - httpClient http.Client + phClient posthog.Client } func (c *client) RecordEvent(event Event) { - select { - case c.events <- event: - default: - } -} - -func (c *client) Flush() { - c.bufferMutex.Lock() - isFull := c.buffer.IsFull() - c.bufferMutex.Unlock() - - if !isFull { - startTime := time.Now() - for time.Since(startTime) < 500*time.Millisecond { - time.Sleep(10 * time.Millisecond) - if len(c.events) == 0 { - break - } - } - } - - c.executeUpload(c.exchangeBuffer()) -} - -func (c *client) loop() { - go func() { - for event := range c.events { - c.bufferMutex.Lock() - if !c.buffer.Append(event) && !c.overflow.Append(event) { - c.droppedEvents++ - } - c.bufferMutex.Unlock() - } - }() - - for { - startWait := time.Now() - c.bufferMutex.Lock() - fullChan := c.buffer.Full() - c.bufferMutex.Unlock() - - select { - case <-fullChan: - timeSinceStart := time.Since(startWait) - if timeSinceStart < minUploadInterval { - time.Sleep(minUploadInterval - timeSinceStart) - } - case <-time.After(maxUploadInterval): - } - - c.Flush() - } -} - -func (c *client) executeUpload(buffer []Event) { - if len(buffer) == 0 { + eventData, ok := event["event"] + if !ok { return } - request := &Request{ - Data: buffer, - } + machineID, _ := eventData["machine_id"].(string) + eventType, _ := eventData["type"].(string) + properties := buildProperties(event) if Dry { - marshaled, err := json.MarshalIndent(request, "", " ") - if err != nil { - log.Debugf("failed to marshal analytics request: %v", err) - return - } - log.Infof("analytics request: %s", string(marshaled)) + log.Infof( + "analytics event: type=%s machine_id=%s properties=%v", + eventType, machineID, properties, + ) return } - marshaled, err := json.Marshal(request) - if err != nil { - log.Debugf("failed to marshal analytics request: %v", err) - return + if err := c.phClient.Enqueue(posthog.Capture{ + DistinctId: machineID, + Event: eventType, + Properties: properties, + }); err != nil { + log.Debugf("error enqueuing PostHog event: %v", err) } +} - resp, err := c.httpClient.Post(c.endpoint, "application/json", bytes.NewReader(marshaled)) - if err != nil { - log.Debugf("error sending analytics request: %v", err) - return +func buildProperties(event Event) posthog.Properties { + properties := posthog.NewProperties() + + for k, v := range event["event"] { + if k == "machine_id" || k == "timestamp" { + continue + } + properties.Set(k, v) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - out, err := io.ReadAll(resp.Body) - if err != nil { - log.Debugf("error reading analytics response body: %v", err) - return + for k, v := range event["user"] { + if k == "machine_id" || k == "timestamp" { + continue } - log.Debugf("analytics request returned status %d: %s", resp.StatusCode, string(out)) + properties.Set(k, v) } -} -func (c *client) exchangeBuffer() []Event { - c.bufferMutex.Lock() - defer c.bufferMutex.Unlock() + return properties +} - if c.droppedEvents > 0 { - log.Debugf("dropped %d analytics events (buffer full)", c.droppedEvents) +func (c *client) Flush() { + if Dry { + return + } + if err := c.phClient.Close(); err != nil { + log.Debugf("error flushing PostHog client: %v", err) } - - events := c.buffer.Drain() - c.buffer = c.overflow - c.overflow = newEventBuffer(eventsCountThreshold) - c.droppedEvents = 0 - return events } diff --git a/pkg/telemetry/analytics/types.go b/pkg/telemetry/analytics/types.go index 7993fbe30..ac7e5034b 100644 --- a/pkg/telemetry/analytics/types.go +++ b/pkg/telemetry/analytics/types.go @@ -6,7 +6,3 @@ type Client interface { RecordEvent(Event) Flush() } - -type Request struct { - Data []Event `json:"data,omitempty"` -} diff --git a/sites/dl-devsy-sh/netlify.toml b/sites/dl-devsy-sh/netlify.toml new file mode 100644 index 000000000..6bdca02cd --- /dev/null +++ b/sites/dl-devsy-sh/netlify.toml @@ -0,0 +1,8 @@ +[build] + publish = "public" + +[[headers]] + for = "/desktop/*" + [headers.values] + Access-Control-Allow-Origin = "*" + Cache-Control = "public, max-age=60" diff --git a/sites/dl-devsy-sh/public/desktop/.gitkeep b/sites/dl-devsy-sh/public/desktop/.gitkeep new file mode 100644 index 000000000..e69de29bb From 4249c3a01d1d5fa4d68803094102b7606a413393 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 16 May 2026 10:47:18 -0500 Subject: [PATCH 2/3] fix: address PR #303 review feedback - Move Netlify deploy to dedicated job collecting metadata from all matrix runners via upload/download-artifact pattern - Use sites/dl-devsy-sh/netlify.toml config explicitly in deploy - Guard against placeholder PostHog API key in both Go and TS clients - Validate and constrain analytics_track IPC payloads (max 20 keys, 64-char key limit, 256-char string value limit) - Normalize route paths in page_view events to strip dynamic IDs (/workspaces/:id, /providers/:id, /machines/:id) - Guard against empty machine_id in Go PostHog client - Remove unused createHash import from analytics.ts --- .github/workflows/release.yml | 42 +++++++++++++++++++++-------- desktop/src/main/analytics.ts | 6 ++++- desktop/src/main/ipc.ts | 22 ++++++++++++++- desktop/src/renderer/src/App.svelte | 9 ++++++- pkg/telemetry/analytics/client.go | 10 +++++++ 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 794b66cf8..dcde646f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,16 +167,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} - - name: deploy update metadata to Netlify - if: runner.os == 'Linux' - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - run: | - npm install -g netlify-cli - mkdir -p netlify-deploy/desktop - cp desktop/release/latest*.yml netlify-deploy/desktop/ 2>/dev/null || true - netlify deploy --prod --dir=netlify-deploy --site="$NETLIFY_SITE_ID" + - name: upload update metadata artifact + uses: actions/upload-artifact@v7 + with: + name: update-metadata-${{ matrix.os }} + path: desktop/release/latest*.yml + if-no-files-found: ignore - name: upload cli assets uses: softprops/action-gh-release@v3 @@ -237,9 +233,33 @@ jobs: files: | ./Devsy.flatpak + deploy-update-metadata: + name: Deploy Update Metadata to Netlify + needs: [build-desktop] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + + - uses: actions/download-artifact@v8 + with: + pattern: update-metadata-* + merge-multiple: true + path: sites/dl-devsy-sh/public/desktop + + - name: deploy metadata + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + run: | + npm install -g netlify-cli + netlify deploy --prod \ + --config=sites/dl-devsy-sh/netlify.toml \ + --dir=sites/dl-devsy-sh/public \ + --site="$NETLIFY_SITE_ID" + prerelease: name: Publish Prerelease - needs: [build-desktop, build-flatpak] + needs: [build-desktop, build-flatpak, deploy-update-metadata] if: github.event.release.prerelease runs-on: ubuntu-latest steps: diff --git a/desktop/src/main/analytics.ts b/desktop/src/main/analytics.ts index 880154897..620f7e533 100644 --- a/desktop/src/main/analytics.ts +++ b/desktop/src/main/analytics.ts @@ -1,4 +1,4 @@ -import { createHash, createHmac } from "node:crypto" +import { createHmac } from "node:crypto" import { homedir, platform, arch } from "node:os" import { PostHog } from "posthog-node" import { app } from "electron" @@ -25,6 +25,10 @@ function isTelemetryDisabled(): boolean { export function initAnalytics(): void { if (isTelemetryDisabled()) return + if (!POSTHOG_API_KEY || POSTHOG_API_KEY === "phc_PLACEHOLDER") { + console.warn("[telemetry] PostHog API key not configured; analytics disabled") + return + } distinctId = getDistinctId() client = new PostHog(POSTHOG_API_KEY, { diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index b3ecccea7..fbc4ebf00 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -652,7 +652,27 @@ export function registerIpcHandlers(deps: IpcDependencies): void { _event, args: { name: string; properties?: Record }, ) => { - trackEvent(args.name, args.properties) + if ( + !args?.name || + typeof args.name !== "string" || + args.name.length > 64 + ) { + return + } + trackEvent(args.name, sanitizeAnalyticsProperties(args.properties)) }, ) } + +function sanitizeAnalyticsProperties( + input?: Record, +): Record | undefined { + if (!input || typeof input !== "object") return undefined + const entries = Object.entries(input).slice(0, 20) + const out: Record = {} + for (const [k, v] of entries) { + if (!k || k.length > 64) continue + out[k] = typeof v === "string" ? v.slice(0, 256) : v + } + return out +} diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index e2c4b894d..9ec94d72b 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -82,6 +82,13 @@ function handleKeydown(e: KeyboardEvent) { let unsubLocation: (() => void) | undefined +function normalizeAnalyticsPath(path: string): string { + if (/^\/workspaces\/[^/]+$/.test(path)) return "/workspaces/:id" + if (/^\/providers\/[^/]+$/.test(path)) return "/providers/:id" + if (/^\/machines\/[^/]+$/.test(path)) return "/machines/:id" + return path +} + onMount(() => { initWorkspaces() initProviders() @@ -90,7 +97,7 @@ onMount(() => { destroySettings = initSettings() unsubLocation = location.subscribe((path) => { - analyticsTrack("page_view", { path }) + analyticsTrack("page_view", { path: normalizeAnalyticsPath(path) }) }) // Signal the backend that the frontend is ready diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 2a94006c7..1c7da100d 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -15,6 +15,11 @@ const ( var Dry = false func NewClient() Client { + if posthogAPIKey == "" || posthogAPIKey == "phc_PLACEHOLDER" { + log.Debugf("PostHog API key not configured; analytics disabled") + return NewNoopClient() + } + phClient, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{ Endpoint: posthogEndpoint, }) @@ -48,6 +53,11 @@ func (c *client) RecordEvent(event Event) { return } + if machineID == "" { + log.Debugf("skipping event with empty machine_id: %s", eventType) + return + } + if err := c.phClient.Enqueue(posthog.Capture{ DistinctId: machineID, Event: eventType, From 12066c236a332a5bdc72330dfcf9463db79ee628 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 17 May 2026 05:44:33 -0500 Subject: [PATCH 3/3] fix(telemetry): replace PostHog placeholder API keys with real project token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog project tokens (phc_...) are public write-only keys — safe to commit. Add .gitleaksignore and golangci exclusion to suppress false-positive secret detection for these entries. --- .gitleaksignore | 4 ++++ .golangci.yaml | 6 ++++++ desktop/src/main/analytics.ts | 3 +-- pkg/telemetry/analytics/client.go | 3 +-- 4 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..ddd22925b --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,4 @@ +# PostHog project tokens are public write-only keys safe to commit. +# See: https://posthog.com/docs/libraries/js#config +pkg/telemetry/analytics/client.go:generic-api-key:9 +desktop/src/main/analytics.ts:generic-api-key:7 diff --git a/.golangci.yaml b/.golangci.yaml index 0964bacd0..66cc8e9a1 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -31,6 +31,12 @@ linters: - unparam - unused - whitespace + exclusions: + rules: + - linters: + - gosec + text: "G101" + path: "pkg/telemetry/analytics/client.go" settings: cyclop: max-complexity: 8 diff --git a/desktop/src/main/analytics.ts b/desktop/src/main/analytics.ts index 620f7e533..0271935f2 100644 --- a/desktop/src/main/analytics.ts +++ b/desktop/src/main/analytics.ts @@ -4,8 +4,7 @@ import { PostHog } from "posthog-node" import { app } from "electron" import { machineIdSync } from "./machine-id.js" -// Replace with a real PostHog project API key. -const POSTHOG_API_KEY = "phc_PLACEHOLDER" +const POSTHOG_API_KEY = "phc_u3TY39zxfrRcyXJoqZ5WRFVTr75gZBHi2AUrfqJ6GCj2" const POSTHOG_HOST = "https://us.i.posthog.com" let client: PostHog | null = null diff --git a/pkg/telemetry/analytics/client.go b/pkg/telemetry/analytics/client.go index 1c7da100d..1bdb9ab41 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -6,8 +6,7 @@ import ( ) const ( - // Replace with a real PostHog project API key. - posthogAPIKey = "phc_PLACEHOLDER" + posthogAPIKey = "phc_u3TY39zxfrRcyXJoqZ5WRFVTr75gZBHi2AUrfqJ6GCj2" posthogEndpoint = "https://us.i.posthog.com" )