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>
pull/1/head
abel533 1 month ago
parent 293dd5657c
commit 76191f5035
  1. 186
      src/main/java/com/claudecode/telemetry/FeatureFlagService.java
  2. 137
      src/main/java/com/claudecode/telemetry/FeatureGate.java
  3. 232
      src/main/java/com/claudecode/telemetry/MetricsCollector.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 的本地替代
* <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…
Cancel
Save