Files
software-mansion__argent/packages/telemetry/test/posthog-host.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

88 lines
3.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { POSTHOG_HOST, resetClient, resolveConfig, getClient } from "../src/posthog.js";
vi.mock("posthog-node", () => {
return {
PostHog: vi.fn().mockImplementation(function (
this: { opts: unknown },
_key: string,
opts: unknown
) {
this.opts = opts;
}),
};
});
describe("posthog host invariance", () => {
beforeEach(() => {
resetClient();
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "phc_real";
});
afterEach(() => {
delete (globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST;
resetClient();
});
it("POSTHOG_HOST is the hard-coded EU URL", () => {
expect(POSTHOG_HOST).toBe("https://eu.i.posthog.com");
});
it.each([
["POSTHOG_HOST", "https://attacker.example/collect"],
["POSTHOG_API_HOST", "https://us.i.posthog.com"],
["POSTHOG_INGESTION_HOST", "https://attacker.example"],
["POSTHOG_PERSONAL_API_KEY", "phx_steal_me"],
])("ignores env var %s=%s", async (envName, value) => {
const old = process.env[envName];
process.env[envName] = value;
try {
const client = getClient() as unknown as { opts: { host: string } } | null;
expect(client).not.toBeNull();
expect(client!.opts.host).toBe("https://eu.i.posthog.com");
} finally {
if (old === undefined) delete process.env[envName];
else process.env[envName] = old;
}
});
it("does not construct a client when key is sentinel-disabled", () => {
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "";
resetClient();
expect(getClient()).toBeNull();
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "phc_disabled";
resetClient();
expect(getClient()).toBeNull();
});
it("does construct a client when a real key is configured", () => {
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "phc_real";
resetClient();
expect(getClient()).not.toBeNull();
});
it("resolveConfig uses the single public project token", () => {
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "phc_single";
expect(resolveConfig().key).toBe("phc_single");
});
it("uses the queued batching config for the singleton client", () => {
const client = getClient() as unknown as {
opts: { flushAt: number; flushInterval: number };
} | null;
expect(client).not.toBeNull();
expect(client!.opts).toEqual(expect.objectContaining({ flushAt: 20, flushInterval: 10_000 }));
});
it("disables in-request retries so a failed flush can't hold the event loop open", () => {
// posthog-node's retry backoff uses a non-unref'd setTimeout; any retry would
// keep short-lived commands alive after their bounded shutdown() returns.
const client = getClient() as unknown as { opts: { fetchRetryCount: number } } | null;
expect(client).not.toBeNull();
expect(client!.opts.fetchRetryCount).toBe(0);
});
});