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).
This commit is contained in:
kumarabhirup 2026-03-18 00:08:23 -07:00
parent 9a26ab6de1
commit 351b71fd05
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
11 changed files with 433 additions and 18 deletions

View File

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

View File

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

View File

@ -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 (
<html lang="en" suppressHydrationWarning>
@ -43,7 +45,7 @@ export default function RootLayout({
<body className="antialiased">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<Suspense fallback={null}>
<PostHogProvider anonymousId={anonymousId}>
<PostHogProvider anonymousId={anonymousId} personInfo={personInfo ?? undefined} privacyMode={privacyMode}>
{children}
</PostHogProvider>
</Suspense>

View File

@ -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<string, unknown>;
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<string, unknown>;
_cachedPrivacyMode = raw.privacyMode !== false;
return _cachedPrivacyMode;
} catch {
_cachedPrivacyMode = true;
return true;
}
}
function personInfoToPostHogProps(person: PersonInfo): Record<string, string> {
const props: Record<string, string> = {};
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<string, unknown>,
@ -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,
},
});
}

View File

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

View File

@ -395,10 +395,7 @@ export function emitCustomEvent(
ph.capture({
distinctId: readOrCreateAnonymousId(),
event: eventName,
properties: {
...properties,
$process_person_profile: false,
},
properties: properties ?? {},
});
} catch {
// Fail silently.

View File

@ -62,6 +62,23 @@ export class PostHogClient {
});
}
identify(distinctId: string, properties: Record<string, unknown>): 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<void> {
if (this.timer) {
clearInterval(this.timer);

View File

@ -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<string, unknown>;
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;
/**

View File

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

View File

@ -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;
/**

View File

@ -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<string, unknown>): 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<string, string> {
const props: Record<string, string> = {};
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<void> {
if (client) {
await client.shutdown();