kumarabhirup a0853ec83c
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.
2026-03-05 19:09:05 -08:00

397 lines
12 KiB
TypeScript

import type { PostHogClient } from "./posthog-client.js";
import type { TraceContextManager } from "./trace-context.js";
import { readOrCreateAnonymousId, sanitizeMessages, sanitizeOutputChoices, stripSecrets } from "./privacy.js";
/**
* Extract actual token counts and cost from OpenClaw's per-message usage metadata.
*/
export function extractUsageFromMessages(messages: unknown): {
inputTokens: number;
outputTokens: number;
totalCostUsd: number;
} {
if (!Array.isArray(messages)) return { inputTokens: 0, outputTokens: 0, totalCostUsd: 0 };
let inputTokens = 0;
let outputTokens = 0;
let totalCostUsd = 0;
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
if (m.role !== "assistant") continue;
const usage = m.usage as Record<string, unknown> | undefined;
if (!usage) continue;
if (typeof usage.input === "number") inputTokens += usage.input;
if (typeof usage.output === "number") outputTokens += usage.output;
const cost = usage.cost as Record<string, unknown> | undefined;
if (cost && typeof cost.total === "number") totalCostUsd += cost.total;
}
return { inputTokens, outputTokens, totalCostUsd };
}
/**
* Extract tool call names from the messages array provided by agent_end.
* Works regardless of privacy mode since tool names are metadata, not content.
*/
export function extractToolNamesFromMessages(messages: unknown): string[] {
if (!Array.isArray(messages)) return [];
const names: string[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
const name = (tc as any)?.function?.name ?? (tc as any)?.name;
if (typeof name === "string" && name) names.push(name);
}
}
if (Array.isArray(m.content)) {
for (const block of m.content) {
if ((block as any)?.type === "tool_use" && typeof (block as any)?.name === "string") {
names.push((block as any).name);
}
if ((block as any)?.type === "toolCall" && typeof (block as any)?.name === "string") {
names.push((block as any).name);
}
if ((block as any)?.type === "tool-call" && typeof (block as any)?.toolName === "string") {
names.push((block as any).toolName);
}
}
}
if (m.role === "tool" && typeof m.name === "string") {
names.push(m.name);
}
}
return [...new Set(names)];
}
/**
* Normalize OpenClaw's message format into OpenAI-compatible output choices
* so PostHog can extract tool calls for the Tools tab.
*/
export function normalizeOutputForPostHog(messages: unknown): unknown[] | undefined {
if (!Array.isArray(messages)) return undefined;
const choices: unknown[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
if (m.role !== "assistant") continue;
const toolCalls: unknown[] = [];
let textContent = "";
if (Array.isArray(m.content)) {
for (const block of m.content as Array<Record<string, unknown>>) {
if (block.type === "text" && typeof block.text === "string") {
textContent += block.text;
}
if (block.type === "toolCall" && typeof block.name === "string") {
toolCalls.push({
id: block.id ?? block.toolCallId,
type: "function",
function: {
name: block.name,
arguments: typeof block.arguments === "string"
? block.arguments
: JSON.stringify(block.arguments ?? {}),
},
});
}
}
} else if (typeof m.content === "string") {
textContent = m.content;
}
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls as Array<Record<string, unknown>>) {
toolCalls.push(tc);
}
}
const choice: Record<string, unknown> = {
role: "assistant",
content: textContent || null,
};
if (toolCalls.length > 0) {
choice.tool_calls = toolCalls;
}
choices.push(choice);
}
return choices.length > 0 ? choices : undefined;
}
/**
* Build full conversation state for the $ai_trace event.
* Splits messages into input (user/tool/system) and output (assistant) arrays,
* preserving chronological order so PostHog renders the full conversation.
*/
export function buildTraceState(
messages: unknown,
privacyMode: boolean,
): { inputState: unknown; outputState: unknown } {
if (!Array.isArray(messages)) return { inputState: undefined, outputState: undefined };
const inputMessages: unknown[] = [];
const outputMessages: unknown[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const extractText = () => {
if (Array.isArray(m.content)) {
return (m.content as Array<Record<string, unknown>>)
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
}
return typeof m.content === "string" ? m.content : null;
};
if (m.role === "assistant") {
const content = privacyMode ? "[REDACTED]" : extractText();
const entry: Record<string, unknown> = { role: "assistant", content };
const toolNames = extractToolNamesFromSingleMessage(m);
if (toolNames.length > 0) {
entry.tool_calls = toolNames.map((name) => ({
type: "function",
function: { name },
}));
}
outputMessages.push(entry);
} else if (m.role === "user" || m.role === "tool" || m.role === "toolResult" || m.role === "system") {
const content = privacyMode ? "[REDACTED]" : extractText();
const entry: Record<string, unknown> = { role: m.role, content };
if (m.name) entry.name = m.name;
if (m.toolName) entry.toolName = m.toolName;
inputMessages.push(entry);
}
}
return {
inputState: inputMessages.length > 0 ? inputMessages : undefined,
outputState: outputMessages.length > 0 ? outputMessages : undefined,
};
}
function extractToolNamesFromSingleMessage(m: Record<string, unknown>): string[] {
const names: string[] = [];
if (Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
const name = (tc as any)?.function?.name ?? (tc as any)?.name;
if (typeof name === "string" && name) names.push(name);
}
}
if (Array.isArray(m.content)) {
for (const block of m.content) {
if ((block as any)?.type === "toolCall" && typeof (block as any)?.name === "string") {
names.push((block as any).name);
}
}
}
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.
*/
export function emitGeneration(
ph: PostHogClient,
traceCtx: TraceContextManager,
sessionKey: string,
event: any,
privacyMode: boolean,
): void {
try {
const trace = traceCtx.getTrace(sessionKey);
if (!trace) return;
const latency = event.durationMs != null
? event.durationMs / 1_000
: trace.startedAt
? (Date.now() - trace.startedAt) / 1_000
: undefined;
const spanToolNames = trace.toolSpans.map((s) => s.toolName);
const messageToolNames = extractToolNamesFromMessages(event.messages);
const allToolNames = [...new Set([...spanToolNames, ...messageToolNames])];
const properties: Record<string, unknown> = {
$ai_trace_id: trace.traceId,
$ai_session_id: trace.sessionId,
$ai_model: trace.model ?? event.model ?? "unknown",
$ai_provider: trace.provider ?? event.provider,
$ai_latency: latency,
$ai_tools: allToolNames.length > 0
? allToolNames.map((name) => ({ type: "function", function: { name } }))
: undefined,
$ai_stream: event.stream,
$ai_temperature: event.temperature,
$ai_is_error: event.success === false || Boolean(event.error),
};
if (event.usage) {
const inputTokens = event.usage.inputTokens ?? event.usage.input_tokens;
const outputTokens = event.usage.outputTokens ?? event.usage.output_tokens;
if (inputTokens != null && inputTokens > 0) properties.$ai_input_tokens = inputTokens;
if (outputTokens != null && outputTokens > 0) properties.$ai_output_tokens = outputTokens;
const cost = event.cost?.totalUsd ?? event.cost?.total_usd;
if (cost != null && cost > 0) properties.$ai_total_cost_usd = cost;
} else if (event.messages) {
const extracted = extractUsageFromMessages(event.messages);
if (extracted.inputTokens > 0) properties.$ai_input_tokens = extracted.inputTokens;
if (extracted.outputTokens > 0) properties.$ai_output_tokens = extracted.outputTokens;
if (extracted.totalCostUsd > 0) properties.$ai_total_cost_usd = extracted.totalCostUsd;
}
properties.$ai_input = sanitizeMessages(
extractInputMessages(event.messages) ?? trace.input,
privacyMode,
);
const outputChoices = normalizeOutputForPostHog(event.messages);
properties.$ai_output_choices = sanitizeOutputChoices(
outputChoices ?? event.output ?? event.messages,
privacyMode,
);
if (event.error) {
properties.$ai_error = typeof event.error === "string"
? event.error
: event.error?.message ?? String(event.error);
}
ph.capture({
distinctId: readOrCreateAnonymousId(),
event: "$ai_generation",
properties,
});
} catch {
// Never crash the gateway for telemetry failures.
}
}
/**
* Emit a `$ai_span` event for a completed tool call.
*/
export function emitToolSpan(
ph: PostHogClient,
traceCtx: TraceContextManager,
sessionKey: string,
event: any,
privacyMode: boolean,
): void {
try {
const trace = traceCtx.getTrace(sessionKey);
const span = traceCtx.getLastToolSpan(sessionKey);
if (!trace || !span) return;
const latency = span.startedAt && span.endedAt
? (span.endedAt - span.startedAt) / 1_000
: event.durationMs != null
? event.durationMs / 1_000
: undefined;
const properties: Record<string, unknown> = {
$ai_trace_id: trace.traceId,
$ai_session_id: trace.sessionId,
$ai_span_id: span.spanId,
$ai_span_name: span.toolName,
$ai_parent_id: trace.traceId,
$ai_latency: latency,
$ai_is_error: span.isError ?? Boolean(event.error),
};
if (!privacyMode) {
properties.tool_params = stripSecrets(span.params);
properties.tool_result = stripSecrets(span.result);
}
ph.capture({
distinctId: readOrCreateAnonymousId(),
event: "$ai_span",
properties,
});
} catch {
// Fail silently.
}
}
/**
* Emit a `$ai_trace` event for the completed agent run.
*/
export function emitTrace(
ph: PostHogClient,
traceCtx: TraceContextManager,
sessionKey: string,
event?: any,
privacyMode?: boolean,
): void {
try {
const trace = traceCtx.getTrace(sessionKey);
if (!trace) return;
const latency = trace.startedAt
? (Date.now() - trace.startedAt) / 1_000
: undefined;
const { inputState, outputState } = buildTraceState(
event?.messages,
privacyMode ?? true,
);
ph.capture({
distinctId: readOrCreateAnonymousId(),
event: "$ai_trace",
properties: {
$ai_trace_id: trace.traceId,
$ai_session_id: trace.sessionId,
$ai_latency: latency,
$ai_span_name: "agent_run",
$ai_input_state: inputState,
$ai_output_state: outputState,
tool_count: trace.toolSpans.length,
},
});
} catch {
// Fail silently.
}
}
/**
* Emit a custom DenchClaw event (not a PostHog $ai_* event).
*/
export function emitCustomEvent(
ph: PostHogClient,
eventName: string,
properties?: Record<string, unknown>,
): void {
try {
ph.capture({
distinctId: readOrCreateAnonymousId(),
event: eventName,
properties: {
...properties,
$process_person_profile: false,
},
});
} catch {
// Fail silently.
}
}