openclaw/apps/web/lib/telemetry.ts
kumarabhirup 351b71fd05
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).
2026-03-18 00:08:23 -07:00

165 lines
4.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { PostHog } from "posthog-node";
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY || "";
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 || "";
let client: PostHog | null = null;
function ensureClient(): PostHog | null {
if (!POSTHOG_KEY) return null;
if (!client) {
client = new PostHog(POSTHOG_KEY, {
host: POSTHOG_HOST,
flushAt: 10,
flushInterval: 30_000,
});
}
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,
* generating and writing one if absent.
*/
export function getOrCreateAnonymousId(): string {
if (_cachedAnonymousId) return _cachedAnonymousId;
try {
const stateDir = join(process.env.HOME || homedir(), ".openclaw-dench");
const configPath = join(stateDir, "telemetry.json");
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;
}
}
/**
* 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>,
distinctId?: string,
): void {
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: id,
event,
properties: {
...properties,
denchclaw_version: DENCHCLAW_VERSION || undefined,
openclaw_version: OPENCLAW_VERSION || undefined,
},
});
}