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
This commit is contained in:
kumarabhirup 2026-03-04 17:33:27 -08:00
parent ee6f3c6df3
commit 912e7711bb
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
24 changed files with 447 additions and 4 deletions

93
TELEMETRY.md Normal file
View File

@ -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
```

View File

@ -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)) {

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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) {

View File

@ -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,

View File

@ -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 });
}

View File

@ -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(

View File

@ -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({

View File

@ -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(

View File

@ -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;
}

View File

@ -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({
}}
/>
</head>
<body className="antialiased">{children}</body>
<body className="antialiased">
<Suspense fallback={null}>
<PostHogPageviewTracker />
</Suspense>
{children}
</body>
</html>
);
}

43
apps/web/lib/telemetry.ts Normal file
View File

@ -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<string, unknown>): void {
const ph = ensureClient();
if (!ph) return;
ph.capture({
distinctId: getAnonymousId(),
event,
properties: {
...properties,
$process_person_profile: false,
},
});
}

View File

@ -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",

View File

@ -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",

View File

@ -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"

3
pnpm-lock.yaml generated
View File

@ -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

View File

@ -36,6 +36,17 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
vi.mock("./web-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./web-runtime.js")>();
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[];

View File

@ -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));
}

View File

@ -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]));

View File

@ -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!");
});
}

View File

@ -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:",

46
src/telemetry/config.ts Normal file
View File

@ -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<TelemetryConfig>;
return {
enabled: raw.enabled !== false,
noticeShown: raw.noticeShown === true,
};
} catch {
return { enabled: true };
}
}
export function writeTelemetryConfig(config: Partial<TelemetryConfig>): 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 });
}

View File

@ -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<string, unknown> {
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<string, unknown>): 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<void> {
if (client) {
await client.shutdown();
client = null;
}
}