feat: add PostHog telemetry for CLI errors and warnings (#3242)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run

Sends CLI errors, warnings, and crashes to PostHog for observability.
Strictly error/warning events — no command tracking or session events.

All messages are scrubbed before sending:
- API keys (sk-or-v1-*, sk-ant-*, key-*)
- GitHub tokens (ghp_*, github_pat_*)
- Bearer tokens
- Email addresses
- IP addresses
- Long tokens (60+ char alphanumeric)
- Base64 blobs (40+ chars)
- Home directory paths (/Users/name → ~/[USER])

Default on. Disable with SPAWN_TELEMETRY=0.
Fire-and-forget with 5s timeout — never blocks the CLI.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-04-08 18:02:39 -07:00 committed by GitHub
parent 3d31f1e328
commit 656b0da975
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 226 additions and 1 deletions

View file

@ -39,11 +39,17 @@ import {
import { expandEqualsFlags, findUnknownFlag } from "./flags.js";
import { agentKeys, cloudKeys, getCacheAge, loadManifest } from "./manifest.js";
import { asyncTryCatch, asyncTryCatchIf, isFileError, isNetworkError, tryCatch, tryCatchIf } from "./shared/result.js";
import { captureError, initTelemetry, setTelemetryContext } from "./shared/telemetry.js";
import { checkForUpdates } from "./update-check.js";
const VERSION = pkg.version;
// Initialize telemetry early — captures uncaught errors and exit flush.
// Disabled with SPAWN_TELEMETRY=0.
initTelemetry(VERSION);
function handleError(err: unknown): never {
captureError("cli_error", err);
const msg = getErrorMessage(err);
console.error(pc.red(`Error: ${msg}`));
console.error(`\nRun ${pc.cyan("spawn help")} for usage information.`);
@ -221,6 +227,10 @@ async function handleDefaultCommand(
headless?: boolean,
outputFormat?: string,
): Promise<void> {
setTelemetryContext("agent", agent);
if (cloud) {
setTelemetryContext("cloud", cloud);
}
if (cloud && HELP_FLAGS.includes(cloud)) {
await showInfoOrError(agent);
return;

View file

@ -0,0 +1,212 @@
// shared/telemetry.ts — PostHog telemetry for errors, warnings, and crashes.
// Default on. Disable with SPAWN_TELEMETRY=0.
// Strictly errors/warnings/crashes — no command tracking, no session events.
import { asyncTryCatch } from "./result.js";
// Same PostHog project as feedback.ts
const POSTHOG_TOKEN = "phc_7ToS2jDeWBlMu4n2JoNzoA1FnArdKwFMFoHVnAqQ6O1";
const POSTHOG_URL = "https://us.i.posthog.com/batch/";
// Patterns to scrub from error messages before sending
const SENSITIVE_PATTERNS: [
RegExp,
string,
][] = [
// API keys: sk-or-v1-..., sk-ant-..., sk-..., key-...
[
/\b(sk-or-v1-|sk-ant-api03-|sk-|key-)[A-Za-z0-9_-]{10,}\b/g,
"[REDACTED_KEY]",
],
// GitHub tokens: ghp_..., gho_..., github_pat_...
[
/\b(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{10,}\b/g,
"[REDACTED_GITHUB_TOKEN]",
],
// Bearer tokens in headers
[
/Bearer\s+[A-Za-z0-9_.\-/+=]{10,}/gi,
"Bearer [REDACTED]",
],
// Email addresses
[
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
"[REDACTED_EMAIL]",
],
// IP addresses (IPv4)
[
/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
"[REDACTED_IP]",
],
// Hetzner/DO/cloud API tokens (64-char hex or similar)
[
/\b[A-Za-z0-9]{60,}\b/g,
"[REDACTED_TOKEN]",
],
// Base64-encoded blobs that might contain secrets (40+ chars)
[
/[A-Za-z0-9+/]{40,}={0,2}\b/g,
"[REDACTED_B64]",
],
// Home directory paths — replace with ~
[
/\/(?:home|Users)\/[a-zA-Z0-9._-]+/g,
"~/[USER]",
],
];
/** Scrub sensitive data from a string before sending to telemetry. */
function scrub(text: string): string {
let result = text;
for (const [pattern, replacement] of SENSITIVE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}
interface TelemetryEvent {
event: string;
timestamp: string;
properties: Record<string, unknown>;
}
// ── State ───────────────────────────────────────────────────────────────────
let _enabled = true;
let _sessionId = "";
let _context: Record<string, string> = {};
const _events: TelemetryEvent[] = [];
let _flushScheduled = false;
// ── Public API ──────────────────────────────────────────────────────────────
/** Initialize telemetry. Call once at startup. */
export function initTelemetry(version: string): void {
_enabled = process.env.SPAWN_TELEMETRY !== "0";
if (!_enabled) {
return;
}
_sessionId = crypto.randomUUID();
_context = {
spawn_version: version,
os: process.platform,
arch: process.arch,
};
// Capture uncaught errors
process.on("uncaughtException", (err) => {
captureError("uncaught_exception", err);
flushSync();
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
captureError("unhandled_rejection", reason);
});
// Flush buffered events before exit
process.on("beforeExit", () => {
flushSync();
});
}
/** Set session context (agent, cloud, etc.). Call as info becomes available. */
export function setTelemetryContext(key: string, value: string): void {
if (!_enabled) {
return;
}
_context[key] = value;
}
/** Capture a warning event. */
export function captureWarning(message: string): void {
if (!_enabled) {
return;
}
pushEvent("cli_warning", {
message: scrub(message),
});
}
/** Capture an error event. */
export function captureError(type: string, err: unknown): void {
if (!_enabled) {
return;
}
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
pushEvent("cli_error", {
type,
message: scrub(message),
...(stack
? {
stack: scrub(stack),
}
: {}),
});
}
// ── Internals ───────────────────────────────────────────────────────────────
function pushEvent(event: string, properties: Record<string, unknown>): void {
_events.push({
event,
timestamp: new Date().toISOString(),
properties: {
..._context,
...properties,
$session_id: _sessionId,
},
});
// Schedule a flush — batch events that happen in quick succession
if (!_flushScheduled && _events.length >= 10) {
_flushScheduled = true;
setTimeout(() => {
_flushScheduled = false;
flush();
}, 1000);
}
}
/** Async flush — best effort, doesn't block. */
function flush(): void {
if (_events.length === 0) {
return;
}
const batch = _events.splice(0);
sendBatch(batch);
}
/** Sync-safe flush for exit handlers. Uses fetch without await. */
function flushSync(): void {
if (_events.length === 0) {
return;
}
const batch = _events.splice(0);
sendBatch(batch);
}
function sendBatch(batch: TelemetryEvent[]): void {
const body = JSON.stringify({
api_key: POSTHOG_TOKEN,
batch: batch.map((e) => ({
event: e.event,
distinct_id: _sessionId,
timestamp: e.timestamp,
properties: e.properties,
})),
});
// Fire-and-forget — never block the CLI on telemetry
asyncTryCatch(() =>
fetch(POSTHOG_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body,
signal: AbortSignal.timeout(5_000),
}),
);
}

View file

@ -9,6 +9,7 @@ import { isString } from "@openrouter/spawn-shared";
import { parseJsonObj } from "./parse.js";
import { getSpawnCloudConfigPath } from "./paths.js";
import { asyncTryCatch, tryCatch, unwrapOr } from "./result.js";
import { captureError, captureWarning } from "./telemetry.js";
const RED = "\x1b[0;31m";
const GREEN = "\x1b[0;32m";
@ -30,10 +31,12 @@ export function logDebug(msg: string): void {
export function logWarn(msg: string): void {
process.stderr.write(`${YELLOW}${msg}${NC}\n`);
captureWarning(msg);
}
export function logError(msg: string): void {
process.stderr.write(`${RED}${msg}${NC}\n`);
captureError("log_error", msg);
}
export function logStep(msg: string): void {