Files
software-mansion__argent/packages/argent-cli/test/run-telemetry.test.ts
Hubert Gancarczyk 8483fe23f9 feat: add telemetry (#274)
## Summary

Adds anonymous, opt-out telemetry for Argent installer, update/uninstall
flows, CLI telemetry controls, tool-server lifecycle, and tool
invocation health signals.
## What Changed
- Added a new private `@argent/telemetry` package
- Introduced typed telemetry events with runtime property
allowlisting/sanitization.
- Added lazy anonymous identity creation in `~/.argent/telemetry-id`.
- Added consent handling via: `argent telemetry enable`, `argent
telemetry disable`, `argent telemetry status`
- Added local debug logging with `ARGENT_TELEMETRY_DEBUG=1`.
- Tracks installer funnel events for init, global install/update
decisions, editor selection, allowlist decisions, skill installation,
rules/agents copy, update, and uninstall.
- Tracks tool-server start/stop and tool invocation/complete/fail
events.
- Adds per-tool invocation IDs so request metadata can be correlated
without relying on ordering.
- Records only coarse platform metadata (`ios` / `android`) for device
tools
- Ensures short-lived commands drain telemetry with bounded shutdown
before exit.
- Wires telemetry package into bundling and injects full Argent CLI
version metadata at build time.
- Added central `FAILURE_CODES` and `FailureSignal` helpers in
`@argent/registry`.
- Attached structured, sanitized failure metadata across registry
errors, tool-server tools/services, HTTP validation/dependency gates,
installer flows, and `argent run`.
- Added telemetry allowlisting for safe failure fields only: static
error codes/stages, coarse area/kind/command, bounded exit codes,
allowlisted signals/spawn codes, and coarse network failure classes.
- Split CLI-local `argent run` failures into `cli:run_fail` so they are
not double-counted as tool failures.
- Added installer telemetry for failed package actions, update/uninstall
failures, rules/agents copy results, and skill refresh results.
- Made telemetry consent writes atomic and kept telemetry status
side-effect free via `peekAnonId()`.
- Adds a coarse, anonymous signal to tool telemetry: which AI coding
tool is driving the MCP server (Claude Code, Codex, Cursor, Gemini, VS
Code, Windsurf, Zed, opencode, Copilot, or other).

## Tests Added / Updated

- Telemetry unit coverage for consent precedence, identity creation,
base props, sanitization, PostHog config, debug logging, shutdown,
opt-out, and forget behavior.
- Registry/tool-server tests for invocation IDs, platform metadata
boundaries, startup failure flushing, and tool lifecycle telemetry.
- Installer tests for final telemetry drain, update action
classification, and uninstall identity cleanup ordering.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:22:34 +02:00

92 lines
2.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { run } from "../src/run.js";
const toolsClientMock = vi.hoisted(() => ({
fetchTool: vi.fn(),
callTool: vi.fn(),
}));
const telemetryMock = vi.hoisted(() => ({
init: vi.fn(),
shutdown: vi.fn(async () => undefined),
track: vi.fn(),
}));
vi.mock("@argent/tools-client", () => ({
createToolsClient: vi.fn(() => toolsClientMock),
}));
vi.mock("@argent/telemetry", () => telemetryMock);
const toolMeta = {
name: "sample-tool",
description: "Sample tool",
inputSchema: {
type: "object",
properties: {},
},
};
describe("argent run telemetry", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
toolsClientMock.fetchTool.mockResolvedValue(toolMeta);
toolsClientMock.callTool.mockResolvedValue({ data: { ok: true } });
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as typeof process.exit);
errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
});
afterEach(() => {
exitSpy.mockRestore();
errorSpy.mockRestore();
logSpy.mockRestore();
});
it("emits cli:run_fail, not tool:fail, when tool-server call fails", async () => {
toolsClientMock.callTool.mockRejectedValue(new Error("server already tracked this"));
await expect(run(["sample-tool"], { paths: {} as never })).rejects.toThrow("process.exit:1");
expect(console.error).toHaveBeenCalledWith("server already tracked this");
expect(telemetryMock.track).toHaveBeenCalledWith(
"cli:run_fail",
expect.objectContaining({
tool: "sample-tool",
error_code: "CLI_RUN_TOOL_CALL_FAILED",
failure_stage: "cli_run_call_tool",
failure_area: "cli",
error_kind: "unknown",
})
);
expect(telemetryMock.track).not.toHaveBeenCalledWith("tool:fail", expect.anything());
expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1);
});
it("emits cli:run_fail, not tool:fail, for local CLI argument parsing failures", async () => {
await expect(
run(["sample-tool", "--args", "not-json"], { paths: {} as never })
).rejects.toThrow("process.exit:2");
expect(toolsClientMock.callTool).not.toHaveBeenCalled();
expect(telemetryMock.track).toHaveBeenCalledWith(
"cli:run_fail",
expect.objectContaining({
tool: "sample-tool",
error_code: "CLI_RUN_ARGS_JSON_INVALID",
failure_stage: "cli_run_parse_raw_args",
failure_area: "cli",
error_kind: "validation",
})
);
expect(telemetryMock.track).not.toHaveBeenCalledWith("tool:fail", expect.anything());
expect(telemetryMock.shutdown).toHaveBeenCalledTimes(1);
});
});