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:
parent
135fa3608a
commit
1e21185d47
39
TELEMETRY.md
39
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.
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
163
src/telemetry/config.test.ts
Normal file
163
src/telemetry/config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user