diff --git a/TELEMETRY.md b/TELEMETRY.md index 85d823476a9..99df91e5b15 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -32,9 +32,23 @@ Both layers share the same opt-out controls and privacy mode setting. | `$pageview` | User navigates within the web app | `$current_url` (path only, no query params with user data) | Every event includes baseline machine context: `os` (platform), `arch`, and -`node_version`. A SHA-256 hash of the machine hostname + username (truncated to -16 hex chars) is used as the anonymous distinct ID — it cannot be reversed to -identify you. +`node_version`. + +### Anonymous install ID + +A single anonymous UUID is generated on first run and persisted in +`~/.openclaw-dench/telemetry.json` as `anonymousId`. This install-scoped ID is +shared across all telemetry layers — CLI, web server, browser, and the OpenClaw +PostHog plugin — so a single DenchClaw installation maps to exactly one PostHog +person. + +The ID is: + +- **Stable** — survives restarts, upgrades, and re-bootstrap. +- **Anonymous** — a random UUID with no relation to your machine, username, or + IP address. +- **Install-scoped** — deleting `~/.openclaw-dench` resets it. +- **Inspectable** — run `npx denchclaw telemetry status` to see your current ID. --- @@ -201,17 +215,22 @@ npx denchclaw telemetry enable ## How It Works +- **Shared identity**: All layers read the same `anonymousId` from + `~/.openclaw-dench/telemetry.json`. The first component to run (usually the + CLI during `denchclaw bootstrap`) generates the UUID; every subsequent layer + reuses it. - **CLI**: The `posthog-node` SDK sends events from the Node.js process. Events are batched and flushed asynchronously — telemetry never blocks the CLI. - **Web app (server)**: API route handlers call `trackServer()` which uses the - same `posthog-node` SDK on the server side. -- **Web app (client)**: The `posthog-js` SDK captures pageview events in the - browser. No cookies are set; session data is stored in memory only. + same `posthog-node` SDK on the server side with the persisted install ID. +- **Web app (client)**: The `posthog-js` SDK is bootstrapped with the install ID + from the server so the browser shares the same PostHog identity. No cookies + are set; session data is stored in memory only. - **OpenClaw plugin**: The `posthog-analytics` plugin runs in-process with the - OpenClaw Gateway. It hooks into agent lifecycle events (`before_model_resolve`, - `before_prompt_build`, `before_tool_call`, `after_tool_call`, `agent_end`, - `message_received`, `session_start`, `session_end`) and emits PostHog AI - events via `posthog-node`. + OpenClaw Gateway. It reads the persisted install ID and hooks into agent + lifecycle events (`before_model_resolve`, `before_prompt_build`, + `before_tool_call`, `after_tool_call`, `agent_end`, `message_received`, + `session_start`, `session_end`) to emit PostHog AI events via `posthog-node`. - **PostHog project token**: The write-only project token (`phc_...`) is embedded in the built artifacts. It can only send events — it cannot read dashboards or analytics data. diff --git a/src/cli/program/register.telemetry.ts b/src/cli/program/register.telemetry.ts index aed177d8503..c2af0af24ea 100644 --- a/src/cli/program/register.telemetry.ts +++ b/src/cli/program/register.telemetry.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { readTelemetryConfig, writeTelemetryConfig } from "../../telemetry/config.js"; +import { readTelemetryConfig, writeTelemetryConfig, getOrCreateAnonymousId } from "../../telemetry/config.js"; import { isTelemetryEnabled } from "../../telemetry/telemetry.js"; export function registerTelemetryCommand(program: Command) { @@ -25,6 +25,7 @@ export function registerTelemetryCommand(program: Command) { } console.log(`Effective status: ${effective ? "enabled" : "disabled"}`); console.log(`Privacy mode: ${privacyOn ? "on (message content is redacted)" : "off (full content is captured)"}`); + console.log(`Install ID: ${getOrCreateAnonymousId()}`); console.log("\nLearn more: https://github.com/openclaw/openclaw/blob/main/TELEMETRY.md"); }); diff --git a/src/telemetry/config.test.ts b/src/telemetry/config.test.ts new file mode 100644 index 00000000000..ea2e1378335 --- /dev/null +++ b/src/telemetry/config.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockWriteFileSync = vi.fn(); +const mockMkdirSync = vi.fn(); +const mockRandomUUID = vi.fn(); + +vi.mock("node:fs", () => ({ + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync, +})); + +vi.mock("node:crypto", () => ({ + randomUUID: mockRandomUUID, +})); + +vi.mock("../config/paths.js", () => ({ + resolveStateDir: () => "/fake/state-dir", +})); + +describe("getOrCreateAnonymousId", () => { + beforeEach(() => { + vi.resetModules(); + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + mockWriteFileSync.mockReset(); + mockMkdirSync.mockReset(); + mockRandomUUID.mockReset(); + }); + + it("generates an install ID and persists it when telemetry.json has no anonymousId", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ enabled: true, privacyMode: true })); + mockRandomUUID.mockReturnValue("new-install-id-1234"); + + const { getOrCreateAnonymousId } = await import("./config.js"); + const id = getOrCreateAnonymousId(); + + expect(id).toBe("new-install-id-1234"); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.anonymousId).toBe("new-install-id-1234"); + }); + + it("reuses the persisted install ID on subsequent reads", async () => { + const persistedId = "persisted-uuid-5678"; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ enabled: true, anonymousId: persistedId })); + + const { getOrCreateAnonymousId } = await import("./config.js"); + const id = getOrCreateAnonymousId(); + + expect(id).toBe(persistedId); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockRandomUUID).not.toHaveBeenCalled(); + }); + + it("generates and persists an ID when telemetry.json does not exist", async () => { + mockExistsSync.mockReturnValue(false); + mockRandomUUID.mockReturnValue("fresh-install-id"); + + const { getOrCreateAnonymousId } = await import("./config.js"); + const id = getOrCreateAnonymousId(); + + expect(id).toBe("fresh-install-id"); + expect(mockMkdirSync).toHaveBeenCalledWith( + expect.stringContaining("state-dir"), + { recursive: true }, + ); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.anonymousId).toBe("fresh-install-id"); + }); + + it("preserves existing enabled/privacyMode flags when adding the ID", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: false, privacyMode: false, noticeShown: true }), + ); + mockRandomUUID.mockReturnValue("added-id"); + + const { getOrCreateAnonymousId } = await import("./config.js"); + getOrCreateAnonymousId(); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.enabled).toBe(false); + expect(written.privacyMode).toBe(false); + expect(written.noticeShown).toBe(true); + expect(written.anonymousId).toBe("added-id"); + }); + + it("returns a stable ID even when fs write fails", async () => { + mockExistsSync.mockReturnValue(false); + mockRandomUUID.mockReturnValue("fallback-id"); + mockMkdirSync.mockImplementation(() => { + throw new Error("EACCES"); + }); + + const { getOrCreateAnonymousId } = await import("./config.js"); + const id1 = getOrCreateAnonymousId(); + const id2 = getOrCreateAnonymousId(); + + expect(id1).toBe("fallback-id"); + expect(id1).toBe(id2); + }); +}); + +describe("readTelemetryConfig", () => { + beforeEach(() => { + vi.resetModules(); + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + }); + + it("includes anonymousId when present in telemetry.json", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, anonymousId: "stored-id" }), + ); + + const { readTelemetryConfig } = await import("./config.js"); + const config = readTelemetryConfig(); + + expect(config.anonymousId).toBe("stored-id"); + }); + + it("returns undefined anonymousId when not present", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ enabled: true })); + + const { readTelemetryConfig } = await import("./config.js"); + const config = readTelemetryConfig(); + + expect(config.anonymousId).toBeUndefined(); + }); +}); + +describe("writeTelemetryConfig preserves anonymousId", () => { + beforeEach(() => { + vi.resetModules(); + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + mockWriteFileSync.mockReset(); + mockMkdirSync.mockReset(); + }); + + it("does not lose anonymousId when writing unrelated config changes", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, anonymousId: "keep-me", privacyMode: true }), + ); + + const { writeTelemetryConfig } = await import("./config.js"); + writeTelemetryConfig({ enabled: false }); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.anonymousId).toBe("keep-me"); + expect(written.enabled).toBe(false); + }); +}); diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index e256affaa60..141b3aeb67d 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { resolveStateDir } from "../config/paths.js"; @@ -6,6 +7,7 @@ type TelemetryConfig = { enabled: boolean; noticeShown?: boolean; privacyMode?: boolean; + anonymousId?: string; }; const TELEMETRY_FILENAME = "telemetry.json"; @@ -25,6 +27,7 @@ export function readTelemetryConfig(): TelemetryConfig { enabled: raw.enabled !== false, noticeShown: raw.noticeShown === true, privacyMode: raw.privacyMode !== false, + anonymousId: typeof raw.anonymousId === "string" ? raw.anonymousId : undefined, }; } catch { return { enabled: true }; @@ -51,3 +54,35 @@ export function isPrivacyModeEnabled(): boolean { const config = readTelemetryConfig(); return config.privacyMode !== false; } + +let _cachedAnonymousId: string | null = null; + +/** + * Return the persisted install-scoped anonymous ID from telemetry.json, + * generating and writing one on first access. + */ +export function getOrCreateAnonymousId(): string { + if (_cachedAnonymousId) return _cachedAnonymousId; + + const configPath = telemetryConfigPath(); + try { + let raw: Record = {}; + if (existsSync(configPath)) { + raw = JSON.parse(readFileSync(configPath, "utf-8")); + } + if (typeof raw.anonymousId === "string" && raw.anonymousId) { + _cachedAnonymousId = raw.anonymousId; + return raw.anonymousId; + } + const id = randomUUID(); + raw.anonymousId = id; + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8"); + _cachedAnonymousId = id; + return id; + } catch { + const id = randomUUID(); + _cachedAnonymousId = id; + return id; + } +} diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index ba27ab21b80..63efb45fd94 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -1,7 +1,5 @@ -import { createHash } from "node:crypto"; -import os from "node:os"; import { PostHog } from "posthog-node"; -import { readTelemetryConfig } from "./config.js"; +import { readTelemetryConfig, getOrCreateAnonymousId } from "./config.js"; const POSTHOG_KEY = process.env.POSTHOG_KEY || ""; const POSTHOG_HOST = "https://us.i.posthog.com"; @@ -24,15 +22,6 @@ export function isTelemetryEnabled(): boolean { return true; } -export function getAnonymousId(): string { - try { - const raw = `${os.hostname()}:${os.userInfo().username}`; - return createHash("sha256").update(raw).digest("hex").slice(0, 16); - } catch { - return "unknown"; - } -} - function getMachineContext(): Record { return { os: process.platform, @@ -67,7 +56,7 @@ export function track(event: string, properties?: Record): void if (!ph) return; ph.capture({ - distinctId: getAnonymousId(), + distinctId: getOrCreateAnonymousId(), event, properties: { ...getMachineContext(),