- 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