diff --git a/extensions/posthog-analytics/index.ts b/extensions/posthog-analytics/index.ts index cda656b92ef..627e322fb04 100644 --- a/extensions/posthog-analytics/index.ts +++ b/extensions/posthog-analytics/index.ts @@ -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); diff --git a/extensions/posthog-analytics/lib/event-mappers.ts b/extensions/posthog-analytics/lib/event-mappers.ts index 94ef45b6c34..a76178b0016 100644 --- a/extensions/posthog-analytics/lib/event-mappers.ts +++ b/extensions/posthog-analytics/lib/event-mappers.ts @@ -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[] 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, diff --git a/extensions/posthog-analytics/lib/privacy.ts b/extensions/posthog-analytics/lib/privacy.ts index 87072bfd090..df9b3d552ae 100644 --- a/extensions/posthog-analytics/lib/privacy.ts +++ b/extensions/posthog-analytics/lib/privacy.ts @@ -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 = {}; + 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") { diff --git a/src/telemetry/plugin-key-fallback.test.ts b/src/telemetry/plugin-key-fallback.test.ts new file mode 100644 index 00000000000..c3e6e5782b8 --- /dev/null +++ b/src/telemetry/plugin-key-fallback.test.ts @@ -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) { + const handlers: Record = {}; + 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" }), + ); + }); +});