Files
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

100 lines
3.2 KiB
TypeScript

import * as fs from "node:fs";
import { describe, expect, it } from "vitest";
import { scopeHome, snapshotEnv } from "./helpers.js";
import { emitDebugPayload, isDebugEnabled } from "../src/debug.js";
import { debugLogPath } from "../src/paths.js";
describe("debug", () => {
scopeHome();
it("isDebugEnabled honours ARGENT_TELEMETRY_DEBUG=1", () => {
const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]);
try {
delete process.env.ARGENT_TELEMETRY_DEBUG;
expect(isDebugEnabled()).toBe(false);
process.env.ARGENT_TELEMETRY_DEBUG = "1";
expect(isDebugEnabled()).toBe(true);
process.env.ARGENT_TELEMETRY_DEBUG = "true";
expect(isDebugEnabled()).toBe(true);
process.env.ARGENT_TELEMETRY_DEBUG = "0";
expect(isDebugEnabled()).toBe(false);
} finally {
restore();
}
});
it("emitDebugPayload appends to ~/.argent/telemetry-debug.log when debug is on", () => {
const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]);
try {
process.env.ARGENT_TELEMETRY_DEBUG = "1";
emitDebugPayload({
event: "test:event",
distinctId: "00000000-0000-0000-0000-000000000000",
properties: { foo: "bar" },
ts: "2026-05-25T00:00:00.000Z",
});
const contents = fs.readFileSync(debugLogPath(), "utf8");
expect(contents).toContain("test:event");
expect(contents).toContain('"foo":"bar"');
} finally {
restore();
}
});
it("emitDebugPayload is a no-op when debug is off (file is not created)", () => {
const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]);
try {
delete process.env.ARGENT_TELEMETRY_DEBUG;
emitDebugPayload({
event: "test:event",
distinctId: "00000000-0000-0000-0000-000000000000",
properties: {},
ts: "2026-05-25T00:00:00.000Z",
});
expect(fs.existsSync(debugLogPath())).toBe(false);
} finally {
restore();
}
});
it("emitDebugPayload does not throw when properties contain circular values", () => {
const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]);
try {
process.env.ARGENT_TELEMETRY_DEBUG = "1";
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(() =>
emitDebugPayload({
event: "test:event",
distinctId: "00000000-0000-0000-0000-000000000000",
properties: circular,
ts: "2026-05-25T00:00:00.000Z",
})
).not.toThrow();
const contents = fs.readFileSync(debugLogPath(), "utf8");
expect(contents).toContain("debug_payload_serialization_error");
} finally {
restore();
}
});
it("emitDebugPayload does not throw when properties contain BigInt", () => {
const restore = snapshotEnv(["ARGENT_TELEMETRY_DEBUG"]);
try {
process.env.ARGENT_TELEMETRY_DEBUG = "1";
expect(() =>
emitDebugPayload({
event: "test:event",
distinctId: "00000000-0000-0000-0000-000000000000",
properties: { value: 1n },
ts: "2026-05-25T00:00:00.000Z",
})
).not.toThrow();
const contents = fs.readFileSync(debugLogPath(), "utf8");
expect(contents).toContain("debug_payload_serialization_error");
} finally {
restore();
}
});
});