@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
169 lines (168 loc) • 5.79 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { runtimeDir } from "./shared.js";
import { errorMessage } from "./utils.js";
import { logger } from "./logger.js";
// In-memory buffers keyed by phrenPath to batch disk writes
// Keeping per-path buffers avoids silently losing events when the active path changes.
const buffers = new Map();
const pendingCounts = new Map();
const FLUSH_THRESHOLD = 10;
function telemetryPath(phrenPath) {
return path.join(runtimeDir(phrenPath), "telemetry.json");
}
function loadFromDisk(phrenPath) {
const file = telemetryPath(phrenPath);
const defaults = {
config: { enabled: false },
stats: { toolCalls: {}, cliCommands: {}, errors: 0, sessions: 0, lastActive: "" },
};
if (!fs.existsSync(file))
return defaults;
try {
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
return {
config: { ...defaults.config, ...raw.config },
stats: { ...defaults.stats, ...raw.stats },
};
}
catch (err) {
logger.debug("telemetry loadFromDisk", errorMessage(err));
return defaults;
}
}
function loadTelemetry(phrenPath) {
const cached = buffers.get(phrenPath);
if (cached)
return cached;
const data = loadFromDisk(phrenPath);
buffers.set(phrenPath, data);
return data;
}
function saveTelemetry(phrenPath, data) {
buffers.set(phrenPath, data);
const pending = (pendingCounts.get(phrenPath) ?? 0) + 1;
pendingCounts.set(phrenPath, pending);
if (pending >= FLUSH_THRESHOLD) {
flushTelemetryForPath(phrenPath);
}
}
function flushTelemetryForPath(phrenPath) {
const data = buffers.get(phrenPath);
const pending = pendingCounts.get(phrenPath) ?? 0;
if (!data || pending === 0)
return;
const file = telemetryPath(phrenPath);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
// Usage stats are private to the user: owner-only perms, including files
// created by older versions (mode only applies on create).
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
fs.chmodSync(file, 0o600);
}
catch (err) {
logger.debug("telemetry flush", errorMessage(err));
}
pendingCounts.set(phrenPath, 0);
}
export function flushTelemetry() {
for (const phrenPath of buffers.keys()) {
flushTelemetryForPath(phrenPath);
}
}
// Register flush on process exit
let exitHookRegistered = false;
function ensureExitHook() {
if (exitHookRegistered)
return;
exitHookRegistered = true;
process.on("exit", () => flushTelemetry());
}
export function isTelemetryEnabled(phrenPath) {
return loadTelemetry(phrenPath).config.enabled;
}
export function setTelemetryEnabled(phrenPath, enabled) {
const data = loadTelemetry(phrenPath);
data.config.enabled = enabled;
if (enabled && !data.config.enabledAt) {
data.config.enabledAt = new Date().toISOString();
}
// Config changes flush immediately
buffers.set(phrenPath, data);
pendingCounts.set(phrenPath, 1);
flushTelemetryForPath(phrenPath);
}
export function trackToolCall(phrenPath, toolName) {
const data = loadTelemetry(phrenPath);
if (!data.config.enabled)
return;
ensureExitHook();
data.stats.toolCalls[toolName] = (data.stats.toolCalls[toolName] || 0) + 1;
data.stats.lastActive = new Date().toISOString();
saveTelemetry(phrenPath, data);
}
export function trackCliCommand(phrenPath, command) {
const data = loadTelemetry(phrenPath);
if (!data.config.enabled)
return;
ensureExitHook();
data.stats.cliCommands[command] = (data.stats.cliCommands[command] || 0) + 1;
data.stats.lastActive = new Date().toISOString();
saveTelemetry(phrenPath, data);
}
export function trackError(phrenPath) {
const data = loadTelemetry(phrenPath);
if (!data.config.enabled)
return;
ensureExitHook();
data.stats.errors += 1;
saveTelemetry(phrenPath, data);
}
export function trackSession(phrenPath) {
const data = loadTelemetry(phrenPath);
if (!data.config.enabled)
return;
ensureExitHook();
data.stats.sessions += 1;
data.stats.lastActive = new Date().toISOString();
saveTelemetry(phrenPath, data);
}
export function getTelemetrySummary(phrenPath) {
const data = loadTelemetry(phrenPath);
if (!data.config.enabled)
return "Telemetry: disabled (opt in with 'phren config telemetry on')";
const topTools = Object.entries(data.stats.toolCalls)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([name, count]) => ` ${name}: ${count}`)
.join("\n");
const topCli = Object.entries(data.stats.cliCommands)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([name, count]) => ` ${name}: ${count}`)
.join("\n");
const lines = [
`Telemetry: enabled (since ${data.config.enabledAt || "unknown"})`,
`Sessions: ${data.stats.sessions}`,
`Errors: ${data.stats.errors}`,
`Last active: ${data.stats.lastActive || "never"}`,
];
if (topTools)
lines.push("Top tools:", topTools);
if (topCli)
lines.push("Top CLI commands:", topCli);
return lines.join("\n");
}
export function resetTelemetry(phrenPath) {
const data = loadTelemetry(phrenPath);
data.stats = { toolCalls: {}, cliCommands: {}, errors: 0, sessions: 0, lastActive: "" };
// Reset flushes immediately
buffers.set(phrenPath, data);
pendingCounts.set(phrenPath, 1);
flushTelemetryForPath(phrenPath);
}
// Reset internal buffer state (for testing)
export function _resetBuffer() {
buffers.clear();
pendingCounts.clear();
}