diff --git a/src/main/java/com/claudecode/telemetry/FeatureFlagService.java b/src/main/java/com/claudecode/telemetry/FeatureFlagService.java new file mode 100644 index 0000000..758f252 --- /dev/null +++ b/src/main/java/com/claudecode/telemetry/FeatureFlagService.java @@ -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 的本地替代。 + *

+ * 使用本地 JSON 文件管理 feature flags,支持: + *

+ *

+ * 配置文件位置: ~/.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 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 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 getAllFlags() { + Map 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 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()); + } + } +} diff --git a/src/main/java/com/claudecode/telemetry/FeatureGate.java b/src/main/java/com/claudecode/telemetry/FeatureGate.java new file mode 100644 index 0000000..48c48c6 --- /dev/null +++ b/src/main/java/com/claudecode/telemetry/FeatureGate.java @@ -0,0 +1,137 @@ +package com.claudecode.telemetry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +/** + * Feature Gate — 连接 FeatureFlagService 到具体功能/工具的开关控制。 + *

+ * 对应 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 toolGates = new LinkedHashMap<>(); + + /** feature category → flag name 映射 */ + private final Map 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 getDisabledTools() { + Set disabled = new LinkedHashSet<>(); + for (Map.Entry entry : toolGates.entrySet()) { + if (!flagService.isEnabled(entry.getValue())) { + disabled.add(entry.getKey()); + } + } + return disabled; + } + + /** + * 获取所有已禁用的功能名称。 + */ + public Set getDisabledFeatures() { + Set disabled = new LinkedHashSet<>(); + for (Map.Entry 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 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 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; + } +} diff --git a/src/main/java/com/claudecode/telemetry/MetricsCollector.java b/src/main/java/com/claudecode/telemetry/MetricsCollector.java new file mode 100644 index 0000000..d135d1f --- /dev/null +++ b/src/main/java/com/claudecode/telemetry/MetricsCollector.java @@ -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 全套。 + *

+ * 只收集本地指标,不上报到任何远程服务。 + *

+ * 收集的指标: + *

+ *

+ * 存储位置: ~/.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 toolUsage = new ConcurrentHashMap<>(); + + /** 命令使用次数: commandName → count */ + private final ConcurrentHashMap commandUsage = new ConcurrentHashMap<>(); + + /** 错误次数: errorType → count */ + private final ConcurrentHashMap 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 getToolUsage() { + Map result = new TreeMap<>(); + toolUsage.forEach((k, v) -> result.put(k, v.get())); + return result; + } + + public Map getCommandUsage() { + Map result = new TreeMap<>(); + commandUsage.forEach((k, v) -> result.put(k, v.get())); + return result; + } + + public Map getErrorCounts() { + Map 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> 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 toMap() { + Map 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; } +}