diff --git a/nanoclaw.sh b/nanoclaw.sh index 95a48247c..0bf193873 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -29,6 +29,14 @@ LOGS_DIR="$PROJECT_ROOT/logs" STEPS_DIR="$LOGS_DIR/setup-steps" PROGRESS_LOG="$LOGS_DIR/setup.log" +# Diagnostics: persisted install-id + fire-and-forget emit. Sourced early +# so `setup_launched` covers dropoff before bootstrap even starts. +# shellcheck source=setup/lib/diagnostics.sh +source "$PROJECT_ROOT/setup/lib/diagnostics.sh" +ph_event setup_launched \ + platform="$(uname -s | tr 'A-Z' 'a-z')" \ + is_wsl="$([ -f /proc/version ] && grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null && echo true || echo false)" + # ─── log helpers ──────────────────────────────────────────────────────── ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } diff --git a/setup.sh b/setup.sh index ae5da2789..9a81531fd 100755 --- a/setup.sh +++ b/setup.sh @@ -167,11 +167,20 @@ elif [ "$NATIVE_OK" = "false" ]; then STATUS="native_failed" fi -# Anonymous setup start event (non-blocking, best-effort) -curl -sS --max-time 3 -X POST https://us.i.posthog.com/capture/ \ - -H 'Content-Type: application/json' \ - -d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"setup_start\",\"distinct_id\":\"$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo unknown)\",\"properties\":{\"platform\":\"$PLATFORM\",\"is_wsl\":\"$IS_WSL\",\"is_root\":\"$IS_ROOT\",\"node_version\":\"$NODE_VERSION\",\"deps_ok\":\"$DEPS_OK\",\"native_ok\":\"$NATIVE_OK\",\"has_build_tools\":\"$HAS_BUILD_TOOLS\"}}" \ - >/dev/null 2>&1 & +# Anonymous setup start event (non-blocking, best-effort). Uses the +# persisted distinct_id from data/install-id so bash-side events and the +# node-side funnel share one id. +# shellcheck source=setup/lib/diagnostics.sh +source "$PROJECT_ROOT/setup/lib/diagnostics.sh" +ph_event setup_start \ + platform="$PLATFORM" \ + is_wsl="$IS_WSL" \ + is_root="$IS_ROOT" \ + node_version="$NODE_VERSION" \ + deps_ok="$DEPS_OK" \ + native_ok="$NATIVE_OK" \ + has_build_tools="$HAS_BUILD_TOOLS" \ + status="$STATUS" cat < { printIntro(); initProgressionLog(); + phEmit('auto_started'); const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') @@ -205,8 +207,10 @@ async function main(): Promise { if (!skip.has('first-chat')) { const ping = await confirmAssistantResponds(); if (ping === 'ok') { + phEmit('first_chat_ready'); await runFirstChat(); } else { + phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); await offerClaudeAssist({ stepName: 'cli-agent', @@ -292,6 +296,12 @@ async function main(): Promise { .map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim()) .filter(Boolean) .join(' · '); + phEmit('setup_incomplete', { + unresolved_count: notes.length, + service_running: res.terminal?.fields.SERVICE === 'running', + has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', + agent_responds: res.terminal?.fields.AGENT_PING === 'ok', + }); await offerClaudeAssist({ stepName: 'verify', msg: summary || 'Verification completed with unresolved issues.', @@ -314,6 +324,7 @@ async function main(): Promise { .join('\n'); p.note(nextSteps, 'Try these'); setupLog.complete(Date.now() - RUN_START); + phEmit('setup_completed', { duration_ms: Date.now() - RUN_START }); p.outro(k.green("You're ready! Enjoy NanoClaw.")); } @@ -440,6 +451,7 @@ async function runAuthStep(): Promise { }), ) as 'subscription' | 'oauth' | 'api'; setupLog.userInput('auth_method', method); + phEmit('auth_method_chosen', { method }); if (method === 'subscription') { await runSubscriptionAuth(); @@ -660,6 +672,7 @@ async function askChannelChoice(): Promise< }), ); setupLog.userInput('channel_choice', String(choice)); + phEmit('channel_chosen', { channel: String(choice) }); return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'; } diff --git a/setup/lib/diagnostics.sh b/setup/lib/diagnostics.sh new file mode 100644 index 000000000..23629d71b --- /dev/null +++ b/setup/lib/diagnostics.sh @@ -0,0 +1,61 @@ +# diagnostics.sh — shared PostHog emitter for bash-side setup code. +# +# Source this file after $PROJECT_ROOT is set: +# +# source "$PROJECT_ROOT/setup/lib/diagnostics.sh" +# ph_event bootstrap_completed status=success platform=macos +# +# All emits are fire-and-forget (background curl, 3s max timeout); they +# never fail the caller. Honors NANOCLAW_NO_DIAGNOSTICS=1. The distinct_id +# is persisted at data/install-id so the bash + node halves of setup use +# the same id and events from one install join into a single funnel. + +NANOCLAW_PH_KEY='phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP' +NANOCLAW_PH_URL='https://us.i.posthog.com/capture/' + +# Resolve or create the persisted install id. Echoes the id (lowercase uuid). +# Creates data/install-id on first use. Safe to call pre-Node: uses only +# bash + uuidgen/urandom fallback + mkdir. +ph_install_id() { + local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}" + local f="$root/data/install-id" + if [ ! -s "$f" ]; then + mkdir -p "$(dirname "$f")" 2>/dev/null || return 0 + local id + id=$(uuidgen 2>/dev/null \ + || cat /proc/sys/kernel/random/uuid 2>/dev/null \ + || printf 'fallback-%s-%s' "$(date +%s)" "$$") + printf '%s' "$id" | tr 'A-Z' 'a-z' > "$f" 2>/dev/null || return 0 + fi + cat "$f" 2>/dev/null +} + +# Emit a PostHog event. First arg is the event name; remaining args are +# `key=value` pairs merged into properties. Values are JSON-escaped for +# quotes and backslashes; keep them short and alphanumeric-ish. +ph_event() { + [ "${NANOCLAW_NO_DIAGNOSTICS:-}" = "1" ] && return 0 + local event=$1 + shift + local id + id=$(ph_install_id) + [ -z "$id" ] && return 0 + + local props='' first=1 kv k v + for kv in "$@"; do + k="${kv%%=*}" + v="${kv#*=}" + v=${v//\\/\\\\} + v=${v//\"/\\\"} + if [ "$first" = "1" ]; then first=0; else props+=','; fi + props+="\"$k\":\"$v\"" + done + + local payload + payload=$(printf '{"api_key":"%s","event":"%s","distinct_id":"%s","properties":{%s}}' \ + "$NANOCLAW_PH_KEY" "$event" "$id" "$props") + + curl -sS --max-time 3 -X POST "$NANOCLAW_PH_URL" \ + -H 'Content-Type: application/json' \ + -d "$payload" >/dev/null 2>&1 & +} diff --git a/setup/lib/diagnostics.ts b/setup/lib/diagnostics.ts new file mode 100644 index 000000000..30605a785 --- /dev/null +++ b/setup/lib/diagnostics.ts @@ -0,0 +1,70 @@ +/** + * Thin PostHog emitter shared across setup:auto code. Fire-and-forget — + * never throws, never blocks. Reuses data/install-id (same file bash + * uses in setup/lib/diagnostics.sh) so events from the bash and node + * halves of a single install join into one funnel. + * + * Honors NANOCLAW_NO_DIAGNOSTICS=1. + */ +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const POSTHOG_KEY = 'phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP'; +const POSTHOG_URL = 'https://us.i.posthog.com/capture/'; +const INSTALL_ID_PATH = path.join('data', 'install-id'); + +let cached: string | null = null; + +export function installId(): string { + if (cached) return cached; + try { + const existing = fs.readFileSync(INSTALL_ID_PATH, 'utf-8').trim(); + if (existing) { + cached = existing; + return existing; + } + } catch { + // fall through to create + } + const id = randomUUID().toLowerCase(); + try { + fs.mkdirSync(path.dirname(INSTALL_ID_PATH), { recursive: true }); + fs.writeFileSync(INSTALL_ID_PATH, id); + } catch { + // best-effort; still return the id so the event fires + } + cached = id; + return id; +} + +export function emit( + event: string, + props: Record = {}, +): void { + if (process.env.NANOCLAW_NO_DIAGNOSTICS === '1') return; + + const cleaned: Record = { platform: process.platform }; + for (const [k, v] of Object.entries(props)) { + if (v === undefined) continue; + cleaned[k] = v; + } + + const body = JSON.stringify({ + api_key: POSTHOG_KEY, + event, + distinct_id: installId(), + properties: cleaned, + }); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 3000); + void fetch(POSTHOG_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: ctrl.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timer)); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 0e33c743a..d8d376527 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -19,6 +19,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { offerClaudeAssist } from './claude-assist.js'; +import { emit as phEmit } from './diagnostics.js'; import { fitToWidth } from './theme.js'; export type Fields = Record; @@ -186,11 +187,17 @@ export async function runQuietStep( ): Promise { const rawLog = setupLog.stepRawLog(stepName); const start = Date.now(); + phEmit('step_started', { step: stepName }); const result = await runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {}, rawLog), ); const durationMs = Date.now() - start; writeStepEntry(stepName, result, durationMs, rawLog); + phEmit('step_completed', { + step: stepName, + status: outcomeStatus(result), + duration_ms: durationMs, + }); return { ...result, rawLog, durationMs }; } @@ -209,6 +216,7 @@ export async function runQuietChild( ): Promise { const rawLog = setupLog.stepRawLog(logName); const start = Date.now(); + phEmit('step_started', { step: logName }); const result = await runUnderSpinner(labels, () => spawnQuiet(cmd, args, rawLog, opts?.env), ); @@ -223,9 +231,17 @@ export async function runQuietChild( ? 'skipped' : 'success'; setupLog.step(logName, status, durationMs, fields, rawLog); + phEmit('step_completed', { step: logName, status, duration_ms: durationMs }); return { ...result, rawLog, durationMs }; } +/** Collapse a step run into the three-way status used by diagnostics + progression log. */ +function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' { + const rawStatus = result.terminal?.fields.STATUS; + if (!result.ok) return 'failed'; + return rawStatus === 'skipped' ? 'skipped' : 'success'; +} + /** Turn a step's terminal-block fields into a concise progression-log entry. */ export function writeStepEntry( stepName: string, @@ -318,6 +334,7 @@ export async function fail( rawLogPath?: string, ): Promise { setupLog.abort(stepName, msg); + phEmit('setup_aborted', { step: stepName, reason: msg }); p.log.error(msg); if (hint) p.log.message(k.dim(hint)); p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));