diff --git a/TELEMETRY.md b/TELEMETRY.md index 99df91e5b15..fdb414c9531 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -50,6 +50,25 @@ The ID is: - **Install-scoped** — deleting `~/.openclaw-dench` resets it. - **Inspectable** — run `npx denchclaw telemetry status` to see your current ID. +### Optional identity fields + +`telemetry.json` also supports optional identity fields. By default all are +empty and the install remains anonymous. When any field is populated, a PostHog +person profile is created with those properties: + +| Field | PostHog property | Description | +| --- | --- | --- | +| `name` | `$name` | Display name shown in PostHog | +| `email` | `$email` | Email address | +| `avatar` | `$avatar` | Avatar URL | +| `denchOrgId` | `dench_org_id` | Dench Cloud organization ID (set automatically by Dench Cloud) | + +These fields are **never written automatically** by the open-source CLI or web +app. They are only populated when: + +- A user manually edits `~/.openclaw-dench/telemetry.json`, or +- Dench Cloud provisions the install and writes `denchOrgId`. + --- ## AI Observability @@ -138,7 +157,8 @@ blocked. - IP addresses (PostHog is configured to discard them) - Environment variable values - Error stack traces or logs -- Any personally identifiable information (PII) +- Any personally identifiable information (PII) — unless you explicitly write + `name`, `email`, or `avatar` into `telemetry.json` --- diff --git a/apps/web/app/components/posthog-provider.tsx b/apps/web/app/components/posthog-provider.tsx index fcebb9b7805..5b6c015786c 100644 --- a/apps/web/app/components/posthog-provider.tsx +++ b/apps/web/app/components/posthog-provider.tsx @@ -10,19 +10,32 @@ const POSTHOG_HOST = "https://us.i.posthog.com"; const DENCHCLAW_VERSION = process.env.NEXT_PUBLIC_DENCHCLAW_VERSION || ""; const OPENCLAW_VERSION = process.env.NEXT_PUBLIC_OPENCLAW_VERSION || ""; +type PersonInfo = { + name?: string; + email?: string; + avatar?: string; + denchOrgId?: string; +}; + let initialized = false; -function initPostHog(anonymousId?: string) { +function initPostHog(anonymousId?: string, personInfo?: PersonInfo, privacyMode?: boolean) { if (initialized || !POSTHOG_KEY || typeof window === "undefined") return; + const privacy = privacyMode !== false; + posthog.init(POSTHOG_KEY, { api_host: POSTHOG_HOST, capture_pageview: false, capture_pageleave: true, persistence: "memory", autocapture: false, - disable_session_recording: true, - person_profiles: "identified_only", + disable_session_recording: false, + person_profiles: "always", + session_recording: { + maskAllInputs: privacy, + maskTextSelector: privacy ? "*" : undefined, + }, bootstrap: anonymousId ? { distinctID: anonymousId, isIdentifiedID: false } : undefined, @@ -33,6 +46,15 @@ function initPostHog(anonymousId?: string) { if (OPENCLAW_VERSION) superProps.openclaw_version = OPENCLAW_VERSION; if (Object.keys(superProps).length > 0) posthog.register(superProps); + if (personInfo && anonymousId) { + const props: Record = {}; + if (personInfo.name) props.$name = personInfo.name; + if (personInfo.email) props.$email = personInfo.email; + if (personInfo.avatar) props.$avatar = personInfo.avatar; + if (personInfo.denchOrgId) props.dench_org_id = personInfo.denchOrgId; + posthog.identify(anonymousId, props); + } + initialized = true; } @@ -54,17 +76,21 @@ function PageviewTracker() { export function PostHogProvider({ children, anonymousId, + personInfo, + privacyMode, }: { children: React.ReactNode; anonymousId?: string; + personInfo?: PersonInfo; + privacyMode?: boolean; }) { const initRef = useRef(false); useEffect(() => { if (initRef.current) return; initRef.current = true; - initPostHog(anonymousId); - }, [anonymousId]); + initPostHog(anonymousId, personInfo, privacyMode); + }, [anonymousId, personInfo, privacyMode]); if (!POSTHOG_KEY) return <>{children}; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index cfe759c0c20..0f6377320fd 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Suspense } from "react"; import { ThemeProvider } from "next-themes"; -import { getOrCreateAnonymousId } from "@/lib/telemetry"; +import { getOrCreateAnonymousId, readPersonInfo, readPrivacyMode } from "@/lib/telemetry"; import { PostHogProvider } from "./components/posthog-provider"; import "./globals.css"; @@ -25,6 +25,8 @@ export default function RootLayout({ children: React.ReactNode; }) { const anonymousId = getOrCreateAnonymousId(); + const personInfo = readPersonInfo(); + const privacyMode = readPrivacyMode(); return ( @@ -43,7 +45,7 @@ export default function RootLayout({ - + {children} diff --git a/apps/web/lib/telemetry.ts b/apps/web/lib/telemetry.ts index 964393d48fb..a2e9e795817 100644 --- a/apps/web/lib/telemetry.ts +++ b/apps/web/lib/telemetry.ts @@ -23,7 +23,16 @@ function ensureClient(): PostHog | null { return client; } +export type PersonInfo = { + name?: string; + email?: string; + avatar?: string; + denchOrgId?: string; +}; + let _cachedAnonymousId: string | null = null; +let _cachedPersonInfo: PersonInfo | null | undefined = undefined; +let _cachedPrivacyMode: boolean | undefined = undefined; /** * Read the persisted install-scoped anonymous ID from ~/.openclaw-dench/telemetry.json, @@ -57,6 +66,71 @@ export function getOrCreateAnonymousId(): string { } } +/** + * Read optional person identity fields from telemetry.json. + * Returns null when no identity fields are set. + */ +export function readPersonInfo(): PersonInfo | null { + if (_cachedPersonInfo !== undefined) return _cachedPersonInfo; + + try { + const stateDir = join(process.env.HOME || homedir(), ".openclaw-dench"); + const configPath = join(stateDir, "telemetry.json"); + + if (!existsSync(configPath)) { + _cachedPersonInfo = null; + return null; + } + const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + const info: PersonInfo = {}; + if (typeof raw.name === "string" && raw.name) info.name = raw.name; + if (typeof raw.email === "string" && raw.email) info.email = raw.email; + if (typeof raw.avatar === "string" && raw.avatar) info.avatar = raw.avatar; + if (typeof raw.denchOrgId === "string" && raw.denchOrgId) info.denchOrgId = raw.denchOrgId; + + _cachedPersonInfo = Object.keys(info).length > 0 ? info : null; + return _cachedPersonInfo; + } catch { + _cachedPersonInfo = null; + return null; + } +} + +/** + * Read privacy mode from telemetry.json. + * Default is true (privacy on) when the file is missing or unreadable. + */ +export function readPrivacyMode(): boolean { + if (_cachedPrivacyMode !== undefined) return _cachedPrivacyMode; + + try { + const stateDir = join(process.env.HOME || homedir(), ".openclaw-dench"); + const configPath = join(stateDir, "telemetry.json"); + + if (!existsSync(configPath)) { + _cachedPrivacyMode = true; + return true; + } + const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + _cachedPrivacyMode = raw.privacyMode !== false; + return _cachedPrivacyMode; + } catch { + _cachedPrivacyMode = true; + return true; + } +} + +function personInfoToPostHogProps(person: PersonInfo): Record { + const props: Record = {}; + if (person.name) props.$name = person.name; + if (person.email) props.$email = person.email; + if (person.avatar) props.$avatar = person.avatar; + if (person.denchOrgId) props.dench_org_id = person.denchOrgId; + return props; +} + +let _identified = false; + export function trackServer( event: string, properties?: Record, @@ -65,14 +139,26 @@ export function trackServer( const ph = ensureClient(); if (!ph) return; + const id = distinctId || getOrCreateAnonymousId(); + + if (!_identified) { + _identified = true; + const person = readPersonInfo(); + if (person) { + ph.identify({ + distinctId: id, + properties: personInfoToPostHogProps(person), + }); + } + } + ph.capture({ - distinctId: distinctId || getOrCreateAnonymousId(), + distinctId: id, event, properties: { ...properties, denchclaw_version: DENCHCLAW_VERSION || undefined, openclaw_version: OPENCLAW_VERSION || undefined, - $process_person_profile: false, }, }); } diff --git a/extensions/posthog-analytics/index.ts b/extensions/posthog-analytics/index.ts index 153af840d23..26f33e69932 100644 --- a/extensions/posthog-analytics/index.ts +++ b/extensions/posthog-analytics/index.ts @@ -1,7 +1,7 @@ import { createPostHogClient, shutdownPostHogClient } from "./lib/posthog-client.js"; import { TraceContextManager, resolveSessionKey } from "./lib/trace-context.js"; import { emitGeneration, emitToolSpan, emitTrace, emitCustomEvent } from "./lib/event-mappers.js"; -import { readPrivacyMode } from "./lib/privacy.js"; +import { readPrivacyMode, readPersonInfo, readOrCreateAnonymousId } from "./lib/privacy.js"; import { POSTHOG_KEY as BUILT_IN_KEY, DENCHCLAW_VERSION, @@ -42,6 +42,17 @@ export default function register(api: any) { const ph = createPostHogClient(apiKey, config?.host, versionProps); const traceCtx = new TraceContextManager(); + const person = readPersonInfo(api.config); + if (person) { + const distinctId = readOrCreateAnonymousId(api.config); + const props: Record = {}; + if (person.name) props.$name = person.name; + if (person.email) props.$email = person.email; + if (person.avatar) props.$avatar = person.avatar; + if (person.denchOrgId) props.dench_org_id = person.denchOrgId; + ph.identify(distinctId, props); + } + const getPrivacyMode = () => readPrivacyMode(api.config); const getConfigModel = (): string | undefined => diff --git a/extensions/posthog-analytics/lib/event-mappers.ts b/extensions/posthog-analytics/lib/event-mappers.ts index 6f0783c3844..6d9268d0361 100644 --- a/extensions/posthog-analytics/lib/event-mappers.ts +++ b/extensions/posthog-analytics/lib/event-mappers.ts @@ -395,10 +395,7 @@ export function emitCustomEvent( ph.capture({ distinctId: readOrCreateAnonymousId(), event: eventName, - properties: { - ...properties, - $process_person_profile: false, - }, + properties: properties ?? {}, }); } catch { // Fail silently. diff --git a/extensions/posthog-analytics/lib/posthog-client.ts b/extensions/posthog-analytics/lib/posthog-client.ts index 165e73b841d..0429e89fa96 100644 --- a/extensions/posthog-analytics/lib/posthog-client.ts +++ b/extensions/posthog-analytics/lib/posthog-client.ts @@ -62,6 +62,23 @@ export class PostHogClient { }); } + identify(distinctId: string, properties: Record): void { + this.queue.push({ + event: "$identify", + distinct_id: distinctId, + properties: { + ...this.globalProperties, + $set: properties, + $lib: "denchclaw-posthog-plugin", + }, + timestamp: new Date().toISOString(), + }); + + if (this.queue.length >= FLUSH_AT) { + this.flush(); + } + } + async shutdown(): Promise { if (this.timer) { clearInterval(this.timer); diff --git a/extensions/posthog-analytics/lib/privacy.ts b/extensions/posthog-analytics/lib/privacy.ts index d1b3dfb3448..cb3e28e6e86 100644 --- a/extensions/posthog-analytics/lib/privacy.ts +++ b/extensions/posthog-analytics/lib/privacy.ts @@ -30,6 +30,43 @@ export function readPrivacyMode(openclawConfig?: any): boolean { } } +export type PersonInfo = { + name?: string; + email?: string; + avatar?: string; + denchOrgId?: string; +}; + +let _cachedPersonInfo: PersonInfo | null | undefined = undefined; + +/** + * Read optional person identity fields from telemetry.json. + * Returns null when no identity fields are set. + */ +export function readPersonInfo(openclawConfig?: any): PersonInfo | null { + if (_cachedPersonInfo !== undefined) return _cachedPersonInfo; + + try { + const configPath = resolveConfigPath(openclawConfig); + if (!existsSync(configPath)) { + _cachedPersonInfo = null; + return null; + } + const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record; + const info: PersonInfo = {}; + if (typeof raw.name === "string" && raw.name) info.name = raw.name; + if (typeof raw.email === "string" && raw.email) info.email = raw.email; + if (typeof raw.avatar === "string" && raw.avatar) info.avatar = raw.avatar; + if (typeof raw.denchOrgId === "string" && raw.denchOrgId) info.denchOrgId = raw.denchOrgId; + + _cachedPersonInfo = Object.keys(info).length > 0 ? info : null; + return _cachedPersonInfo; + } catch { + _cachedPersonInfo = null; + return null; + } +} + let _cachedAnonymousId: string | null = null; /** diff --git a/src/telemetry/config.test.ts b/src/telemetry/config.test.ts index ea2e1378335..eb7093c7c2a 100644 --- a/src/telemetry/config.test.ts +++ b/src/telemetry/config.test.ts @@ -161,3 +161,167 @@ describe("writeTelemetryConfig preserves anonymousId", () => { expect(written.enabled).toBe(false); }); }); + +describe("readPersonInfo", () => { + beforeEach(() => { + vi.resetModules(); + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + }); + + it("returns null when no identity fields are present", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, anonymousId: "some-id" }), + ); + + const { readPersonInfo } = await import("./config.js"); + expect(readPersonInfo()).toBeNull(); + }); + + it("returns null when telemetry.json does not exist", async () => { + mockExistsSync.mockReturnValue(false); + + const { readPersonInfo } = await import("./config.js"); + expect(readPersonInfo()).toBeNull(); + }); + + it("returns person info when name is set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, name: "Alice" }), + ); + + const { readPersonInfo } = await import("./config.js"); + const info = readPersonInfo(); + expect(info).toEqual({ name: "Alice" }); + }); + + it("returns person info when email is set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, email: "alice@example.com" }), + ); + + const { readPersonInfo } = await import("./config.js"); + const info = readPersonInfo(); + expect(info).toEqual({ email: "alice@example.com" }); + }); + + it("returns all identity fields when present", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + enabled: true, + name: "Alice", + email: "alice@example.com", + avatar: "https://example.com/avatar.png", + denchOrgId: "org-123", + }), + ); + + const { readPersonInfo } = await import("./config.js"); + const info = readPersonInfo(); + expect(info).toEqual({ + name: "Alice", + email: "alice@example.com", + avatar: "https://example.com/avatar.png", + denchOrgId: "org-123", + }); + }); + + it("ignores empty string identity fields", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, name: "", email: "", denchOrgId: "org-1" }), + ); + + const { readPersonInfo } = await import("./config.js"); + const info = readPersonInfo(); + expect(info).toEqual({ denchOrgId: "org-1" }); + }); + + it("ignores non-string identity fields", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ enabled: true, name: 42, email: true }), + ); + + const { readPersonInfo } = await import("./config.js"); + expect(readPersonInfo()).toBeNull(); + }); +}); + +describe("readTelemetryConfig includes identity fields", () => { + beforeEach(() => { + vi.resetModules(); + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + }); + + it("parses name, email, avatar, and denchOrgId from config", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + enabled: true, + name: "Bob", + email: "bob@test.com", + avatar: "https://img.test/bob.jpg", + denchOrgId: "org-abc", + }), + ); + + const { readTelemetryConfig } = await import("./config.js"); + const config = readTelemetryConfig(); + + expect(config.name).toBe("Bob"); + expect(config.email).toBe("bob@test.com"); + expect(config.avatar).toBe("https://img.test/bob.jpg"); + expect(config.denchOrgId).toBe("org-abc"); + }); + + it("returns undefined for missing identity fields", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ enabled: true })); + + const { readTelemetryConfig } = await import("./config.js"); + const config = readTelemetryConfig(); + + expect(config.name).toBeUndefined(); + expect(config.email).toBeUndefined(); + expect(config.avatar).toBeUndefined(); + expect(config.denchOrgId).toBeUndefined(); + }); +}); + +describe("writeTelemetryConfig preserves identity fields", () => { + beforeEach(() => { + vi.resetModules(); + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + mockWriteFileSync.mockReset(); + mockMkdirSync.mockReset(); + }); + + it("does not lose identity fields when writing unrelated config changes", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + enabled: true, + anonymousId: "id-1", + name: "Alice", + email: "alice@test.com", + denchOrgId: "org-x", + }), + ); + + const { writeTelemetryConfig } = await import("./config.js"); + writeTelemetryConfig({ privacyMode: false }); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(written.name).toBe("Alice"); + expect(written.email).toBe("alice@test.com"); + expect(written.denchOrgId).toBe("org-x"); + expect(written.privacyMode).toBe(false); + }); +}); diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index 141b3aeb67d..60c1199e997 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -8,6 +8,17 @@ type TelemetryConfig = { noticeShown?: boolean; privacyMode?: boolean; anonymousId?: string; + name?: string; + email?: string; + avatar?: string; + denchOrgId?: string; +}; + +export type PersonInfo = { + name?: string; + email?: string; + avatar?: string; + denchOrgId?: string; }; const TELEMETRY_FILENAME = "telemetry.json"; @@ -28,6 +39,10 @@ export function readTelemetryConfig(): TelemetryConfig { noticeShown: raw.noticeShown === true, privacyMode: raw.privacyMode !== false, anonymousId: typeof raw.anonymousId === "string" ? raw.anonymousId : undefined, + name: typeof raw.name === "string" && raw.name ? raw.name : undefined, + email: typeof raw.email === "string" && raw.email ? raw.email : undefined, + avatar: typeof raw.avatar === "string" && raw.avatar ? raw.avatar : undefined, + denchOrgId: typeof raw.denchOrgId === "string" && raw.denchOrgId ? raw.denchOrgId : undefined, }; } catch { return { enabled: true }; @@ -55,6 +70,23 @@ export function isPrivacyModeEnabled(): boolean { return config.privacyMode !== false; } +/** + * Read optional person identity fields from telemetry.json. + * Returns null when no identity fields are set. + */ +export function readPersonInfo(): PersonInfo | null { + const config = readTelemetryConfig(); + if (!config.name && !config.email && !config.avatar && !config.denchOrgId) { + return null; + } + const info: PersonInfo = {}; + if (config.name) info.name = config.name; + if (config.email) info.email = config.email; + if (config.avatar) info.avatar = config.avatar; + if (config.denchOrgId) info.denchOrgId = config.denchOrgId; + return info; +} + let _cachedAnonymousId: string | null = null; /** diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index 89fe9988ce9..e42bd2e7dc7 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -1,11 +1,13 @@ import { PostHog } from "posthog-node"; -import { readTelemetryConfig, getOrCreateAnonymousId } from "./config.js"; +import { readTelemetryConfig, getOrCreateAnonymousId, readPersonInfo } from "./config.js"; +import type { PersonInfo } from "./config.js"; import { VERSION, resolveOpenClawVersion } from "../version.js"; const POSTHOG_KEY = process.env.POSTHOG_KEY || ""; const POSTHOG_HOST = "https://us.i.posthog.com"; let client: PostHog | null = null; +let _identified = false; export function isTelemetryEnabled(): boolean { if (!POSTHOG_KEY) return false; @@ -58,17 +60,38 @@ export function track(event: string, properties?: Record): void const ph = ensureClient(); if (!ph) return; + const distinctId = getOrCreateAnonymousId(); + + if (!_identified) { + _identified = true; + const person = readPersonInfo(); + if (person) { + ph.identify({ + distinctId, + properties: personInfoToPostHogProps(person), + }); + } + } + ph.capture({ - distinctId: getOrCreateAnonymousId(), + distinctId, event, properties: { ...getMachineContext(), ...properties, - $process_person_profile: false, }, }); } +function personInfoToPostHogProps(person: PersonInfo): Record { + const props: Record = {}; + if (person.name) props.$name = person.name; + if (person.email) props.$email = person.email; + if (person.avatar) props.$avatar = person.avatar; + if (person.denchOrgId) props.dench_org_id = person.denchOrgId; + return props; +} + export async function shutdownTelemetry(): Promise { if (client) { await client.shutdown();