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:
parent
1e21185d47
commit
a0853ec83c
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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") {
|
||||
|
||||
103
src/telemetry/plugin-key-fallback.test.ts
Normal file
103
src/telemetry/plugin-key-fallback.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user