feat(telemetry): replace hostname hash with persisted install-scoped anonymous ID

A random UUID is generated on first run and stored in ~/.openclaw-dench/telemetry.json, replacing the SHA-256 hostname hash that could theoretically be reversed.
This commit is contained in:
kumarabhirup 2026-03-05 19:09:00 -08:00
parent 135fa3608a
commit 1e21185d47
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 231 additions and 24 deletions

View File

@ -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.

View File

@ -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");
});

View File

@ -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);
});
});

View File

@ -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<string, unknown> = {};
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;
}
}

View File

@ -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<string, unknown> {
return {
os: process.platform,
@ -67,7 +56,7 @@ export function track(event: string, properties?: Record<string, unknown>): void
if (!ph) return;
ph.capture({
distinctId: getAnonymousId(),
distinctId: getOrCreateAnonymousId(),
event,
properties: {
...getMachineContext(),