mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
3393d22edd
## Summary Replaces the random-UUID telemetry `distinct_id` with a stable per-machine id derived from the host fingerprint, so PostHog's native "Unique users" counts machines (funnels / retention / breakdowns work with no HogQL). Every Argent entry point - installer, CLI, tool-server, MCP - derives the id from one shared code path, so a given machine reports a single identity across every event and across install / uninstall / reinstall. ## What changed - `distinct_id = uuidv5(hostFingerprint, NAMESPACE)`, where the fingerprint comes from `simulator-server fingerprint`. Deterministic, keeps the UUID shape, and never stores the raw hardware value in PostHog (only the SHA-1-derived UUID leaves the process). - The fingerprint is resolved in `buildPayload` - the single `capture()` path shared by every entry point - so installer / CLI / tool-server / MCP all produce the same id. Resolved lazily, once per process, memoized. - One-time, local-only migration: the first time a fingerprint resolves, `~/.argent/telemetry-id` is rewritten from the legacy random UUID to the derived id. No PostHog `alias` / `$identify` event is emitted. - Fallback: if the fingerprint can't be resolved (unsupported platform, binary absent), the stored or a fresh random UUID is used - identically for every entry point on that machine, self-healing to the derived id on the next successful resolve. - Hardening: never throws; the fingerprint is validated (exactly 64 hex, case-insensitive) before hashing; corrupt / empty / unreadable id files self-heal; the binary is spawned at most once per process. ## Why the id is stable across install -> uninstall -> reinstall Uninstall deletes `~/.argent/telemetry-id`. Because the id is `uuidv5(fingerprint)` and the fingerprint is deterministic per machine, a fresh install recomputes the identical id. Previously only the tool-server resolved the fingerprint while the installer emitted under a random UUID, so this cycle produced a different random id each time. ## Bundling note for reviewers `@argent/telemetry` now depends on `@argent/native-devtools-ios` to locate the binary. That package computes its bin dir from `__dirname` at module load, which is undefined in an ES module. The installer / CLI / MCP ship as ESM bundles, so `bundle-tools.cjs`'s ESM banner is extended to shim `__dirname` / `__filename` (the CJS tool-server bundle already has them natively). Without this shim those bundles would crash on load, so the banner change is load-bearing. ## Testing - Unit: 219 telemetry tests - RFC 4122 v5 vectors, migration, idempotency, case/length validation, corrupt-file self-heal, spawn-at-most-once, and all fallbacks. - E2E on a real machine + booted iPhone 16 Pro: installer, CLI, and tool-server runtimes all emit the same derived id; the id is stable across a delete/recreate (uninstall/reinstall) cycle; a legacy v4 id migrates locally with no alias/identify emitted; verified from an ESM bundle placed in the real install's `dist/` so the binary resolves via the bundled `__dirname` with no env override; tool calls against the device carry the same id.
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import {
|
|
_resetConsentCacheForTest,
|
|
getConsentState,
|
|
markDisabled,
|
|
markEnabled,
|
|
writeConsentFlag,
|
|
warmTelemetryIdentity,
|
|
warmTelemetryIdentitySync,
|
|
resetLocalTelemetryState,
|
|
init,
|
|
isEnabled,
|
|
status,
|
|
shutdown,
|
|
track,
|
|
} from "../src/index.js";
|
|
import { resetClient } from "../src/posthog.js";
|
|
import { _resetIdentityCacheForTest } from "../src/identity.js";
|
|
import { _resetBasePropsCacheForTest } from "../src/base-props.js";
|
|
import { scopeHome, snapshotEnv } from "./helpers.js";
|
|
import { configFilePath } from "../src/paths.js";
|
|
|
|
const posthogMock = vi.hoisted(() => ({
|
|
instances: [] as Array<{
|
|
capture: ReturnType<typeof vi.fn>;
|
|
flush: ReturnType<typeof vi.fn>;
|
|
shutdown: ReturnType<typeof vi.fn>;
|
|
opts: unknown;
|
|
}>,
|
|
flushImpl: () => Promise.resolve(),
|
|
}));
|
|
|
|
// Telemetry resolves the host fingerprint internally for every entry point.
|
|
// Stub both the sync and async resolvers to a fixed 64-hex value so track() uses
|
|
// a deterministic id without spawning the real simulator-server binary. (The
|
|
// async resolver backs the background upgrade / warm-up; with the fingerprint
|
|
// resolved synchronously here it is a no-op, but the export must exist.)
|
|
vi.mock("../src/fingerprint.js", () => ({
|
|
resolveHostFingerprint: () => "f".repeat(64),
|
|
resolveHostFingerprintAsync: () => Promise.resolve("f".repeat(64)),
|
|
}));
|
|
|
|
vi.mock("posthog-node", () => {
|
|
return {
|
|
PostHog: vi.fn().mockImplementation(function (_key: string, opts: unknown) {
|
|
const instance = {
|
|
capture: vi.fn(),
|
|
flush: vi.fn(() => posthogMock.flushImpl()),
|
|
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
opts,
|
|
};
|
|
posthogMock.instances.push(instance);
|
|
return instance;
|
|
}),
|
|
};
|
|
});
|
|
|
|
describe("telemetry public surface", () => {
|
|
const { tmp } = scopeHome();
|
|
|
|
beforeEach(() => {
|
|
posthogMock.instances.length = 0;
|
|
posthogMock.flushImpl = () => Promise.resolve();
|
|
resetClient();
|
|
// is_ci and friends are memoized per process; reset so a test that sets CI
|
|
// env vars sees them recomputed rather than a value cached by a prior test.
|
|
_resetBasePropsCacheForTest();
|
|
// Isolate the module-level id / fingerprint / consent caches between tests.
|
|
_resetIdentityCacheForTest();
|
|
_resetConsentCacheForTest();
|
|
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "phc_real";
|
|
init("tool_server");
|
|
markEnabled();
|
|
});
|
|
|
|
// Helpers for the identity-wiring tests below.
|
|
const idFile = () => path.join(tmp(), ".argent", "telemetry-id");
|
|
const readId = () => {
|
|
try {
|
|
return fs.readFileSync(idFile(), "utf8").trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const flushUpgrade = async () => {
|
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
};
|
|
|
|
afterEach(() => {
|
|
delete (globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST;
|
|
resetClient();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("markDisabled persists disabled state and drains prior events without emitting an opt-out event", async () => {
|
|
track("toolserver:start", {});
|
|
const client = posthogMock.instances[0]!;
|
|
|
|
client.shutdown.mockImplementation(async () => {
|
|
expect(isEnabled()).toBe(false);
|
|
});
|
|
|
|
await markDisabled();
|
|
|
|
expect(posthogMock.instances).toHaveLength(1);
|
|
expect(client.capture).toHaveBeenCalledTimes(1);
|
|
expect(client.capture).toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "toolserver:start" })
|
|
);
|
|
expect(client.capture).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "telemetry:opt_out" })
|
|
);
|
|
expect(client.flush).not.toHaveBeenCalled();
|
|
expect(client.shutdown).toHaveBeenCalledTimes(1);
|
|
expect(isEnabled()).toBe(false);
|
|
});
|
|
|
|
it("does not provision the anon-id file when the PostHog key is unusable", () => {
|
|
// An intentionally-disabled/empty key means nothing can ever transmit, so
|
|
// track() must not write a persistent identifier to the user's disk.
|
|
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "";
|
|
resetClient();
|
|
|
|
track("toolserver:start", {});
|
|
|
|
expect(posthogMock.instances).toHaveLength(0);
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
});
|
|
|
|
it("track queues without flushing so command shutdown drains later", async () => {
|
|
track("toolserver:start", {});
|
|
track("toolserver:stop", {
|
|
reason: "signal",
|
|
uptime_ms: 1,
|
|
total_tool_calls: 0,
|
|
});
|
|
|
|
const client = posthogMock.instances[0]!;
|
|
|
|
expect(posthogMock.instances).toHaveLength(1);
|
|
expect(client.capture).toHaveBeenCalledTimes(2);
|
|
expect(client.capture).toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "toolserver:stop" })
|
|
);
|
|
expect(client.flush).not.toHaveBeenCalled();
|
|
expect(client.shutdown).not.toHaveBeenCalled();
|
|
expect(client.opts).toEqual(expect.objectContaining({ flushAt: 20, flushInterval: 10_000 }));
|
|
});
|
|
|
|
it("uses the host fingerprint verbatim as the distinctId", () => {
|
|
// The fingerprint module is stubbed (top of file) to a fixed 64-hex value,
|
|
// so the id is that fingerprint rather than a random v4.
|
|
track("toolserver:start", {});
|
|
|
|
const client = posthogMock.instances[0]!;
|
|
expect(client.capture).toHaveBeenCalledTimes(1);
|
|
const { distinctId } = client.capture.mock.calls[0]![0] as { distinctId: string };
|
|
// The distinctId is the fingerprint hash itself, not a random v4 UUID.
|
|
expect(distinctId).toBe("f".repeat(64));
|
|
// Persisted to disk as the migrated id.
|
|
expect(status().anonIdPrefix).toBe(distinctId.slice(0, 8));
|
|
// Migration is local-only: no alias/$identify event is ever emitted.
|
|
expect(client.capture).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "$identify" })
|
|
);
|
|
expect(client.capture).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "$create_alias" })
|
|
);
|
|
});
|
|
|
|
it("self-heals a fallback id to the fingerprint via the background upgrade wired into track()", async () => {
|
|
// Regression guard for the reviewer's C2: a legacy random id on disk. The
|
|
// first event emits under it (no blocking migrate), then buildPayload's
|
|
// scheduleFingerprintUpgrade (fed the async resolver, mocked to the
|
|
// fingerprint) migrates the on-disk id so subsequent events converge. If the
|
|
// scheduleFingerprintUpgrade call in buildPayload were removed, the on-disk
|
|
// id would never migrate and the second event would still be the legacy id.
|
|
const LEGACY = "11111111-1111-4111-8111-111111111111";
|
|
const FP = "f".repeat(64);
|
|
fs.mkdirSync(path.join(tmp(), ".argent"), { recursive: true });
|
|
fs.writeFileSync(idFile(), LEGACY, { mode: 0o600 });
|
|
|
|
track("toolserver:start", {});
|
|
const client = posthogMock.instances[0]!;
|
|
expect((client.capture.mock.calls[0]![0] as { distinctId: string }).distinctId).toBe(LEGACY);
|
|
|
|
await flushUpgrade();
|
|
expect(readId()).toBe(FP); // background upgrade migrated the file
|
|
|
|
track("toolserver:start", {});
|
|
expect((client.capture.mock.calls[1]![0] as { distinctId: string }).distinctId).toBe(FP);
|
|
});
|
|
|
|
it("warmTelemetryIdentity establishes the fingerprint id off the hot path", async () => {
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
await warmTelemetryIdentity();
|
|
// The async resolver (mocked) yields the fingerprint, persisted before any event.
|
|
expect(readId()).toBe("f".repeat(64));
|
|
// A subsequent event then finds it on disk (no truly-fresh sync resolve).
|
|
track("toolserver:start", {});
|
|
const client = posthogMock.instances[0]!;
|
|
expect((client.capture.mock.calls[0]![0] as { distinctId: string }).distinctId).toBe(
|
|
"f".repeat(64)
|
|
);
|
|
});
|
|
|
|
it("warmTelemetryIdentity mints no identity when telemetry is disabled", async () => {
|
|
writeConsentFlag(false);
|
|
_resetConsentCacheForTest();
|
|
expect(isEnabled()).toBe(false);
|
|
await warmTelemetryIdentity();
|
|
// A disabled machine must not have a persistent identifier provisioned.
|
|
expect(readId()).toBeNull();
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
});
|
|
|
|
it("warmTelemetryIdentity provisions no identity when the PostHog key is unusable", async () => {
|
|
// Consent-enabled but an unusable key: like track()/buildPayload, warm-up must
|
|
// resolve the client first and bail before spawning the fingerprint binary or
|
|
// writing a durable per-machine id for events that can never be transmitted.
|
|
// The async resolver is mocked to a fingerprint, so without the getClient()
|
|
// gate warmIdentity would persist "f".repeat(64); asserting no id proves the
|
|
// short-circuit.
|
|
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "";
|
|
resetClient();
|
|
_resetConsentCacheForTest();
|
|
expect(isEnabled()).toBe(true);
|
|
|
|
await warmTelemetryIdentity();
|
|
|
|
expect(readId()).toBeNull();
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
});
|
|
|
|
// The SHORT-LIVED-CLI (installer) counterpart of warmTelemetryIdentity, wired
|
|
// into `argent init`/`update` so the very first event (cli_init_start) carries
|
|
// the stable fingerprint instead of a random fallback the background upgrade
|
|
// would only migrate to afterward.
|
|
it("warmTelemetryIdentitySync migrates a legacy fallback to the fingerprint before the first event (the cli_init_start fix)", () => {
|
|
const LEGACY = "11111111-1111-4111-8111-111111111111";
|
|
const FP = "f".repeat(64);
|
|
fs.mkdirSync(path.join(tmp(), ".argent"), { recursive: true });
|
|
fs.writeFileSync(idFile(), LEGACY, { mode: 0o600 });
|
|
|
|
// Without the sync warm, track() would serve the legacy fallback (hot-path
|
|
// contract). The warm forces the resolve+migrate up front...
|
|
warmTelemetryIdentitySync();
|
|
expect(readId()).toBe(FP);
|
|
|
|
// ...so the first tracked event already carries the fingerprint.
|
|
track("installation:cli_init_start", {
|
|
package_manager: "npm",
|
|
is_non_interactive: false,
|
|
});
|
|
const client = posthogMock.instances[0]!;
|
|
expect((client.capture.mock.calls[0]![0] as { distinctId: string }).distinctId).toBe(FP);
|
|
});
|
|
|
|
it("warmTelemetryIdentitySync mints no identity when telemetry is disabled", () => {
|
|
writeConsentFlag(false);
|
|
_resetConsentCacheForTest();
|
|
expect(isEnabled()).toBe(false);
|
|
warmTelemetryIdentitySync();
|
|
expect(readId()).toBeNull();
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
});
|
|
|
|
it("warmTelemetryIdentitySync provisions no identity when the PostHog key is unusable", () => {
|
|
(globalThis as Record<string, unknown>).__ARGENT_POSTHOG_KEY_TEST = "";
|
|
resetClient();
|
|
_resetConsentCacheForTest();
|
|
expect(isEnabled()).toBe(true);
|
|
warmTelemetryIdentitySync();
|
|
expect(readId()).toBeNull();
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
});
|
|
|
|
it("captures events in CI and annotates payloads with is_ci", () => {
|
|
const restore = snapshotEnv(["CI"]);
|
|
try {
|
|
process.env.CI = "1";
|
|
|
|
track("toolserver:start", {});
|
|
|
|
const client = posthogMock.instances[0]!;
|
|
expect(client.capture).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
event: "toolserver:start",
|
|
properties: expect.objectContaining({ is_ci: true }),
|
|
})
|
|
);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("shutdown drains the constructed client", async () => {
|
|
track("toolserver:start", {});
|
|
track("toolserver:stop", {
|
|
reason: "signal",
|
|
uptime_ms: 1,
|
|
total_tool_calls: 0,
|
|
});
|
|
|
|
const client = posthogMock.instances[0]!;
|
|
|
|
await shutdown();
|
|
|
|
expect(posthogMock.instances).toHaveLength(1);
|
|
expect(client.shutdown).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resetLocalTelemetryState removes local state without a delete-person event and leaves consent untouched", async () => {
|
|
track("toolserver:start", {});
|
|
expect(status().hasAnonIdOnDisk).toBe(true);
|
|
|
|
const result = await resetLocalTelemetryState();
|
|
const client = posthogMock.instances[0]!;
|
|
|
|
expect(posthogMock.instances).toHaveLength(1);
|
|
expect(client.capture).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "$delete_person" })
|
|
);
|
|
expect(client.flush).not.toHaveBeenCalled();
|
|
expect(client.shutdown).not.toHaveBeenCalled();
|
|
expect(result.localIdRemoved).toBe(true);
|
|
expect(result.noticeReset).toBe(true);
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
// Consent is deliberately left untouched — a lasting opt-out is `markDisabled()`.
|
|
expect(isEnabled()).toBe(true);
|
|
});
|
|
|
|
it("removes the local telemetry id file but the deterministic id re-derives on the next event while consent stays enabled", async () => {
|
|
fs.unlinkSync(configFilePath());
|
|
_resetConsentCacheForTest();
|
|
track("toolserver:start", {});
|
|
const derivedId = readId();
|
|
|
|
const result = await resetLocalTelemetryState();
|
|
|
|
const client = posthogMock.instances[0]!;
|
|
expect(client.capture).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ event: "$delete_person" })
|
|
);
|
|
expect(result.localIdRemoved).toBe(true);
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
// No config file is created just to clear a marker that was never set.
|
|
expect(fs.existsSync(configFilePath())).toBe(false);
|
|
expect(isEnabled()).toBe(true);
|
|
|
|
// Deleting the id file with consent still enabled is a LOCAL removal, not a
|
|
// permanent erasure: the fingerprint-derived id is re-created identically on
|
|
// the next tracked event. A genuine reset comes from the opt-out path
|
|
// (`markDisabled()` / `argent telemetry disable`).
|
|
track("toolserver:start", {});
|
|
expect(status().hasAnonIdOnDisk).toBe(true);
|
|
expect(readId()).toBe(derivedId);
|
|
});
|
|
|
|
it("leaves an explicit opt-out untouched", async () => {
|
|
fs.writeFileSync(configFilePath(), JSON.stringify({ telemetry: { enabled: false } }) + "\n");
|
|
_resetConsentCacheForTest();
|
|
|
|
await resetLocalTelemetryState();
|
|
|
|
expect(getConsentState({}).enabled).toBe(false);
|
|
expect(getConsentState({}).source.source).toBe("config_file");
|
|
});
|
|
|
|
it("leaves an explicit opt-in untouched", async () => {
|
|
markEnabled();
|
|
|
|
await resetLocalTelemetryState();
|
|
|
|
expect(getConsentState({}).enabled).toBe(true);
|
|
expect(getConsentState({}).source.source).toBe("config_file");
|
|
});
|
|
|
|
it("removes the local telemetry id", async () => {
|
|
track("toolserver:start", {});
|
|
expect(status().hasAnonIdOnDisk).toBe(true);
|
|
|
|
const result = await resetLocalTelemetryState();
|
|
|
|
expect(result.localIdRemoved).toBe(true);
|
|
expect(status().hasAnonIdOnDisk).toBe(false);
|
|
});
|
|
});
|