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:
parent
9a26ab6de1
commit
351b71fd05
22
TELEMETRY.md
22
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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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}</>;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -395,10 +395,7 @@ export function emitCustomEvent(
|
||||
ph.capture({
|
||||
distinctId: readOrCreateAnonymousId(),
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
$process_person_profile: false,
|
||||
},
|
||||
properties: properties ?? {},
|
||||
});
|
||||
} catch {
|
||||
// Fail silently.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user