feat(telemetry): unified identity for PostHog plugin with key fallback

Plugin reads the same persisted install ID from telemetry.json and falls back to a build-time baked PostHog key when no config-level key is set.
This commit is contained in:
kumarabhirup 2026-03-05 19:09:05 -08:00
parent 1e21185d47
commit a0853ec83c
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 179 additions and 26 deletions

View File

@ -2,6 +2,7 @@ import { createPostHogClient, shutdownPostHogClient } from "./lib/posthog-client
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 { POSTHOG_KEY as BUILT_IN_KEY } from "./lib/build-env.js";
import type { PluginConfig } from "./lib/types.js";
export const id = "posthog-analytics";
@ -19,10 +20,16 @@ export default function register(api: any) {
const config: PluginConfig | undefined =
api.config?.plugins?.entries?.["posthog-analytics"]?.config;
if (!config?.apiKey) return;
if (config.enabled === false) return;
const apiKey = config?.apiKey || BUILT_IN_KEY;
const ph = createPostHogClient(config.apiKey, config.host);
if (!apiKey) {
return;
}
if (config?.enabled === false) {
return;
}
const ph = createPostHogClient(apiKey, config?.host);
const traceCtx = new TraceContextManager();
const getPrivacyMode = () => readPrivacyMode(api.config);

View File

@ -1,17 +1,6 @@
import { createHash } from "node:crypto";
import os from "node:os";
import type { PostHogClient } from "./posthog-client.js";
import type { TraceContextManager } from "./trace-context.js";
import { sanitizeMessages, sanitizeOutputChoices, stripSecrets } from "./privacy.js";
function getAnonymousId(): string {
try {
const raw = `${os.hostname()}:${os.userInfo().username}`;
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
} catch {
return "unknown";
}
}
import { readOrCreateAnonymousId, sanitizeMessages, sanitizeOutputChoices, stripSecrets } from "./privacy.js";
/**
* Extract actual token counts and cost from OpenClaw's per-message usage metadata.
@ -205,6 +194,19 @@ function extractToolNamesFromSingleMessage(m: Record<string, unknown>): string[]
return names;
}
/**
* Extract non-assistant messages from the full conversation as model input.
* Returns user, system, and tool messages that represent what was sent to the model.
* Falls back to undefined when no input messages are found.
*/
export function extractInputMessages(messages: unknown): unknown[] | undefined {
if (!Array.isArray(messages)) return undefined;
const input = messages.filter(
(m: any) => m && typeof m === "object" && m.role !== "assistant",
);
return input.length > 0 ? input : undefined;
}
/**
* Emit a `$ai_generation` event from the agent_end hook data.
*/
@ -257,7 +259,10 @@ export function emitGeneration(
if (extracted.totalCostUsd > 0) properties.$ai_total_cost_usd = extracted.totalCostUsd;
}
properties.$ai_input = sanitizeMessages(trace.input, privacyMode);
properties.$ai_input = sanitizeMessages(
extractInputMessages(event.messages) ?? trace.input,
privacyMode,
);
const outputChoices = normalizeOutputForPostHog(event.messages);
properties.$ai_output_choices = sanitizeOutputChoices(
@ -272,7 +277,7 @@ export function emitGeneration(
}
ph.capture({
distinctId: getAnonymousId(),
distinctId: readOrCreateAnonymousId(),
event: "$ai_generation",
properties,
});
@ -318,7 +323,7 @@ export function emitToolSpan(
}
ph.capture({
distinctId: getAnonymousId(),
distinctId: readOrCreateAnonymousId(),
event: "$ai_span",
properties,
});
@ -351,7 +356,7 @@ export function emitTrace(
);
ph.capture({
distinctId: getAnonymousId(),
distinctId: readOrCreateAnonymousId(),
event: "$ai_trace",
properties: {
$ai_trace_id: trace.traceId,
@ -378,7 +383,7 @@ export function emitCustomEvent(
): void {
try {
ph.capture({
distinctId: getAnonymousId(),
distinctId: readOrCreateAnonymousId(),
event: eventName,
properties: {
...properties,

View File

@ -1,21 +1,26 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
const SECRETS_PATTERN =
/(?:sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|xoxb-[a-zA-Z0-9-]+|AKIA[A-Z0-9]{16}|eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,})/g;
const REDACTED = "[REDACTED]";
function resolveConfigPath(openclawConfig?: any): string {
const stateDir =
openclawConfig?.stateDir ??
join(process.env.HOME || "~", ".openclaw-dench");
return join(stateDir, "telemetry.json");
}
/**
* Read privacy mode from DenchClaw's telemetry config.
* Default is true (privacy on) when the file is missing or unreadable.
*/
export function readPrivacyMode(openclawConfig?: any): boolean {
try {
const stateDir =
openclawConfig?.stateDir ??
join(process.env.HOME || "~", ".openclaw-dench");
const configPath = join(stateDir, "telemetry.json");
const configPath = resolveConfigPath(openclawConfig);
if (!existsSync(configPath)) return true;
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
return raw.privacyMode !== false;
@ -24,6 +29,39 @@ export function readPrivacyMode(openclawConfig?: any): boolean {
}
}
let _cachedAnonymousId: string | null = null;
/**
* Read the persisted install-scoped anonymous ID from telemetry.json,
* generating and writing one if absent.
*/
export function readOrCreateAnonymousId(openclawConfig?: any): string {
if (_cachedAnonymousId) return _cachedAnonymousId;
try {
const configPath = resolveConfigPath(openclawConfig);
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;
}
}
/** Strip known credential patterns from any string value. */
export function stripSecrets(value: unknown): unknown {
if (typeof value === "string") {

View File

@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCapture = vi.fn();
const mockCreatePostHogClient = vi.fn(() => ({
capture: mockCapture,
shutdown: vi.fn(),
}));
const mockShutdownPostHogClient = vi.fn();
vi.mock("../../extensions/posthog-analytics/lib/posthog-client.js", () => ({
createPostHogClient: mockCreatePostHogClient,
shutdownPostHogClient: mockShutdownPostHogClient,
}));
vi.mock("../../extensions/posthog-analytics/lib/privacy.js", () => ({
readPrivacyMode: () => true,
readOrCreateAnonymousId: () => "test-anon-id",
sanitizeMessages: (v: unknown) => v,
sanitizeOutputChoices: (v: unknown) => v,
stripSecrets: (v: unknown) => v,
}));
function createMockApi(pluginConfig?: Record<string, unknown>) {
const handlers: Record<string, Function> = {};
return {
config: pluginConfig
? { plugins: { entries: { "posthog-analytics": { config: pluginConfig } } } }
: {},
on: vi.fn((event: string, handler: Function) => {
handlers[event] = handler;
}),
registerService: vi.fn(),
logger: { info: vi.fn() },
};
}
describe("posthog-analytics plugin key fallback", () => {
beforeEach(() => {
vi.resetModules();
mockCreatePostHogClient.mockClear();
mockCapture.mockClear();
});
it("uses api.config.apiKey when provided", async () => {
vi.doMock("../../extensions/posthog-analytics/lib/build-env.js", () => ({
POSTHOG_KEY: "built-in-key",
}));
const { default: register } = await import(
"../../extensions/posthog-analytics/index.js"
);
const api = createMockApi({ apiKey: "config-key", enabled: true });
register(api);
expect(mockCreatePostHogClient).toHaveBeenCalledWith("config-key", undefined);
});
it("falls back to built-in key when api.config has no apiKey", async () => {
vi.doMock("../../extensions/posthog-analytics/lib/build-env.js", () => ({
POSTHOG_KEY: "built-in-key",
}));
const { default: register } = await import(
"../../extensions/posthog-analytics/index.js"
);
const api = createMockApi();
register(api);
expect(mockCreatePostHogClient).toHaveBeenCalledWith("built-in-key", undefined);
});
it("does not initialize when neither config nor built-in key is available", async () => {
vi.doMock("../../extensions/posthog-analytics/lib/build-env.js", () => ({
POSTHOG_KEY: "",
}));
const { default: register } = await import(
"../../extensions/posthog-analytics/index.js"
);
const api = createMockApi();
register(api);
expect(mockCreatePostHogClient).not.toHaveBeenCalled();
expect(api.registerService).not.toHaveBeenCalled();
});
it("registers lifecycle hooks when built-in key is used", async () => {
vi.doMock("../../extensions/posthog-analytics/lib/build-env.js", () => ({
POSTHOG_KEY: "built-in-key",
}));
const { default: register } = await import(
"../../extensions/posthog-analytics/index.js"
);
const api = createMockApi();
register(api);
expect(api.on).toHaveBeenCalled();
expect(api.registerService).toHaveBeenCalledWith(
expect.objectContaining({ id: "posthog-analytics" }),
);
});
});