kumarabhirup fdd89b4e6f
feat: add PostHog AI observability, feedback UI, and telemetry privacy mode
Integrate PostHog LLM Analytics via a bundled OpenClaw plugin that captures
$ai_generation, $ai_span, and $ai_trace events with configurable privacy
mode (content redaction on by default). Add like/dislike feedback buttons
to the web chat UI backed by a /api/feedback route. Extend the CLI with
`telemetry privacy on|off` subcommands and fix command delegation so
telemetry subcommands aren't forwarded to OpenClaw. Harden the web runtime
installer to auto-flatten pnpm standalone deps and dereference dangling
symlinks, preventing "Cannot find module 'next'" crashes in dev. Move
plugin installation before onboard in bootstrap so the gateway starts
with plugins.allow already configured.
2026-03-05 12:28:08 -08:00

84 lines
2.5 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { join } 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]";
/**
* 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");
if (!existsSync(configPath)) return true;
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
return raw.privacyMode !== false;
} catch {
return true;
}
}
/** Strip known credential patterns from any string value. */
export function stripSecrets(value: unknown): unknown {
if (typeof value === "string") {
return value.replace(SECRETS_PATTERN, REDACTED);
}
if (Array.isArray(value)) {
return value.map(stripSecrets);
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
const keyLower = k.toLowerCase();
if (
keyLower.includes("key") ||
keyLower.includes("token") ||
keyLower.includes("secret") ||
keyLower.includes("password") ||
keyLower.includes("credential")
) {
out[k] = REDACTED;
} else {
out[k] = stripSecrets(v);
}
}
return out;
}
return value;
}
/**
* Redact message content for privacy mode.
* Preserves structure (role, tool names) but removes actual text content.
*/
export function redactMessages(messages: unknown): unknown {
if (!Array.isArray(messages)) return messages;
return messages.map((msg: any) => {
if (!msg || typeof msg !== "object") return msg;
const redacted: Record<string, unknown> = { role: msg.role };
if (msg.name) redacted.name = msg.name;
if (msg.tool_call_id) redacted.tool_call_id = msg.tool_call_id;
redacted.content = REDACTED;
return redacted;
});
}
/**
* Sanitize a value based on privacy mode.
* When privacy is on: redacts content, always strips secrets.
* When privacy is off: only strips secrets.
*/
export function sanitizeForCapture(
value: unknown,
privacyMode: boolean,
): unknown {
if (privacyMode) return REDACTED;
return stripSecrets(value);
}