feat: Basic telemetry - feature flags, metrics, feature gates (Phase 3D)
- FeatureFlagService: local JSON config with env override, hot reload, typed accessors
- MetricsCollector: session metrics (tools, commands, tokens, errors), daily JSON persistence
- FeatureGate: connects flags to tool/feature enable checks with status reporting
- Default flags for all major features (server, worktree, LSP, coordinator, etc.)
- Metrics flushed to ~/.claude-code/metrics/{date}.json
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
package com.claudecode.telemetry;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Flag 服务 —— 对应 claude-code 中 GrowthBook 的本地替代。
|
||||||
|
* <p>
|
||||||
|
* 使用本地 JSON 文件管理 feature flags,支持:
|
||||||
|
* <ul>
|
||||||
|
* <li>布尔型 flag(功能开关)</li>
|
||||||
|
* <li>字符串/数字型 flag(配置值)</li>
|
||||||
|
* <li>运行时重加载</li>
|
||||||
|
* <li>环境变量覆盖 (CLAUDE_CODE_FF_xxx)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 配置文件位置: ~/.claude-code/feature-flags.json
|
||||||
|
*/
|
||||||
|
public class FeatureFlagService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FeatureFlagService.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** flag 默认值 */
|
||||||
|
private static final Map<String, Object> DEFAULTS = Map.ofEntries(
|
||||||
|
Map.entry("DIRECT_CONNECT", true),
|
||||||
|
Map.entry("WORKTREE_MODE", true),
|
||||||
|
Map.entry("LSP_INTEGRATION", true),
|
||||||
|
Map.entry("SESSION_MEMORY", true),
|
||||||
|
Map.entry("COORDINATOR_MODE", true),
|
||||||
|
Map.entry("PLUGIN_MARKETPLACE", false),
|
||||||
|
Map.entry("ADVANCED_UI", false),
|
||||||
|
Map.entry("VOICE_INPUT", false),
|
||||||
|
Map.entry("AUTO_COMPACT", true),
|
||||||
|
Map.entry("METRICS_COLLECTION", false)
|
||||||
|
);
|
||||||
|
|
||||||
|
private final Path configFile;
|
||||||
|
private final ConcurrentHashMap<String, Object> flags = new ConcurrentHashMap<>();
|
||||||
|
private long lastLoadTime = 0;
|
||||||
|
|
||||||
|
public FeatureFlagService() {
|
||||||
|
this(Path.of(System.getProperty("user.home"), ".claude-code", "feature-flags.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public FeatureFlagService(Path configFile) {
|
||||||
|
this.configFile = configFile;
|
||||||
|
loadFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取布尔型 flag。
|
||||||
|
*/
|
||||||
|
public boolean isEnabled(String flagName) {
|
||||||
|
// 环境变量覆盖(最高优先级)
|
||||||
|
String envKey = "CLAUDE_CODE_FF_" + flagName;
|
||||||
|
String envVal = System.getenv(envKey);
|
||||||
|
if (envVal != null) {
|
||||||
|
return "true".equalsIgnoreCase(envVal) || "1".equals(envVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object value = flags.get(flagName);
|
||||||
|
if (value instanceof Boolean b) return b;
|
||||||
|
if (value != null) return Boolean.parseBoolean(value.toString());
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
Object def = DEFAULTS.get(flagName);
|
||||||
|
if (def instanceof Boolean b) return b;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字符串型 flag。
|
||||||
|
*/
|
||||||
|
public String getString(String flagName, String defaultValue) {
|
||||||
|
String envKey = "CLAUDE_CODE_FF_" + flagName;
|
||||||
|
String envVal = System.getenv(envKey);
|
||||||
|
if (envVal != null) return envVal;
|
||||||
|
|
||||||
|
Object value = flags.get(flagName);
|
||||||
|
if (value != null) return value.toString();
|
||||||
|
|
||||||
|
Object def = DEFAULTS.get(flagName);
|
||||||
|
if (def != null) return def.toString();
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数字型 flag。
|
||||||
|
*/
|
||||||
|
public long getNumber(String flagName, long defaultValue) {
|
||||||
|
String envKey = "CLAUDE_CODE_FF_" + flagName;
|
||||||
|
String envVal = System.getenv(envKey);
|
||||||
|
if (envVal != null) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(envVal);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object value = flags.get(flagName);
|
||||||
|
if (value instanceof Number n) return n.longValue();
|
||||||
|
if (value != null) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(value.toString());
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object def = DEFAULTS.get(flagName);
|
||||||
|
if (def instanceof Number n) return n.longValue();
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 flag 值(运行时)。
|
||||||
|
*/
|
||||||
|
public void setFlag(String flagName, Object value) {
|
||||||
|
flags.put(flagName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 flag 及其当前值。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getAllFlags() {
|
||||||
|
Map<String, Object> result = new ConcurrentHashMap<>(DEFAULTS);
|
||||||
|
result.putAll(flags);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从配置文件加载 flag。
|
||||||
|
*/
|
||||||
|
public void loadFlags() {
|
||||||
|
if (!Files.isRegularFile(configFile)) {
|
||||||
|
log.debug("Feature flag config not found: {}", configFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, Object> loaded = MAPPER.readValue(
|
||||||
|
configFile.toFile(), new TypeReference<>() {});
|
||||||
|
flags.putAll(loaded);
|
||||||
|
lastLoadTime = System.currentTimeMillis();
|
||||||
|
log.info("Loaded {} feature flags from {}", loaded.size(), configFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to load feature flags: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存当前 flag 到配置文件。
|
||||||
|
*/
|
||||||
|
public void saveFlags() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(configFile.getParent());
|
||||||
|
MAPPER.writerWithDefaultPrettyPrinter()
|
||||||
|
.writeValue(configFile.toFile(), flags);
|
||||||
|
log.info("Saved {} feature flags to {}", flags.size(), configFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to save feature flags: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新加载(如果文件有变化)。
|
||||||
|
*/
|
||||||
|
public void reloadIfChanged() {
|
||||||
|
try {
|
||||||
|
if (!Files.isRegularFile(configFile)) return;
|
||||||
|
long mtime = Files.getLastModifiedTime(configFile).toMillis();
|
||||||
|
if (mtime > lastLoadTime) {
|
||||||
|
loadFlags();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to check flag file mtime: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.claudecode.telemetry;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Gate — 连接 FeatureFlagService 到具体功能/工具的开关控制。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 中散布在各处的 featureFlag.isEnabled() 检查。
|
||||||
|
* 提供统一的 gate 注册和查询机制。
|
||||||
|
*/
|
||||||
|
public class FeatureGate {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FeatureGate.class);
|
||||||
|
|
||||||
|
private final FeatureFlagService flagService;
|
||||||
|
|
||||||
|
/** tool name → flag name 映射 */
|
||||||
|
private final Map<String, String> toolGates = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** feature category → flag name 映射 */
|
||||||
|
private final Map<String, String> featureGates = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public FeatureGate(FeatureFlagService flagService) {
|
||||||
|
this.flagService = flagService;
|
||||||
|
registerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerDefaults() {
|
||||||
|
// 工具级 gate
|
||||||
|
registerToolGate("enter_worktree", "WORKTREE_MODE");
|
||||||
|
registerToolGate("exit_worktree", "WORKTREE_MODE");
|
||||||
|
|
||||||
|
// 功能级 gate
|
||||||
|
registerFeatureGate("server_mode", "DIRECT_CONNECT");
|
||||||
|
registerFeatureGate("lsp", "LSP_INTEGRATION");
|
||||||
|
registerFeatureGate("session_memory", "SESSION_MEMORY");
|
||||||
|
registerFeatureGate("coordinator", "COORDINATOR_MODE");
|
||||||
|
registerFeatureGate("plugin_marketplace", "PLUGIN_MARKETPLACE");
|
||||||
|
registerFeatureGate("auto_compact", "AUTO_COMPACT");
|
||||||
|
registerFeatureGate("metrics", "METRICS_COLLECTION");
|
||||||
|
registerFeatureGate("voice", "VOICE_INPUT");
|
||||||
|
registerFeatureGate("advanced_ui", "ADVANCED_UI");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册工具级 gate。
|
||||||
|
*/
|
||||||
|
public void registerToolGate(String toolName, String flagName) {
|
||||||
|
toolGates.put(toolName, flagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册功能级 gate。
|
||||||
|
*/
|
||||||
|
public void registerFeatureGate(String featureName, String flagName) {
|
||||||
|
featureGates.put(featureName, flagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查工具是否启用。
|
||||||
|
*/
|
||||||
|
public boolean isToolEnabled(String toolName) {
|
||||||
|
String flagName = toolGates.get(toolName);
|
||||||
|
if (flagName == null) return true; // 没注册 gate 的工具默认启用
|
||||||
|
return flagService.isEnabled(flagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查功能是否启用。
|
||||||
|
*/
|
||||||
|
public boolean isFeatureEnabled(String featureName) {
|
||||||
|
String flagName = featureGates.get(featureName);
|
||||||
|
if (flagName == null) return true;
|
||||||
|
return flagService.isEnabled(flagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已禁用的工具名称。
|
||||||
|
*/
|
||||||
|
public Set<String> getDisabledTools() {
|
||||||
|
Set<String> disabled = new LinkedHashSet<>();
|
||||||
|
for (Map.Entry<String, String> entry : toolGates.entrySet()) {
|
||||||
|
if (!flagService.isEnabled(entry.getValue())) {
|
||||||
|
disabled.add(entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已禁用的功能名称。
|
||||||
|
*/
|
||||||
|
public Set<String> getDisabledFeatures() {
|
||||||
|
Set<String> disabled = new LinkedHashSet<>();
|
||||||
|
for (Map.Entry<String, String> entry : featureGates.entrySet()) {
|
||||||
|
if (!flagService.isEnabled(entry.getValue())) {
|
||||||
|
disabled.add(entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成人类可读的 gate 状态报告。
|
||||||
|
*/
|
||||||
|
public String statusReport() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("=== Feature Gates ===\n");
|
||||||
|
|
||||||
|
if (!featureGates.isEmpty()) {
|
||||||
|
sb.append("\nFeatures:\n");
|
||||||
|
for (Map.Entry<String, String> entry : featureGates.entrySet()) {
|
||||||
|
boolean enabled = flagService.isEnabled(entry.getValue());
|
||||||
|
sb.append(" ").append(enabled ? "✓" : "✗").append(" ")
|
||||||
|
.append(entry.getKey()).append(" (").append(entry.getValue()).append(")\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toolGates.isEmpty()) {
|
||||||
|
sb.append("\nTools:\n");
|
||||||
|
for (Map.Entry<String, String> entry : toolGates.entrySet()) {
|
||||||
|
boolean enabled = flagService.isEnabled(entry.getValue());
|
||||||
|
sb.append(" ").append(enabled ? "✓" : "✗").append(" ")
|
||||||
|
.append(entry.getKey()).append(" (").append(entry.getValue()).append(")\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public FeatureFlagService getFlagService() {
|
||||||
|
return flagService;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package com.claudecode.telemetry;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础指标收集器 —— 替代 claude-code 中的 Datadog/OpenTelemetry 全套。
|
||||||
|
* <p>
|
||||||
|
* 只收集本地指标,不上报到任何远程服务。
|
||||||
|
* <p>
|
||||||
|
* 收集的指标:
|
||||||
|
* <ul>
|
||||||
|
* <li>会话时长</li>
|
||||||
|
* <li>工具使用次数(按工具名)</li>
|
||||||
|
* <li>命令使用次数(按命令名)</li>
|
||||||
|
* <li>API 调用次数和 token 用量</li>
|
||||||
|
* <li>错误次数(按类型)</li>
|
||||||
|
* <li>自动压缩次数</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 存储位置: ~/.claude-code/metrics/{date}.json
|
||||||
|
*/
|
||||||
|
public class MetricsCollector {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MetricsCollector.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||||
|
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||||
|
|
||||||
|
private final Path metricsDir;
|
||||||
|
private final String sessionId;
|
||||||
|
private final Instant sessionStart;
|
||||||
|
|
||||||
|
// ==================== 计数器 ====================
|
||||||
|
|
||||||
|
/** 工具使用次数: toolName → count */
|
||||||
|
private final ConcurrentHashMap<String, AtomicLong> toolUsage = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** 命令使用次数: commandName → count */
|
||||||
|
private final ConcurrentHashMap<String, AtomicLong> commandUsage = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** 错误次数: errorType → count */
|
||||||
|
private final ConcurrentHashMap<String, AtomicLong> errorCounts = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** API 调用次数 */
|
||||||
|
private final AtomicLong apiCallCount = new AtomicLong(0);
|
||||||
|
|
||||||
|
/** 总 token 使用量 */
|
||||||
|
private final AtomicLong totalInputTokens = new AtomicLong(0);
|
||||||
|
private final AtomicLong totalOutputTokens = new AtomicLong(0);
|
||||||
|
|
||||||
|
/** 自动压缩次数 */
|
||||||
|
private final AtomicLong autoCompactCount = new AtomicLong(0);
|
||||||
|
|
||||||
|
/** 用户消息数 */
|
||||||
|
private final AtomicLong userMessageCount = new AtomicLong(0);
|
||||||
|
|
||||||
|
/** 助手消息数 */
|
||||||
|
private final AtomicLong assistantMessageCount = new AtomicLong(0);
|
||||||
|
|
||||||
|
public MetricsCollector() {
|
||||||
|
this(Path.of(System.getProperty("user.home"), ".claude-code", "metrics"),
|
||||||
|
UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
public MetricsCollector(Path metricsDir, String sessionId) {
|
||||||
|
this.metricsDir = metricsDir;
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.sessionStart = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 记录方法 ====================
|
||||||
|
|
||||||
|
public void recordToolUse(String toolName) {
|
||||||
|
toolUsage.computeIfAbsent(toolName, k -> new AtomicLong(0)).incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordCommand(String commandName) {
|
||||||
|
commandUsage.computeIfAbsent(commandName, k -> new AtomicLong(0)).incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordError(String errorType) {
|
||||||
|
errorCounts.computeIfAbsent(errorType, k -> new AtomicLong(0)).incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordApiCall(long inputTokens, long outputTokens) {
|
||||||
|
apiCallCount.incrementAndGet();
|
||||||
|
totalInputTokens.addAndGet(inputTokens);
|
||||||
|
totalOutputTokens.addAndGet(outputTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordAutoCompact() {
|
||||||
|
autoCompactCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordUserMessage() {
|
||||||
|
userMessageCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordAssistantMessage() {
|
||||||
|
assistantMessageCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 获取指标 ====================
|
||||||
|
|
||||||
|
public long getSessionDurationSeconds() {
|
||||||
|
return Instant.now().getEpochSecond() - sessionStart.getEpochSecond();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Long> getToolUsage() {
|
||||||
|
Map<String, Long> result = new TreeMap<>();
|
||||||
|
toolUsage.forEach((k, v) -> result.put(k, v.get()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Long> getCommandUsage() {
|
||||||
|
Map<String, Long> result = new TreeMap<>();
|
||||||
|
commandUsage.forEach((k, v) -> result.put(k, v.get()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Long> getErrorCounts() {
|
||||||
|
Map<String, Long> result = new TreeMap<>();
|
||||||
|
errorCounts.forEach((k, v) -> result.put(k, v.get()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 持久化 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将当前会话指标保存到按日期分割的 JSON 文件。
|
||||||
|
*/
|
||||||
|
public void flush() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(metricsDir);
|
||||||
|
|
||||||
|
String date = LocalDate.now(ZoneId.systemDefault()).toString();
|
||||||
|
Path file = metricsDir.resolve(date + ".json");
|
||||||
|
|
||||||
|
// 读取已有数据
|
||||||
|
List<Map<String, Object>> sessions = new ArrayList<>();
|
||||||
|
if (Files.isRegularFile(file)) {
|
||||||
|
try {
|
||||||
|
sessions = MAPPER.readValue(file.toFile(),
|
||||||
|
MAPPER.getTypeFactory().constructCollectionType(List.class, Map.class));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Failed to read existing metrics file, starting fresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加当前会话
|
||||||
|
sessions.add(toMap());
|
||||||
|
|
||||||
|
// 写入
|
||||||
|
MAPPER.writeValue(file.toFile(), sessions);
|
||||||
|
log.debug("Metrics flushed to {}", file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to flush metrics: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将指标转为 Map(用于序列化)。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("session_id", sessionId);
|
||||||
|
map.put("start_time", sessionStart.toString());
|
||||||
|
map.put("duration_seconds", getSessionDurationSeconds());
|
||||||
|
map.put("api_calls", apiCallCount.get());
|
||||||
|
map.put("input_tokens", totalInputTokens.get());
|
||||||
|
map.put("output_tokens", totalOutputTokens.get());
|
||||||
|
map.put("user_messages", userMessageCount.get());
|
||||||
|
map.put("assistant_messages", assistantMessageCount.get());
|
||||||
|
map.put("auto_compacts", autoCompactCount.get());
|
||||||
|
map.put("tool_usage", getToolUsage());
|
||||||
|
map.put("command_usage", getCommandUsage());
|
||||||
|
map.put("errors", getErrorCounts());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指标摘要文本(用于 /doctor 或 /session 命令)。
|
||||||
|
*/
|
||||||
|
public String summary() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Session: ").append(sessionId).append("\n");
|
||||||
|
sb.append("Duration: ").append(formatDuration(getSessionDurationSeconds())).append("\n");
|
||||||
|
sb.append("API Calls: ").append(apiCallCount.get()).append("\n");
|
||||||
|
sb.append("Tokens: ").append(totalInputTokens.get()).append(" in / ")
|
||||||
|
.append(totalOutputTokens.get()).append(" out\n");
|
||||||
|
sb.append("Messages: ").append(userMessageCount.get()).append(" user / ")
|
||||||
|
.append(assistantMessageCount.get()).append(" assistant\n");
|
||||||
|
sb.append("Auto-compacts: ").append(autoCompactCount.get()).append("\n");
|
||||||
|
|
||||||
|
if (!toolUsage.isEmpty()) {
|
||||||
|
sb.append("Top tools: ");
|
||||||
|
toolUsage.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.comparingByValue(Comparator.comparingLong(AtomicLong::get).reversed()))
|
||||||
|
.limit(5)
|
||||||
|
.forEach(e -> sb.append(e.getKey()).append("(").append(e.getValue().get()).append(") "));
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errorCounts.isEmpty()) {
|
||||||
|
sb.append("Errors: ");
|
||||||
|
errorCounts.forEach((k, v) -> sb.append(k).append("(").append(v.get()).append(") "));
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatDuration(long seconds) {
|
||||||
|
if (seconds < 60) return seconds + "s";
|
||||||
|
if (seconds < 3600) return (seconds / 60) + "m " + (seconds % 60) + "s";
|
||||||
|
return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionId() { return sessionId; }
|
||||||
|
public Instant getSessionStart() { return sessionStart; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user