- 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>
pull/1/head
parent
293dd5657c
commit
76191f5035
@ -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; } |
||||||
|
} |
||||||
Loading…
Reference in new issue