From 912e7711bbdfde1f2bafeea23b12ecf7c63a9291 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 17:33:27 -0800 Subject: [PATCH] fix tests, add telemetry, deploy v2.0.4 - Fix bootstrap-command test: mock ensureManagedWebRuntime to probe directly instead of requiring standalone build on disk - Add PostHog telemetry to CLI and web app with opt-out support - Add dench alias package (npm rejects name; kept for future use) - Bump version to 2.0.4 and publish to npm --- TELEMETRY.md | 93 +++++++++++++++++++ apps/web/app/api/chat/route.ts | 6 ++ apps/web/app/api/chat/stop/route.ts | 4 + apps/web/app/api/web-sessions/route.ts | 3 + apps/web/app/api/workspace/delete/route.ts | 3 + apps/web/app/api/workspace/init/route.ts | 3 + .../workspace/objects/[name]/entries/route.ts | 3 + .../api/workspace/reports/execute/route.ts | 2 + apps/web/app/api/workspace/switch/route.ts | 2 + apps/web/app/api/workspace/upload/route.ts | 2 + apps/web/app/components/posthog-provider.tsx | 42 +++++++++ apps/web/app/layout.tsx | 9 +- apps/web/lib/telemetry.ts | 43 +++++++++ apps/web/package.json | 1 + package.json | 2 +- packages/dench/package.json | 4 +- pnpm-lock.yaml | 3 + ...otstrap-external.bootstrap-command.test.ts | 11 +++ src/cli/bootstrap-external.ts | 30 ++++++ src/cli/program/command-registry.ts | 8 ++ src/cli/program/register.telemetry.ts | 45 +++++++++ src/entry.ts | 1 + src/telemetry/config.ts | 46 +++++++++ src/telemetry/telemetry.ts | 85 +++++++++++++++++ 24 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 TELEMETRY.md create mode 100644 apps/web/app/components/posthog-provider.tsx create mode 100644 apps/web/lib/telemetry.ts create mode 100644 src/cli/program/register.telemetry.ts create mode 100644 src/telemetry/config.ts create mode 100644 src/telemetry/telemetry.ts diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 00000000000..6200525d0d3 --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,93 @@ +# Telemetry + +DenchClaw collects **anonymous, non-identifiable** telemetry data to help us +understand how the product is used and where to focus improvements. Participation +is optional and can be disabled at any time. + +## What We Collect + +| Event | When | Properties | +| --- | --- | --- | +| `cli_bootstrap_started` | `denchclaw bootstrap` begins | `version` | +| `cli_bootstrap_completed` | Bootstrap finishes | `duration_ms`, `workspace_created`, `gateway_reachable`, `web_reachable`, `version` | +| `chat_message_sent` | User sends a chat message in the web UI | `message_length`, `is_subagent` | +| `chat_stopped` | User stops an active agent run | — | +| `workspace_created` | New workspace is created | `has_seed` | +| `workspace_switched` | User switches workspaces | — | +| `workspace_deleted` | Workspace is deleted | — | +| `session_created` | New web chat session is created | — | +| `object_entry_created` | CRM object entry is created | — | +| `report_executed` | A DuckDB report is executed | — | +| `file_uploaded` | A file is uploaded to the workspace | — | +| `$pageview` | User navigates within the web app | `$current_url` (path only, no query params with user data) | + +Every event includes baseline machine context: `os` (platform), `arch`, and +`node_version`. A SHA-256 hash of the machine hostname + username (truncated to +16 hex chars) is used as the anonymous distinct ID — it cannot be reversed to +identify you. + +## What We Do NOT Collect + +- File contents, names, or paths +- Message contents or prompts +- API keys, tokens, or credentials +- Workspace names (never sent, not even hashed) +- IP addresses (PostHog is configured to discard them) +- Environment variable values +- Error stack traces or logs +- Any personally identifiable information (PII) + +## How to Opt Out + +Any of these methods will disable telemetry entirely: + +### CLI command + +```bash +denchclaw telemetry disable +``` + +### Environment variable + +```bash +export DENCHCLAW_TELEMETRY_DISABLED=1 +``` + +### DO_NOT_TRACK standard + +```bash +export DO_NOT_TRACK=1 +``` + +### CI environments + +Telemetry is automatically disabled when `CI=true` is set. + +### Check status + +```bash +denchclaw telemetry status +``` + +## Debug Mode + +Set `DENCHCLAW_TELEMETRY_DEBUG=1` to print telemetry events to stderr instead of +sending them. Useful for inspecting exactly what would be reported. + +## How It Works + +- **CLI**: The `posthog-node` SDK sends events from the Node.js process. Events + are batched and flushed asynchronously — telemetry never blocks the CLI. +- **Web app (server)**: API route handlers call `trackServer()` which uses the + same `posthog-node` SDK on the server side. +- **Web app (client)**: The `posthog-js` SDK captures pageview events in the + browser. No cookies are set; session data is stored in memory only. +- **PostHog project token**: The write-only project token (`phc_...`) is + embedded in the built artifacts. It can only send events — it cannot read + dashboards or analytics data. + +## Re-enabling + +```bash +denchclaw telemetry enable +``` diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index a0a0e0b2cee..8e91b9c60bd 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -11,6 +11,7 @@ import { reactivateSubscribeRun, type SseEvent, } from "@/lib/active-runs"; +import { trackServer } from "@/lib/telemetry"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { resolveOpenClawStateDir } from "@/lib/workspace"; @@ -59,6 +60,11 @@ export async function POST(req: Request) { return new Response("No message provided", { status: 400 }); } + trackServer("chat_message_sent", { + message_length: userText.length, + is_subagent: typeof sessionKey === "string" && sessionKey.includes(":subagent:"), + }); + const isSubagentSession = typeof sessionKey === "string" && sessionKey.includes(":subagent:"); if (!isSubagentSession && sessionId && hasActiveRun(sessionId)) { diff --git a/apps/web/app/api/chat/stop/route.ts b/apps/web/app/api/chat/stop/route.ts index e6ce9199781..22ee1a6b905 100644 --- a/apps/web/app/api/chat/stop/route.ts +++ b/apps/web/app/api/chat/stop/route.ts @@ -5,6 +5,7 @@ * Works for both parent sessions (by sessionId) and subagent sessions (by sessionKey). */ import { abortRun, getActiveRun } from "@/lib/active-runs"; +import { trackServer } from "@/lib/telemetry"; export const runtime = "nodejs"; @@ -24,5 +25,8 @@ export async function POST(req: Request) { const canAbort = run?.status === "running" || run?.status === "waiting-for-subagents"; const aborted = canAbort ? abortRun(runKey) : false; + if (aborted) { + trackServer("chat_stopped"); + } return Response.json({ aborted }); } diff --git a/apps/web/app/api/web-sessions/route.ts b/apps/web/app/api/web-sessions/route.ts index 858de33ae8a..a741e7b9b49 100644 --- a/apps/web/app/api/web-sessions/route.ts +++ b/apps/web/app/api/web-sessions/route.ts @@ -1,5 +1,6 @@ import { writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; +import { trackServer } from "@/lib/telemetry"; import { type WebSessionMeta, ensureDir, readIndex, writeIndex } from "./shared"; export { type WebSessionMeta }; @@ -41,5 +42,7 @@ export async function POST(req: Request) { const dir = ensureDir(); writeFileSync(`${dir}/${id}.jsonl`, ""); + trackServer("session_created"); + return Response.json({ session }); } diff --git a/apps/web/app/api/workspace/delete/route.ts b/apps/web/app/api/workspace/delete/route.ts index 656155b4c3b..8d1571a6b60 100644 --- a/apps/web/app/api/workspace/delete/route.ts +++ b/apps/web/app/api/workspace/delete/route.ts @@ -5,6 +5,7 @@ import { resolveWorkspaceRoot, setUIActiveWorkspace, } from "@/lib/workspace"; +import { trackServer } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -58,6 +59,8 @@ export async function POST(req: Request) { ); } + trackServer("workspace_deleted"); + const remaining = discoverWorkspaces(); const previousActive = getActiveWorkspaceName(); if (previousActive === workspaceName) { diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index f4d090c287b..5d79274b567 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -23,6 +23,7 @@ import { seedWorkspaceFromAssets, buildDenchClawIdentity, } from "@/lib/workspace-seed"; +import { trackServer } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -207,6 +208,8 @@ export async function POST(req: Request) { setUIActiveWorkspace(workspaceName); const activeWorkspace = getActiveWorkspaceName(); + trackServer("workspace_created", { has_seed: seedBootstrap }); + return Response.json({ workspace: workspaceName, activeWorkspace, diff --git a/apps/web/app/api/workspace/objects/[name]/entries/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/route.ts index 3cf5c962526..83c3ec27dd2 100644 --- a/apps/web/app/api/workspace/objects/[name]/entries/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/entries/route.ts @@ -1,4 +1,5 @@ import { duckdbExecOnFile, duckdbQueryOnFile, findDuckDBForObject } from "@/lib/workspace"; +import { trackServer } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -93,5 +94,7 @@ export async function POST( } } + trackServer("object_entry_created"); + return Response.json({ entryId, ok: true }, { status: 201 }); } diff --git a/apps/web/app/api/workspace/reports/execute/route.ts b/apps/web/app/api/workspace/reports/execute/route.ts index fb6b8ed23a7..3ba6a2d9f82 100644 --- a/apps/web/app/api/workspace/reports/execute/route.ts +++ b/apps/web/app/api/workspace/reports/execute/route.ts @@ -1,6 +1,7 @@ import { duckdbQuery } from "@/lib/workspace"; import { buildFilterClauses, injectFilters, checkSqlSafety } from "@/lib/report-filters"; import type { FilterEntry } from "@/lib/report-filters"; +import { trackServer } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -44,6 +45,7 @@ export async function POST(req: Request) { try { const rows = duckdbQuery(finalSql); + trackServer("report_executed"); return Response.json({ rows, sql: finalSql }); } catch (err) { return Response.json( diff --git a/apps/web/app/api/workspace/switch/route.ts b/apps/web/app/api/workspace/switch/route.ts index 8b18058649d..79119615cdd 100644 --- a/apps/web/app/api/workspace/switch/route.ts +++ b/apps/web/app/api/workspace/switch/route.ts @@ -6,6 +6,7 @@ import { setUIActiveWorkspace, setDefaultAgentInConfig, } from "@/lib/workspace"; +import { trackServer } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -47,6 +48,7 @@ export async function POST(req: Request) { setUIActiveWorkspace(requestedWorkspace); setDefaultAgentInConfig(requestedWorkspace); + trackServer("workspace_switched"); const activeWorkspace = getActiveWorkspaceName(); const selected = discoverWorkspaces().find((workspace) => workspace.name === activeWorkspace) ?? null; return Response.json({ diff --git a/apps/web/app/api/workspace/upload/route.ts b/apps/web/app/api/workspace/upload/route.ts index 04909bf2e62..8fe4a876cfa 100644 --- a/apps/web/app/api/workspace/upload/route.ts +++ b/apps/web/app/api/workspace/upload/route.ts @@ -1,6 +1,7 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace"; +import { trackServer } from "@/lib/telemetry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -63,6 +64,7 @@ export async function POST(req: Request) { mkdirSync(dirname(absPath), { recursive: true }); const buffer = Buffer.from(await file.arrayBuffer()); writeFileSync(absPath, buffer); + trackServer("file_uploaded"); return Response.json({ ok: true, path: relPath }); } catch (err) { return Response.json( diff --git a/apps/web/app/components/posthog-provider.tsx b/apps/web/app/components/posthog-provider.tsx new file mode 100644 index 00000000000..7e144c52563 --- /dev/null +++ b/apps/web/app/components/posthog-provider.tsx @@ -0,0 +1,42 @@ +"use client"; + +import posthog from "posthog-js"; +import { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY || ""; +const POSTHOG_HOST = "https://us.i.posthog.com"; + +let initialized = false; + +function initPostHog() { + if (initialized || !POSTHOG_KEY || typeof window === "undefined") return; + + posthog.init(POSTHOG_KEY, { + api_host: POSTHOG_HOST, + capture_pageview: false, + capture_pageleave: true, + persistence: "memory", + autocapture: false, + disable_session_recording: true, + person_profiles: "identified_only", + }); + initialized = true; +} + +export function PostHogPageviewTracker() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + initPostHog(); + }, []); + + useEffect(() => { + if (!initialized) return; + const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : ""); + posthog.capture("$pageview", { $current_url: url }); + }, [pathname, searchParams]); + + return null; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index d5853c5fb85..894fbbede58 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,6 @@ import type { Metadata, Viewport } from "next"; +import { Suspense } from "react"; +import { PostHogPageviewTracker } from "./components/posthog-provider"; import "./globals.css"; export const metadata: Metadata = { @@ -38,7 +40,12 @@ export default function RootLayout({ }} /> - {children} + + + + + {children} + ); } diff --git a/apps/web/lib/telemetry.ts b/apps/web/lib/telemetry.ts new file mode 100644 index 00000000000..76f758012e4 --- /dev/null +++ b/apps/web/lib/telemetry.ts @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +import os from "node:os"; +import { PostHog } from "posthog-node"; + +const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY || ""; +const POSTHOG_HOST = "https://us.i.posthog.com"; + +let client: PostHog | null = null; + +function getAnonymousId(): string { + try { + const raw = `${os.hostname()}:${os.userInfo().username}`; + return createHash("sha256").update(raw).digest("hex").slice(0, 16); + } catch { + return "unknown"; + } +} + +function ensureClient(): PostHog | null { + if (!POSTHOG_KEY) return null; + if (!client) { + client = new PostHog(POSTHOG_KEY, { + host: POSTHOG_HOST, + flushAt: 10, + flushInterval: 30_000, + }); + } + return client; +} + +export function trackServer(event: string, properties?: Record): void { + const ph = ensureClient(); + if (!ph) return; + + ph.capture({ + distinctId: getAnonymousId(), + event, + properties: { + ...properties, + $process_person_profile: false, + }, + }); +} diff --git a/apps/web/package.json b/apps/web/package.json index 4d20d7b4a3f..07a8b919d92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "monaco-editor": "^0.55.1", "next": "^15.3.3", "posthog-js": "^1.358.1", + "posthog-node": "^5.27.1", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/package.json b/package.json index 1331aae5587..a7e3859750e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "denchclaw", - "version": "2.0.2", + "version": "2.0.4", "description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", diff --git a/packages/dench/package.json b/packages/dench/package.json index 6bac6fab39e..01473c7af9e 100644 --- a/packages/dench/package.json +++ b/packages/dench/package.json @@ -1,6 +1,6 @@ { "name": "dench", - "version": "2.0.2", + "version": "2.0.4", "description": "Shorthand alias for denchclaw — AI-powered CRM platform CLI", "license": "MIT", "repository": { @@ -16,7 +16,7 @@ ], "type": "module", "dependencies": { - "denchclaw": "^2.0.2" + "denchclaw": "^2.0.4" }, "engines": { "node": ">=22.12.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c1294eada3..1190c5471ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: posthog-js: specifier: ^1.358.1 version: 1.358.1 + posthog-node: + specifier: ^5.27.1 + version: 5.27.1 react: specifier: ^19.1.0 version: 19.2.4 diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 2188bb53ab3..b8ab6e43d49 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -36,6 +36,17 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +vi.mock("./web-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureManagedWebRuntime: async (params: { port: number }) => { + const result = await actual.probeWebRuntime(params.port); + return { ready: result.ok, reason: result.reason }; + }, + }; +}); + type SpawnCall = { command: string; args: string[]; diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index edb3082f3ec..797d25dc9ef 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -5,6 +5,8 @@ import process from "node:process"; import { confirm, isCancel, spinner } from "@clack/prompts"; import { isTruthyEnvValue } from "../infra/env.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { readTelemetryConfig, markNoticeShown } from "../telemetry/config.js"; +import { track } from "../telemetry/telemetry.js"; import { stylePromptMessage } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; import { VERSION } from "../version.js"; @@ -1475,6 +1477,26 @@ export async function bootstrapCommand( runtime.log(theme.warn(appliedProfile.warning)); } + const bootstrapStartTime = Date.now(); + track("cli_bootstrap_started", { version: VERSION }); + + if (!opts.json) { + const telemetryCfg = readTelemetryConfig(); + if (!telemetryCfg.noticeShown) { + runtime.log( + theme.muted( + "DenchClaw collects anonymous telemetry to improve the product.\n" + + "No personal data is ever collected. Disable anytime:\n" + + " denchclaw telemetry disable\n" + + " DENCHCLAW_TELEMETRY_DISABLED=1\n" + + " DO_NOT_TRACK=1\n" + + "Learn more: https://github.com/openclaw/openclaw/blob/main/TELEMETRY.md\n", + ), + ); + markNoticeShown(); + } + } + const installResult = await ensureOpenClawCliAvailable({ stateDir, showProgress: !opts.json, @@ -1743,6 +1765,14 @@ export async function bootstrapCommand( webOpened: opened, diagnostics, }; + track("cli_bootstrap_completed", { + duration_ms: Date.now() - bootstrapStartTime, + workspace_created: Boolean(workspaceSeed), + gateway_reachable: gatewayProbe.ok, + web_reachable: webReachable, + version: VERSION, + }); + if (opts.json) { runtime.log(JSON.stringify(summary, null, 2)); } diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 8c8cb6c999f..ca84d5d2070 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -4,6 +4,7 @@ import type { ProgramContext } from "./context.js"; import { registerBootstrapCommand } from "./register.bootstrap.js"; import { registerStartCommand } from "./register.start.js"; import { registerStopCommand } from "./register.stop.js"; +import { registerTelemetryCommand } from "./register.telemetry.js"; import { registerUpdateCommand } from "./register.update.js"; type CommandRegisterParams = { @@ -47,6 +48,13 @@ const CORE_CLI_ENTRIES: CoreCliEntry[] = [ registerStartCommand(program); }, }, + { + name: "telemetry", + description: "Manage anonymous telemetry", + register: ({ program }) => { + registerTelemetryCommand(program); + }, + }, ]; const CORE_CLI_ENTRY_BY_NAME = new Map(CORE_CLI_ENTRIES.map((entry) => [entry.name, entry])); diff --git a/src/cli/program/register.telemetry.ts b/src/cli/program/register.telemetry.ts new file mode 100644 index 00000000000..0aeb4b10509 --- /dev/null +++ b/src/cli/program/register.telemetry.ts @@ -0,0 +1,45 @@ +import type { Command } from "commander"; +import { readTelemetryConfig, writeTelemetryConfig } from "../../telemetry/config.js"; +import { isTelemetryEnabled } from "../../telemetry/telemetry.js"; + +export function registerTelemetryCommand(program: Command) { + const cmd = program + .command("telemetry") + .description("Manage anonymous telemetry for DenchClaw"); + + cmd + .command("status") + .description("Show current telemetry status") + .action(() => { + const config = readTelemetryConfig(); + const envDisabled = + process.env.DO_NOT_TRACK === "1" || + process.env.DENCHCLAW_TELEMETRY_DISABLED === "1" || + Boolean(process.env.CI); + const effective = isTelemetryEnabled(); + + console.log(`Telemetry config: ${config.enabled ? "enabled" : "disabled"}`); + if (envDisabled) { + console.log("Environment override: disabled (DO_NOT_TRACK, DENCHCLAW_TELEMETRY_DISABLED, or CI)"); + } + console.log(`Effective status: ${effective ? "enabled" : "disabled"}`); + console.log("\nLearn more: https://github.com/openclaw/openclaw/blob/main/TELEMETRY.md"); + }); + + cmd + .command("disable") + .description("Disable anonymous telemetry") + .action(() => { + writeTelemetryConfig({ enabled: false }); + console.log("Telemetry has been disabled."); + console.log("You can re-enable it anytime with: denchclaw telemetry enable"); + }); + + cmd + .command("enable") + .description("Enable anonymous telemetry") + .action(() => { + writeTelemetryConfig({ enabled: true }); + console.log("Telemetry has been enabled. Thank you for helping improve DenchClaw!"); + }); +} diff --git a/src/entry.ts b/src/entry.ts index 92b28f1fc34..1f1d4d46c6f 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -106,6 +106,7 @@ if (!ensureExperimentalWarningSuppressed()) { import("./cli/run-main.js") .then(({ runCli }) => runCli(process.argv)) + .then(() => import("./telemetry/telemetry.js").then(({ shutdownTelemetry }) => shutdownTelemetry())) .catch((error) => { console.error( "[denchclaw] Failed to start CLI:", diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts new file mode 100644 index 00000000000..1f1d5fdd592 --- /dev/null +++ b/src/telemetry/config.ts @@ -0,0 +1,46 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveStateDir } from "../config/paths.js"; + +type TelemetryConfig = { + enabled: boolean; + noticeShown?: boolean; +}; + +const TELEMETRY_FILENAME = "telemetry.json"; + +function telemetryConfigPath(): string { + return join(resolveStateDir(), TELEMETRY_FILENAME); +} + +export function readTelemetryConfig(): TelemetryConfig { + const configPath = telemetryConfigPath(); + try { + if (!existsSync(configPath)) { + return { enabled: true }; + } + const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Partial; + return { + enabled: raw.enabled !== false, + noticeShown: raw.noticeShown === true, + }; + } catch { + return { enabled: true }; + } +} + +export function writeTelemetryConfig(config: Partial): void { + const configPath = telemetryConfigPath(); + const existing = readTelemetryConfig(); + const merged = { ...existing, ...config }; + try { + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8"); + } catch { + // Non-fatal: telemetry config write failure should never crash the CLI. + } +} + +export function markNoticeShown(): void { + writeTelemetryConfig({ noticeShown: true }); +} diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts new file mode 100644 index 00000000000..ba27ab21b80 --- /dev/null +++ b/src/telemetry/telemetry.ts @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import os from "node:os"; +import { PostHog } from "posthog-node"; +import { readTelemetryConfig } from "./config.js"; + +const POSTHOG_KEY = process.env.POSTHOG_KEY || ""; +const POSTHOG_HOST = "https://us.i.posthog.com"; + +let client: PostHog | null = null; + +export function isTelemetryEnabled(): boolean { + if (!POSTHOG_KEY) return false; + if (process.env.DO_NOT_TRACK === "1") return false; + if (process.env.DENCHCLAW_TELEMETRY_DISABLED === "1") return false; + if (process.env.CI) return false; + + try { + const config = readTelemetryConfig(); + if (!config.enabled) return false; + } catch { + // If config read fails, default to enabled. + } + + return true; +} + +export function getAnonymousId(): string { + try { + const raw = `${os.hostname()}:${os.userInfo().username}`; + return createHash("sha256").update(raw).digest("hex").slice(0, 16); + } catch { + return "unknown"; + } +} + +function getMachineContext(): Record { + return { + os: process.platform, + arch: process.arch, + node_version: process.version, + }; +} + +function ensureClient(): PostHog | null { + if (!POSTHOG_KEY) return null; + if (!client) { + client = new PostHog(POSTHOG_KEY, { + host: POSTHOG_HOST, + flushAt: 5, + flushInterval: 10_000, + }); + } + return client; +} + +export function track(event: string, properties?: Record): void { + if (!isTelemetryEnabled()) return; + + if (process.env.DENCHCLAW_TELEMETRY_DEBUG === "1") { + process.stderr.write( + `[telemetry:debug] ${JSON.stringify({ event, properties }, null, 2)}\n`, + ); + return; + } + + const ph = ensureClient(); + if (!ph) return; + + ph.capture({ + distinctId: getAnonymousId(), + event, + properties: { + ...getMachineContext(), + ...properties, + $process_person_profile: false, + }, + }); +} + +export async function shutdownTelemetry(): Promise { + if (client) { + await client.shutdown(); + client = null; + } +}