From 351b71fd05084c64383806c19a7575bfaf22ca45 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 18 Mar 2026 00:08:23 -0700 Subject: [PATCH] feat(telemetry): add person identity support and enable session replay Add optional name, email, avatar, and denchOrgId fields to telemetry.json. When present, all telemetry layers (CLI, web server, web client, OpenClaw plugin) call PostHog identify() with $name, $email, $avatar, and dench_org_id person properties. Remove $process_person_profile:false from all layers so every install gets a PostHog person profile. Enable session replay with masking controlled by privacy mode (all text/inputs masked when on, nothing masked when off). --- TELEMETRY.md | 22 ++- apps/web/app/components/posthog-provider.tsx | 36 +++- apps/web/app/layout.tsx | 6 +- apps/web/lib/telemetry.ts | 90 +++++++++- extensions/posthog-analytics/index.ts | 13 +- .../posthog-analytics/lib/event-mappers.ts | 5 +- .../posthog-analytics/lib/posthog-client.ts | 17 ++ extensions/posthog-analytics/lib/privacy.ts | 37 ++++ src/telemetry/config.test.ts | 164 ++++++++++++++++++ src/telemetry/config.ts | 32 ++++ src/telemetry/telemetry.ts | 29 +++- 11 files changed, 433 insertions(+), 18 deletions(-) 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();