Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.12",
"version": "1.0.13",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
51 changes: 50 additions & 1 deletion packages/cli/src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,21 @@ function getFirstExceptionEntry(
describe("telemetry", () => {
let originalFetch: typeof global.fetch;
let originalTelemetry: string | undefined;
let originalBunEnv: string | undefined;
let originalNodeEnv: string | undefined;
let fetchMock: ReturnType<typeof mock>;

beforeEach(() => {
originalFetch = global.fetch;
originalTelemetry = process.env.SPAWN_TELEMETRY;
// Enable telemetry
originalBunEnv = process.env.BUN_ENV;
originalNodeEnv = process.env.NODE_ENV;
// Enable telemetry — these tests need initTelemetry() to actually flip
// _enabled to true so they can assert on the sent payloads. Clearing
// BUN_ENV/NODE_ENV lets the test-env guard in initTelemetry pass.
delete process.env.SPAWN_TELEMETRY;
delete process.env.BUN_ENV;
delete process.env.NODE_ENV;
// Mock fetch to capture PostHog payloads
fetchMock = mock(() => Promise.resolve(new Response("ok")));
global.fetch = fetchMock;
Expand All @@ -124,6 +132,16 @@ describe("telemetry", () => {
} else {
delete process.env.SPAWN_TELEMETRY;
}
if (originalBunEnv !== undefined) {
process.env.BUN_ENV = originalBunEnv;
} else {
delete process.env.BUN_ENV;
}
if (originalNodeEnv !== undefined) {
process.env.NODE_ENV = originalNodeEnv;
} else {
delete process.env.NODE_ENV;
}
});

/** Flush telemetry and wait for async send. */
Expand Down Expand Up @@ -416,6 +434,37 @@ describe("telemetry", () => {

expect(fetchMock).not.toHaveBeenCalled();
});

it("does not send events when BUN_ENV=test (CI guard)", async () => {
process.env.BUN_ENV = "test";

const mod = await import("../shared/telemetry.js");
mod.initTelemetry("0.0.0-test");
await drainStaleEvents();

mod.captureEvent("funnel_started", {
agent: "claude",
});
mod.captureError("test", new Error("ci"));
await flushAndWait();

expect(fetchMock).not.toHaveBeenCalled();
});

it("does not send events when NODE_ENV=test (CI guard)", async () => {
process.env.NODE_ENV = "test";

const mod = await import("../shared/telemetry.js");
mod.initTelemetry("0.0.0-test");
await drainStaleEvents();

mod.captureEvent("funnel_started", {
agent: "claude",
});
await flushAndWait();

expect(fetchMock).not.toHaveBeenCalled();
});
});

describe("captureEvent", () => {
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/src/commands/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getAgentOptionalSteps } from "../shared/agents.js";
import { hasSavedOpenRouterKey } from "../shared/oauth.js";
import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js";
import { maybeShowStarPrompt } from "../shared/star-prompt.js";
import { captureEvent, setTelemetryContext } from "../shared/telemetry.js";
import { validateModelId } from "../shared/ui.js";
import { cmdLink } from "./link.js";
import { activeServerPicker } from "./list.js";
Expand Down Expand Up @@ -278,10 +279,19 @@ export { getAndValidateCloudChoices, promptSetupOptions, promptSpawnName, select
export async function cmdInteractive(): Promise<void> {
p.intro(pc.inverse(` spawn v${VERSION} `));

// Funnel entry — fires BEFORE any prompt so we catch users who bail at
// the very first screen. See also: funnel_* events in orchestrate.ts.
captureEvent("spawn_launched", {
mode: "interactive",
});

// If the user has existing spawns, offer a top-level menu so they can
// reconnect without knowing about `spawn list` or `spawn last`.
const activeServers = getActiveServers();
if (activeServers.length > 0) {
captureEvent("menu_shown", {
active_servers: activeServers.length,
});
const topChoice = await p.select({
message: "What would you like to do?",
options: [
Expand All @@ -296,8 +306,12 @@ export async function cmdInteractive(): Promise<void> {
],
});
if (p.isCancel(topChoice)) {
captureEvent("menu_cancelled");
handleCancel();
}
captureEvent("menu_selected", {
choice: String(topChoice),
});
if (topChoice === "connect") {
const manifestResult = await asyncTryCatch(() => loadManifestWithSpinner());
const manifest = manifestResult.ok ? manifestResult.data : null;
Expand All @@ -307,10 +321,20 @@ export async function cmdInteractive(): Promise<void> {
}

const manifest = await loadManifestWithSpinner();
captureEvent("agent_picker_shown");
const agentChoice = await selectAgent(manifest);
captureEvent("agent_selected", {
agent: agentChoice,
});
setTelemetryContext("agent", agentChoice);

const { clouds, hintOverrides } = getAndValidateCloudChoices(manifest, agentChoice);
captureEvent("cloud_picker_shown");
const cloudChoice = await selectCloud(manifest, clouds, hintOverrides);
captureEvent("cloud_selected", {
cloud: cloudChoice,
});
setTelemetryContext("cloud", cloudChoice);

// Handle "Link Existing Server" — redirect to spawn link with the agent pre-selected
if (cloudChoice === "link-existing") {
Expand All @@ -324,27 +348,37 @@ export async function cmdInteractive(): Promise<void> {
}

await preflightCredentialCheck(manifest, cloudChoice);
captureEvent("preflight_passed");

// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
captureEvent("setup_options_shown");
const enabledSteps = await promptSetupOptions(agentChoice);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
captureEvent("setup_options_selected", {
step_count: enabledSteps.size,
});
}
}

// Skills picker (--beta skills)
await maybePromptSkills(manifest, agentChoice);

captureEvent("name_prompt_shown");
const spawnName = await promptSpawnName();
// promptSpawnName cancels via handleCancel() on its own path if the user
// bails; if we reach this line the name was entered successfully.
captureEvent("name_entered");

const agentName = manifest.agents[agentChoice].name;
const cloudName = manifest.clouds[cloudChoice].name;
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}`);
p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${agentChoice} ${cloudChoice}`)}`);
p.outro("Handing off to spawn script...");
captureEvent("picker_completed");

const success = await execScript(
cloudChoice,
Expand All @@ -364,10 +398,19 @@ export async function cmdInteractive(): Promise<void> {
export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun?: boolean): Promise<void> {
p.intro(pc.inverse(` spawn v${VERSION} `));

// Same funnel entry as cmdInteractive — mode distinguishes the short-form
// (`spawn claude`) entry point from the full interactive picker.
captureEvent("spawn_launched", {
mode: "agent_interactive",
});

const manifest = await loadManifestWithSpinner();
const resolvedAgent = resolveAgentKey(manifest, agent);

if (!resolvedAgent) {
captureEvent("agent_invalid", {
raw: agent,
});
const agentMatch = findClosestKeyByNameOrKey(agent, agentKeys(manifest), (k) => manifest.agents[k].name);
p.log.error(`Unknown agent: ${pc.bold(agent)}`);
if (agentMatch) {
Expand All @@ -377,8 +420,19 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun
process.exit(1);
}

// Agent was pre-supplied on the command line — treat as implicitly selected.
captureEvent("agent_selected", {
agent: resolvedAgent,
});
setTelemetryContext("agent", resolvedAgent);

const { clouds, hintOverrides } = getAndValidateCloudChoices(manifest, resolvedAgent);
captureEvent("cloud_picker_shown");
const cloudChoice = await selectCloud(manifest, clouds, hintOverrides);
captureEvent("cloud_selected", {
cloud: cloudChoice,
});
setTelemetryContext("cloud", cloudChoice);

// Handle "Link Existing Server" — redirect to spawn link with the agent pre-selected
if (cloudChoice === "link-existing") {
Expand All @@ -397,24 +451,32 @@ export async function cmdAgentInteractive(agent: string, prompt?: string, dryRun
}

await preflightCredentialCheck(manifest, cloudChoice);
captureEvent("preflight_passed");

// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
captureEvent("setup_options_shown");
const enabledSteps = await promptSetupOptions(resolvedAgent);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
captureEvent("setup_options_selected", {
step_count: enabledSteps.size,
});
}
}

captureEvent("name_prompt_shown");
const spawnName = await promptSpawnName();
captureEvent("name_entered");

const agentName = manifest.agents[resolvedAgent].name;
const cloudName = manifest.clouds[cloudChoice].name;
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}`);
p.log.info(`Next time, run directly: ${pc.cyan(`spawn ${resolvedAgent} ${cloudChoice}`)}`);
p.outro("Handing off to spawn script...");
captureEvent("picker_completed");

const success = await execScript(
cloudChoice,
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js";
import { getLocalShell, isWindows } from "../shared/shell.js";
import { maybeShowStarPrompt } from "../shared/star-prompt.js";
import { captureEvent, setTelemetryContext } from "../shared/telemetry.js";
import { logError, logInfo, logStep, prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
import { handleRecordAction } from "./list.js";
Expand Down Expand Up @@ -987,6 +988,13 @@ function runBundleHeadless(
export async function cmdRunHeadless(agent: string, cloud: string, opts: HeadlessOptions = {}): Promise<void> {
const { prompt, debug, outputFormat, spawnName } = opts;

// Funnel entry for headless runs. No picker to instrument — headless either
// validates and proceeds straight to runOrchestration, or it errors out.
// The orchestrate.ts funnel_* events cover the rest.
captureEvent("spawn_launched", {
mode: "headless",
});

// Phase 1: Validate inputs (exit code 3)
const validationResult = tryCatch(() => {
validateIdentifier(agent, "Agent name");
Expand Down Expand Up @@ -1230,31 +1238,56 @@ export async function cmdRun(
dryRun?: boolean,
debug?: boolean,
): Promise<void> {
// Funnel entry for the non-interactive `spawn <agent> <cloud>` path.
// mode distinguishes this from the interactive pickers so we can split the
// funnel by entry point in PostHog.
captureEvent("spawn_launched", {
mode: "direct",
});

const manifest = await loadManifestWithSpinner();
({ agent, cloud } = resolveAndLog(manifest, agent, cloud));

validateRunSecurity(agent, cloud, prompt);
({ agent, cloud } = detectAndFixSwappedArgs(manifest, agent, cloud));
validateEntities(manifest, agent, cloud);

// Both arguments were pre-supplied — treat as implicit selection so the
// funnel has the same shape regardless of entry point.
captureEvent("agent_selected", {
agent,
});
captureEvent("cloud_selected", {
cloud,
});
setTelemetryContext("agent", agent);
setTelemetryContext("cloud", cloud);

if (dryRun) {
showDryRunPreview(manifest, agent, cloud, prompt);
return;
}

await preflightCredentialCheck(manifest, cloud);
captureEvent("preflight_passed");

// Skip setup prompt if steps already set via --steps or --config
if (!process.env.SPAWN_ENABLED_STEPS) {
captureEvent("setup_options_shown");
const enabledSteps = await promptSetupOptions(agent);
if (enabledSteps) {
process.env.SPAWN_ENABLED_STEPS = [
...enabledSteps,
].join(",");
captureEvent("setup_options_selected", {
step_count: enabledSteps.size,
});
}
}

captureEvent("name_prompt_shown");
const spawnName = await promptSpawnName();
captureEvent("name_entered");

// If a name was given, check whether an active instance with that name already
// exists for this agent + cloud combination. When it does, route the user into
Expand All @@ -1276,6 +1309,7 @@ export async function cmdRun(
const cloudName = manifest.clouds[cloud].name;
const suffix = prompt ? " with prompt..." : "...";
p.log.step(`Launching ${pc.bold(agentName)} on ${pc.bold(cloudName)}${suffix}`);
captureEvent("picker_completed");

const success = await execScript(
cloud,
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/shared/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,14 @@ interface TelemetryEvent {

// ── State ───────────────────────────────────────────────────────────────────

let _enabled = true;
// Telemetry is OPT-IN: nothing fires until initTelemetry() is called. This
// matters for tests that import modules which call captureEvent — without
// this default, every `bun test` run of orchestrate.test.ts fired real
// PostHog events tagged agent=testagent, because the test imports
// runOrchestration directly (bypassing index.ts's initTelemetry call) but
// runOrchestration calls captureEvent unconditionally. Defaulting _enabled
// to false means no events escape until a real process explicitly opts in.
let _enabled = false;
let _sessionId = "";
let _context: Record<string, string> = {};
const _events: TelemetryEvent[] = [];
Expand All @@ -133,6 +140,15 @@ let _flushScheduled = false;

/** Initialize telemetry. Call once at startup. */
export function initTelemetry(version: string): void {
// Never send telemetry from test environments. bun:test sets BUN_ENV=test,
// Node test runners set NODE_ENV=test. Without this guard, every CI run of
// orchestrate.test.ts fires real PostHog events tagged agent=testagent,
// polluting the onboarding funnel with fixture data. (See #3305 follow-up.)
if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") {
_enabled = false;
return;
}

_enabled = process.env.SPAWN_TELEMETRY !== "0";
if (!_enabled) {
return;
Expand Down
Loading