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
231 changes: 231 additions & 0 deletions packages/cli/src/__tests__/lifecycle-telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/**
* lifecycle-telemetry.test.ts — Verifies trackSpawnConnected /
* trackSpawnDeleted emit the right PostHog events and persist the
* connect_count + last_connected_at metadata.
*/

import type { SpawnRecord } from "../history";

import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { isNumber, isString } from "@openrouter/spawn-shared";
// Import the real modules so we can spy on their exports without
// polluting the global module registry (mock.module contaminates
// other test files when running under --coverage).
import * as historyMod from "../history";
import { trackSpawnConnected, trackSpawnDeleted } from "../shared/lifecycle-telemetry";
import * as telemetryMod from "../shared/telemetry";

const savedMetadataCalls: Array<{
entries: Record<string, string>;
spawnId?: string;
}> = [];

const capturedEvents: Array<{
event: string;
properties: Record<string, unknown>;
}> = [];

// ── Helpers ─────────────────────────────────────────────────────────────

function makeRecord(overrides: Partial<SpawnRecord> = {}): SpawnRecord {
return {
id: "spawn-abc123",
agent: "claude",
cloud: "digitalocean",
timestamp: "2026-04-13T12:00:00.000Z",
connection: {
ip: "10.0.0.1",
user: "root",
cloud: "digitalocean",
metadata: {},
},
...overrides,
};
}

// ── Tests ───────────────────────────────────────────────────────────────

describe("lifecycle-telemetry", () => {
let saveMetadataSpy: ReturnType<typeof spyOn>;
let captureEventSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
savedMetadataCalls.length = 0;
capturedEvents.length = 0;

saveMetadataSpy = spyOn(historyMod, "saveMetadata").mockImplementation(
(entries: Record<string, string>, spawnId?: string) => {
savedMetadataCalls.push({
entries,
spawnId,
});
},
);
captureEventSpy = spyOn(telemetryMod, "captureEvent").mockImplementation(
(event: string, properties: Record<string, unknown>) => {
capturedEvents.push({
event,
properties,
});
},
);
});

afterEach(() => {
saveMetadataSpy.mockRestore();
captureEventSpy.mockRestore();
savedMetadataCalls.length = 0;
capturedEvents.length = 0;
});

describe("trackSpawnConnected", () => {
it("starts the connect count at 1 when metadata is empty", () => {
const record = makeRecord();
const count = trackSpawnConnected(record);

expect(count).toBe(1);
expect(savedMetadataCalls).toHaveLength(1);
expect(savedMetadataCalls[0].entries.connect_count).toBe("1");
expect(savedMetadataCalls[0].spawnId).toBe("spawn-abc123");
});

it("increments an existing connect count", () => {
const record = makeRecord({
connection: {
ip: "10.0.0.1",
user: "root",
cloud: "digitalocean",
metadata: {
connect_count: "4",
},
},
});
const count = trackSpawnConnected(record);

expect(count).toBe(5);
expect(savedMetadataCalls[0].entries.connect_count).toBe("5");
});

it("tolerates malformed connect_count by resetting to 1", () => {
const record = makeRecord({
connection: {
ip: "10.0.0.1",
user: "root",
cloud: "digitalocean",
metadata: {
connect_count: "not-a-number",
},
},
});
const count = trackSpawnConnected(record);

// Malformed parses to 0, +1 = 1. Never throws.
expect(count).toBe(1);
});

it("updates last_connected_at to an ISO timestamp", () => {
trackSpawnConnected(makeRecord());

const ts = savedMetadataCalls[0].entries.last_connected_at;
expect(ts).toBeDefined();
// ISO 8601 format YYYY-MM-DDTHH:MM:SS.sssZ
expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});

it("emits spawn_connected event with spawn metadata", () => {
const record = makeRecord();
trackSpawnConnected(record);

expect(capturedEvents).toHaveLength(1);
expect(capturedEvents[0].event).toBe("spawn_connected");
expect(capturedEvents[0].properties.spawn_id).toBe("spawn-abc123");
expect(capturedEvents[0].properties.agent).toBe("claude");
expect(capturedEvents[0].properties.cloud).toBe("digitalocean");
expect(capturedEvents[0].properties.connect_count).toBe(1);
});

it("is a no-op for records without an id or connection", () => {
const noId = makeRecord({
id: undefined,
});
expect(trackSpawnConnected(noId)).toBe(0);
expect(savedMetadataCalls).toHaveLength(0);
expect(capturedEvents).toHaveLength(0);

const noConn = makeRecord({
connection: undefined,
});
expect(trackSpawnConnected(noConn)).toBe(0);
expect(savedMetadataCalls).toHaveLength(0);
expect(capturedEvents).toHaveLength(0);
});
});

describe("trackSpawnDeleted", () => {
it("emits spawn_deleted with lifetime_hours computed from timestamp", () => {
// Record created 3 hours ago. With `new Date()` in the helper we can't
// easily mock the clock here, so we assert on a loose-but-correct
// range (3h +/- a minute).
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
const record = makeRecord({
timestamp: threeHoursAgo,
});

trackSpawnDeleted(record);

expect(capturedEvents).toHaveLength(1);
expect(capturedEvents[0].event).toBe("spawn_deleted");
const rawLifetime = capturedEvents[0].properties.lifetime_hours;
const lifetime = isNumber(rawLifetime) ? rawLifetime : 0;
expect(lifetime).toBeGreaterThanOrEqual(2.98);
expect(lifetime).toBeLessThanOrEqual(3.02);
});

it("reports the final connect count", () => {
const record = makeRecord({
connection: {
ip: "10.0.0.1",
user: "root",
cloud: "digitalocean",
metadata: {
connect_count: "7",
},
},
});
trackSpawnDeleted(record);

expect(capturedEvents[0].properties.connect_count).toBe(7);
});

it("clamps negative lifetimes to 0 (corrupt clock / timestamp)", () => {
const futureTimestamp = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const record = makeRecord({
timestamp: futureTimestamp,
});

trackSpawnDeleted(record);

expect(capturedEvents[0].properties.lifetime_hours).toBe(0);
});

it("is a no-op for records without an id", () => {
trackSpawnDeleted(
makeRecord({
id: undefined,
}),
);
expect(capturedEvents).toHaveLength(0);
});

it("includes spawn_id, agent, cloud, and date on every event", () => {
trackSpawnDeleted(makeRecord());

const props = capturedEvents[0].properties;
expect(props.spawn_id).toBe("spawn-abc123");
expect(props.agent).toBe("claude");
expect(props.cloud).toBe("digitalocean");
expect(isString(props.date)).toBe(true);
expect(props.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
});
});
54 changes: 54 additions & 0 deletions packages/cli/src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import { isString } from "@openrouter/spawn-shared";
import * as v from "valibot";

// ── Schemas for validating PostHog payloads ─────────────────────────────────
Expand Down Expand Up @@ -408,9 +409,62 @@ describe("telemetry", () => {

mod.captureWarning("should not send");
mod.captureError("test", new Error("should not send"));
mod.captureEvent("should_not_send", {
spawn_id: "abc",
});
await flushAndWait();

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

describe("captureEvent", () => {
it("emits a batched event with the given name and properties", async () => {
const mod = await import("../shared/telemetry.js");
mod.initTelemetry("1.2.3-test");
await drainStaleEvents();

mod.captureEvent("funnel_started", {
fast_mode: true,
elapsed_ms: 0,
});
await flushAndWait();

const body = getLastBatchBody(fetchMock);
expect(body).not.toBeNull();
const evt = body?.batch[0];
expect(evt?.event).toBe("funnel_started");
expect(evt?.properties.fast_mode).toBe(true);
expect(evt?.properties.elapsed_ms).toBe(0);
expect(evt?.properties.spawn_version).toBe("1.2.3-test");
});

it("scrubs string property values but leaves non-strings alone", async () => {
const mod = await import("../shared/telemetry.js");
mod.initTelemetry("1.2.3-test");
await drainStaleEvents();

mod.captureEvent("spawn_connected", {
spawn_id: "abc123",
note: "contact me at alice@example.com about sk-or-v1-1234567890abcdef",
connect_count: 5,
lifetime_hours: 3.5,
});
await flushAndWait();

const body = getLastBatchBody(fetchMock);
const props = body?.batch[0]?.properties;
// Non-string values pass through untouched.
expect(props?.spawn_id).toBe("abc123");
expect(props?.connect_count).toBe(5);
expect(props?.lifetime_hours).toBe(3.5);
// String values get scrubbed.
const rawNote = props?.note;
const note = isString(rawNote) ? rawNote : "";
expect(note).toContain("[REDACTED_EMAIL]");
expect(note).not.toContain("alice@example.com");
expect(note).toContain("[REDACTED_KEY]");
expect(note).not.toContain("sk-or-v1-1234567890abcdef");
});
});
});
5 changes: 5 additions & 0 deletions packages/cli/src/commands/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
validateServerIdentifier,
validateUsername,
} from "../security.js";
import { trackSpawnDeleted } from "../shared/lifecycle-telemetry.js";
import { getHistoryPath } from "../shared/paths.js";
import { asyncTryCatch, asyncTryCatchIf, isNetworkError, tryCatch } from "../shared/result.js";
import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js";
Expand Down Expand Up @@ -259,6 +260,8 @@ export async function confirmAndDelete(
if (success) {
const detail = lastMessage ? `: ${lastMessage}` : "";
p.log.success(`Server "${label}" deleted${detail}`);
// Lifecycle telemetry: lifetime hours + final login count.
trackSpawnDeleted(record);
} else {
const detail = lastMessage ? `: ${lastMessage}` : "";
p.log.error(`Failed to delete "${label}"${detail}`);
Expand Down Expand Up @@ -448,6 +451,8 @@ export async function cmdDelete(
const ok = await execDeleteServer(record);
if (ok) {
p.log.success(`Server "${label}" deleted`);
// Lifecycle telemetry: headless path also fires the event.
trackSpawnDeleted(record);
}
}
return;
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
updateRecordIp,
} from "../history.js";
import { agentKeys, cloudKeys, loadManifest } from "../manifest.js";
import { trackSpawnConnected } from "../shared/lifecycle-telemetry.js";
import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js";
import { cmdConnect, cmdEnterAgent, cmdOpenDashboard } from "./connect.js";
import { confirmAndDelete } from "./delete.js";
Expand Down Expand Up @@ -707,6 +708,10 @@ export async function handleRecordAction(
}

if (action === "reconnect") {
// Lifecycle telemetry: record the login BEFORE we hand off to SSH.
// cmdConnect spawns an interactive session and never returns under normal
// use, so calling trackSpawnConnected after would be unreachable code.
trackSpawnConnected(selected);
const reconnectResult = await asyncTryCatch(() => cmdConnect(conn, selected.agent));
if (!reconnectResult.ok) {
p.log.error(`Connection failed: ${getErrorMessage(reconnectResult.error)}`);
Expand Down
Loading
Loading