feat(diagnostics): funnel events throughout setup with persisted install-id

Shared bash + node emitter in setup/lib/diagnostics.{sh,ts} reads/writes data/install-id so every event from a single install shares one distinct_id — bash-side setup_launched/setup_start, node-side auto_started, per-step started/completed, auth_method_chosen, channel_chosen, first_chat_ready/failed, setup_incomplete, setup_aborted, setup_completed. Opt-out via NANOCLAW_NO_DIAGNOSTICS=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-22 19:21:06 +03:00
parent 39ae04df98
commit 8412b899fa
6 changed files with 183 additions and 5 deletions

61
setup/lib/diagnostics.sh Normal file
View File

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

70
setup/lib/diagnostics.ts Normal file
View File

@@ -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<string, string | number | boolean | undefined> = {},
): void {
if (process.env.NANOCLAW_NO_DIAGNOSTICS === '1') return;
const cleaned: Record<string, unknown> = { 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));
}

View File

@@ -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<string, string>;
@@ -186,11 +187,17 @@ export async function runQuietStep(
): Promise<StepResult & { rawLog: string; durationMs: number }> {
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<QuietChildResult & { rawLog: string; durationMs: number }> {
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<never> {
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/'));