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} + +