diff --git a/docs/configuration.md b/docs/configuration.md index 1cce9a1..922f39e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | | `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | | `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | +| `telemetryEnabled` | boolean | 是否启用匿名使用数据上报(默认 `true`) | | `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | | `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | | `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | @@ -45,6 +46,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `THINKING_ENABLED` | string | 是否启用思考模式 | | `REASONING_EFFORT` | string | 推理强度 | | `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | +| `TELEMETRY_ENABLED` | string | 是否启用匿名使用数据上报 | | `<其他任意KEY>` | string | 自定义环境变量 | #### `thinkingEnabled` — 思考模式 @@ -130,6 +132,16 @@ MCP(Model Context Protocol)服务器配置。值是键值对,键为服务 设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 +#### `telemetryEnabled` — 匿名使用数据上报 + +设为 `false` 可关闭匿名使用数据上报(默认 `true`)。上报仅包含匿名的机器标识,不包含对话内容、代码或 API 密钥。 + +也可以通过环境变量关闭: + +```bash +DEEPCODE_TELEMETRY_ENABLED=0 deepcode +``` + ## 环境变量优先级 环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index fa396f9..f53fb11 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -31,6 +31,7 @@ The following are all the top-level fields supported in `settings.json`, along w | `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| | `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | | `debugLogEnabled` | boolean | Enable debug log output (default `false`) | +| `telemetryEnabled` | boolean | Enable anonymous usage reporting (default `true`) | | `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | | `webSearchTool` | string | Full path to a custom web search script | | `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | @@ -45,6 +46,7 @@ The following are all the top-level fields supported in `settings.json`, along w | `THINKING_ENABLED`| string | Enable thinking mode | | `REASONING_EFFORT`| string | Reasoning intensity | | `DEBUG_LOG_ENABLED`| string| Enable debug log output | +| `TELEMETRY_ENABLED`| string| Enable anonymous usage reporting | | `` | string | Custom environment variable | #### `thinkingEnabled` — Thinking Mode @@ -129,6 +131,16 @@ For detailed MCP usage instructions, refer to [mcp.md](mcp.md). Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. +#### `telemetryEnabled` — Anonymous Usage Reporting + +Set to `false` to disable anonymous usage reporting (default `true`). The report only includes an anonymous machine identifier and does not contain conversation content, code, or API keys. + +You can also disable it via environment variable: + +```bash +DEEPCODE_TELEMETRY_ENABLED=0 deepcode +``` + ## Environment Variable Priority Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. diff --git a/src/common/openai-client.ts b/src/common/openai-client.ts index ee3dd66..3587783 100644 --- a/src/common/openai-client.ts +++ b/src/common/openai-client.ts @@ -26,6 +26,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { thinkingEnabled: boolean; reasoningEffort: "high" | "max"; debugLogEnabled: boolean; + telemetryEnabled: boolean; notify?: string; webSearchTool?: string; env: Record; @@ -40,6 +41,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { thinkingEnabled: settings.thinkingEnabled, reasoningEffort: settings.reasoningEffort, debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, env: settings.env, @@ -56,6 +58,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { thinkingEnabled: settings.thinkingEnabled, reasoningEffort: settings.reasoningEffort, debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, env: settings.env, @@ -91,6 +94,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { thinkingEnabled: settings.thinkingEnabled, reasoningEffort: settings.reasoningEffort, debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, notify: settings.notify, webSearchTool: settings.webSearchTool, env: settings.env, diff --git a/src/common/telemetry.ts b/src/common/telemetry.ts new file mode 100644 index 0000000..f6dc60b --- /dev/null +++ b/src/common/telemetry.ts @@ -0,0 +1,34 @@ +const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; +const DEFAULT_REPORT_TIMEOUT_MS = 3000; + +export type NewPromptReportOptions = { + enabled: boolean; + machineId?: string; + timeoutMs?: number; +}; + +/** + * Fire-and-forget report of a new prompt session. + * Respects the `enabled` toggle: when disabled, the call is a no-op. + */ +export function reportNewPrompt(options: NewPromptReportOptions): void { + if (!options.enabled || !options.machineId) { + return; + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + void fetch(DEFAULT_NEW_PROMPT_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Token: options.machineId, + }, + body: JSON.stringify({}), + signal: controller.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timeout)); +} diff --git a/src/session.ts b/src/session.ts index 6c73e37..358789e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -45,6 +45,7 @@ import { type UserToolPermission, } from "./common/permissions"; import { clearSessionWorkingDir } from "./tools/bash-handler"; +import { reportNewPrompt } from "./common/telemetry"; export type { PermissionScope } from "./settings"; export type { @@ -59,8 +60,6 @@ export type { const MAX_SESSION_ENTRIES = 50; const MAX_PROJECT_CODE_LENGTH = 64; const PROJECT_CODE_HASH_LENGTH = 16; -const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; -const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; @@ -1504,25 +1503,8 @@ ${skillMd} } private reportNewPrompt(): void { - const { machineId } = this.createOpenAIClient(); - if (!machineId) { - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), NEW_PROMPT_REPORT_TIMEOUT_MS); - - void fetch(DEFAULT_NEW_PROMPT_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Token: machineId, - }, - body: JSON.stringify({}), - signal: controller.signal, - }) - .catch(() => {}) - .finally(() => clearTimeout(timeout)); + const { machineId, telemetryEnabled } = this.createOpenAIClient(); + reportNewPrompt({ enabled: telemetryEnabled ?? true, machineId }); } interruptActiveSession(): void { diff --git a/src/settings.ts b/src/settings.ts index b7a7a77..14755dd 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -10,6 +10,7 @@ export type DeepcodingEnv = Record & { THINKING_ENABLED?: string; REASONING_EFFORT?: string; DEBUG_LOG_ENABLED?: string; + TELEMETRY_ENABLED?: string; }; export type ReasoningEffort = "high" | "max"; @@ -47,6 +48,7 @@ export type DeepcodingSettings = { thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; debugLogEnabled?: boolean; + telemetryEnabled?: boolean; notify?: string; webSearchTool?: string; mcpServers?: Record; @@ -61,6 +63,7 @@ export type ResolvedDeepcodingSettings = { thinkingEnabled: boolean; reasoningEffort: ReasoningEffort; debugLogEnabled: boolean; + telemetryEnabled: boolean; notify?: string; webSearchTool?: string; mcpServers?: Record; @@ -313,6 +316,14 @@ export function resolveSettingsSources( parseBoolean(userEnv.DEBUG_LOG_ENABLED) ?? false; + const telemetryEnabled = + parseBoolean(systemEnv.TELEMETRY_ENABLED) ?? + parseBoolean(projectSettings?.telemetryEnabled) ?? + parseBoolean(projectEnv.TELEMETRY_ENABLED) ?? + parseBoolean(userSettings?.telemetryEnabled) ?? + parseBoolean(userEnv.TELEMETRY_ENABLED) ?? + true; + const notify = trimString(systemEnv.NOTIFY) || trimString(projectSettings?.notify) || trimString(userSettings?.notify) || ""; const webSearchTool = @@ -329,6 +340,7 @@ export function resolveSettingsSources( thinkingEnabled, reasoningEffort, debugLogEnabled, + telemetryEnabled, notify: notify || undefined, webSearchTool: webSearchTool || undefined, mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index 52f8671..9e18dc1 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -83,6 +83,36 @@ test("resolveSettings reads THINKING_ENABLED, REASONING_EFFORT, and DEBUG_LOG_EN assert.equal(resolved.baseURL, "https://default.example.com"); }); +test("resolveSettings defaults telemetryEnabled to true", () => { + const resolved = resolveSettings( + {}, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.telemetryEnabled, true); +}); + +test("resolveSettings reads TELEMETRY_ENABLED from env", () => { + const resolved = resolveSettings( + { env: { TELEMETRY_ENABLED: "0" } }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.telemetryEnabled, false); +}); + +test("resolveSettings gives top-level telemetryEnabled priority over env TELEMETRY_ENABLED", () => { + const resolved = resolveSettings( + { + telemetryEnabled: false, + env: { TELEMETRY_ENABLED: "true" }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.telemetryEnabled, false); +}); + test("resolveSettings ignores removed legacy env.THINKING", () => { const resolved = resolveSettings( { @@ -115,6 +145,7 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre thinkingEnabled: true, reasoningEffort: "max", debugLogEnabled: true, + telemetryEnabled: false, }, { env: { @@ -125,6 +156,7 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre }, model: "project-top-model", thinkingEnabled: true, + telemetryEnabled: true, }, { model: "default-model", @@ -135,6 +167,7 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre DEEPCODE_THINKING_ENABLED: "false", DEEPCODE_REASONING_EFFORT: "high", DEEPCODE_DEBUG_LOG_ENABLED: "true", + DEEPCODE_TELEMETRY_ENABLED: "false", DEEPCODE_WEBHOOK: "system-webhook", } ); @@ -144,6 +177,7 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre assert.equal(resolved.thinkingEnabled, false); assert.equal(resolved.reasoningEffort, "high"); assert.equal(resolved.debugLogEnabled, true); + assert.equal(resolved.telemetryEnabled, false); assert.equal(resolved.env.WEBHOOK, "system-webhook"); }); diff --git a/src/tests/telemetry.test.ts b/src/tests/telemetry.test.ts new file mode 100644 index 0000000..6db0261 --- /dev/null +++ b/src/tests/telemetry.test.ts @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { reportNewPrompt } from "../common/telemetry"; + +test("reportNewPrompt does not call fetch when enabled is false", () => { + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((..._args: unknown[]) => { + called = true; + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: false, machineId: "test-machine" }); + assert.equal(called, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt does not call fetch when machineId is undefined", () => { + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((..._args: unknown[]) => { + called = true; + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true }); + assert.equal(called, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt does not call fetch when machineId is empty string", () => { + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((..._args: unknown[]) => { + called = true; + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true, machineId: "" }); + assert.equal(called, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt calls fetch with correct URL, method, headers, and body", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true, machineId: "test-machine" }); + + // Wait for the fire-and-forget fetch to settle. + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://deepcode.vegamo.cn/api/plugin/new"); + assert.equal(calls[0].init.method, "POST"); + assert.equal((calls[0].init.headers as Record)["Content-Type"], "application/json"); + assert.equal((calls[0].init.headers as Record)["Token"], "test-machine"); + assert.equal(calls[0].init.body, JSON.stringify({})); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt swallows fetch errors without throwing", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + return Promise.reject(new Error("Network error")); + }) as typeof globalThis.fetch; + + try { + // Should not throw. + reportNewPrompt({ enabled: true, machineId: "test-machine" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt respects custom timeoutMs", async () => { + const calls: Array<{ signal: AbortSignal }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((_url: string | URL | Request, init?: RequestInit) => { + calls.push({ signal: init?.signal as AbortSignal }); + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true, machineId: "test-machine", timeoutMs: 100 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(calls.length, 1); + assert.equal(calls[0].signal.aborted, false); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 220fc89..155c872 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -16,6 +16,7 @@ export type CreateOpenAIClient = () => { thinkingEnabled: boolean; reasoningEffort?: ReasoningEffort; debugLogEnabled?: boolean; + telemetryEnabled?: boolean; notify?: string; webSearchTool?: string; env?: Record;