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:
parent
ee6f3c6df3
commit
912e7711bb
93
TELEMETRY.md
Normal file
93
TELEMETRY.md
Normal 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
|
||||
```
|
||||
@ -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)) {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
42
apps/web/app/components/posthog-provider.tsx
Normal file
42
apps/web/app/components/posthog-provider.tsx
Normal 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;
|
||||
}
|
||||
@ -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
43
apps/web/lib/telemetry.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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]));
|
||||
|
||||
|
||||
45
src/cli/program/register.telemetry.ts
Normal file
45
src/cli/program/register.telemetry.ts
Normal 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!");
|
||||
});
|
||||
}
|
||||
@ -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
46
src/telemetry/config.ts
Normal 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 });
|
||||
}
|
||||
85
src/telemetry/telemetry.ts
Normal file
85
src/telemetry/telemetry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user