diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d6c5881c..dcde646f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,6 +167,13 @@ jobs: env: GH_TOKEN: ${{ github.token }} + - 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 if: runner.os == 'Linux' @@ -226,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/.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/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..0271935f2 --- /dev/null +++ b/desktop/src/main/analytics.ts @@ -0,0 +1,62 @@ +import { 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" + +const POSTHOG_API_KEY = "phc_u3TY39zxfrRcyXJoqZ5WRFVTr75gZBHi2AUrfqJ6GCj2" +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 + 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, { + 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..fbc4ebf00 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,35 @@ export function registerIpcHandlers(deps: IpcDependencies): void { return key }, ) + + // ── Analytics ── + ipcMain.handle( + "analytics_track", + async ( + _event, + args: { name: string; properties?: Record }, + ) => { + 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/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..9ec94d72b 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,15 @@ 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() @@ -86,6 +96,10 @@ onMount(() => { initContexts() destroySettings = initSettings() + unsubLocation = location.subscribe((path) => { + analyticsTrack("page_view", { path: normalizeAnalyticsPath(path) }) + }) + // Signal the backend that the frontend is ready appReady().catch((err) => { console.warn("[Devsy] appReady failed:", err) @@ -93,6 +107,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..1bdb9ab41 100644 --- a/pkg/telemetry/analytics/client.go +++ b/pkg/telemetry/analytics/client.go @@ -1,164 +1,96 @@ 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 + posthogAPIKey = "phc_u3TY39zxfrRcyXJoqZ5WRFVTr75gZBHi2AUrfqJ6GCj2" - 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}, + if posthogAPIKey == "" || posthogAPIKey == "phc_PLACEHOLDER" { + log.Debugf("PostHog API key not configured; analytics disabled") + return NewNoopClient() } - go c.loop() + phClient, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{ + Endpoint: posthogEndpoint, + }) + if err != nil { + log.Debugf("failed to create PostHog client: %v", err) + return NewNoopClient() + } - 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) + if machineID == "" { + log.Debugf("skipping event with empty machine_id: %s", eventType) return } - resp, err := c.httpClient.Post(c.endpoint, "application/json", bytes.NewReader(marshaled)) - if err != nil { - log.Debugf("error sending 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) + } +} + +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