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:
@@ -29,6 +29,14 @@ LOGS_DIR="$PROJECT_ROOT/logs"
|
|||||||
STEPS_DIR="$LOGS_DIR/setup-steps"
|
STEPS_DIR="$LOGS_DIR/setup-steps"
|
||||||
PROGRESS_LOG="$LOGS_DIR/setup.log"
|
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 ────────────────────────────────────────────────────────
|
# ─── log helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|||||||
19
setup.sh
19
setup.sh
@@ -167,11 +167,20 @@ elif [ "$NATIVE_OK" = "false" ]; then
|
|||||||
STATUS="native_failed"
|
STATUS="native_failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Anonymous setup start event (non-blocking, best-effort)
|
# Anonymous setup start event (non-blocking, best-effort). Uses the
|
||||||
curl -sS --max-time 3 -X POST https://us.i.posthog.com/capture/ \
|
# persisted distinct_id from data/install-id so bash-side events and the
|
||||||
-H 'Content-Type: application/json' \
|
# node-side funnel share one id.
|
||||||
-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\"}}" \
|
# shellcheck source=setup/lib/diagnostics.sh
|
||||||
>/dev/null 2>&1 &
|
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 <<EOF
|
cat <<EOF
|
||||||
=== NANOCLAW SETUP: BOOTSTRAP ===
|
=== NANOCLAW SETUP: BOOTSTRAP ===
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from './lib/tz-from-claude.js';
|
} from './lib/tz-from-claude.js';
|
||||||
import * as setupLog from './logs.js';
|
import * as setupLog from './logs.js';
|
||||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||||
|
import { emit as phEmit } from './lib/diagnostics.js';
|
||||||
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
||||||
import { isValidTimezone } from '../src/timezone.js';
|
import { isValidTimezone } from '../src/timezone.js';
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ const RUN_START = Date.now();
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
printIntro();
|
printIntro();
|
||||||
initProgressionLog();
|
initProgressionLog();
|
||||||
|
phEmit('auto_started');
|
||||||
|
|
||||||
const skip = new Set(
|
const skip = new Set(
|
||||||
(process.env.NANOCLAW_SKIP ?? '')
|
(process.env.NANOCLAW_SKIP ?? '')
|
||||||
@@ -205,8 +207,10 @@ async function main(): Promise<void> {
|
|||||||
if (!skip.has('first-chat')) {
|
if (!skip.has('first-chat')) {
|
||||||
const ping = await confirmAssistantResponds();
|
const ping = await confirmAssistantResponds();
|
||||||
if (ping === 'ok') {
|
if (ping === 'ok') {
|
||||||
|
phEmit('first_chat_ready');
|
||||||
await runFirstChat();
|
await runFirstChat();
|
||||||
} else {
|
} else {
|
||||||
|
phEmit('first_chat_failed', { reason: ping });
|
||||||
renderPingFailureNote(ping);
|
renderPingFailureNote(ping);
|
||||||
await offerClaudeAssist({
|
await offerClaudeAssist({
|
||||||
stepName: 'cli-agent',
|
stepName: 'cli-agent',
|
||||||
@@ -292,6 +296,12 @@ async function main(): Promise<void> {
|
|||||||
.map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim())
|
.map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.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({
|
await offerClaudeAssist({
|
||||||
stepName: 'verify',
|
stepName: 'verify',
|
||||||
msg: summary || 'Verification completed with unresolved issues.',
|
msg: summary || 'Verification completed with unresolved issues.',
|
||||||
@@ -314,6 +324,7 @@ async function main(): Promise<void> {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
p.note(nextSteps, 'Try these');
|
p.note(nextSteps, 'Try these');
|
||||||
setupLog.complete(Date.now() - RUN_START);
|
setupLog.complete(Date.now() - RUN_START);
|
||||||
|
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
||||||
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +451,7 @@ async function runAuthStep(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
) as 'subscription' | 'oauth' | 'api';
|
) as 'subscription' | 'oauth' | 'api';
|
||||||
setupLog.userInput('auth_method', method);
|
setupLog.userInput('auth_method', method);
|
||||||
|
phEmit('auth_method_chosen', { method });
|
||||||
|
|
||||||
if (method === 'subscription') {
|
if (method === 'subscription') {
|
||||||
await runSubscriptionAuth();
|
await runSubscriptionAuth();
|
||||||
@@ -660,6 +672,7 @@ async function askChannelChoice(): Promise<
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setupLog.userInput('channel_choice', String(choice));
|
setupLog.userInput('channel_choice', String(choice));
|
||||||
|
phEmit('channel_chosen', { channel: String(choice) });
|
||||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
setup/lib/diagnostics.sh
Normal file
61
setup/lib/diagnostics.sh
Normal 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
70
setup/lib/diagnostics.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { offerClaudeAssist } from './claude-assist.js';
|
import { offerClaudeAssist } from './claude-assist.js';
|
||||||
|
import { emit as phEmit } from './diagnostics.js';
|
||||||
import { fitToWidth } from './theme.js';
|
import { fitToWidth } from './theme.js';
|
||||||
|
|
||||||
export type Fields = Record<string, string>;
|
export type Fields = Record<string, string>;
|
||||||
@@ -186,11 +187,17 @@ export async function runQuietStep(
|
|||||||
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
||||||
const rawLog = setupLog.stepRawLog(stepName);
|
const rawLog = setupLog.stepRawLog(stepName);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
phEmit('step_started', { step: stepName });
|
||||||
const result = await runUnderSpinner(labels, () =>
|
const result = await runUnderSpinner(labels, () =>
|
||||||
spawnStep(stepName, extra, () => {}, rawLog),
|
spawnStep(stepName, extra, () => {}, rawLog),
|
||||||
);
|
);
|
||||||
const durationMs = Date.now() - start;
|
const durationMs = Date.now() - start;
|
||||||
writeStepEntry(stepName, result, durationMs, rawLog);
|
writeStepEntry(stepName, result, durationMs, rawLog);
|
||||||
|
phEmit('step_completed', {
|
||||||
|
step: stepName,
|
||||||
|
status: outcomeStatus(result),
|
||||||
|
duration_ms: durationMs,
|
||||||
|
});
|
||||||
return { ...result, rawLog, durationMs };
|
return { ...result, rawLog, durationMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +216,7 @@ export async function runQuietChild(
|
|||||||
): Promise<QuietChildResult & { rawLog: string; durationMs: number }> {
|
): Promise<QuietChildResult & { rawLog: string; durationMs: number }> {
|
||||||
const rawLog = setupLog.stepRawLog(logName);
|
const rawLog = setupLog.stepRawLog(logName);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
phEmit('step_started', { step: logName });
|
||||||
const result = await runUnderSpinner(labels, () =>
|
const result = await runUnderSpinner(labels, () =>
|
||||||
spawnQuiet(cmd, args, rawLog, opts?.env),
|
spawnQuiet(cmd, args, rawLog, opts?.env),
|
||||||
);
|
);
|
||||||
@@ -223,9 +231,17 @@ export async function runQuietChild(
|
|||||||
? 'skipped'
|
? 'skipped'
|
||||||
: 'success';
|
: 'success';
|
||||||
setupLog.step(logName, status, durationMs, fields, rawLog);
|
setupLog.step(logName, status, durationMs, fields, rawLog);
|
||||||
|
phEmit('step_completed', { step: logName, status, duration_ms: durationMs });
|
||||||
return { ...result, rawLog, 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. */
|
/** Turn a step's terminal-block fields into a concise progression-log entry. */
|
||||||
export function writeStepEntry(
|
export function writeStepEntry(
|
||||||
stepName: string,
|
stepName: string,
|
||||||
@@ -318,6 +334,7 @@ export async function fail(
|
|||||||
rawLogPath?: string,
|
rawLogPath?: string,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
setupLog.abort(stepName, msg);
|
setupLog.abort(stepName, msg);
|
||||||
|
phEmit('setup_aborted', { step: stepName, reason: msg });
|
||||||
p.log.error(msg);
|
p.log.error(msg);
|
||||||
if (hint) p.log.message(k.dim(hint));
|
if (hint) p.log.message(k.dim(hint));
|
||||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
||||||
|
|||||||
Reference in New Issue
Block a user