Files
Hubert Gancarczyk 3393d22edd feat(telemetry): derive distinct_id from a stable host fingerprint (#444)
## 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.
2026-07-07 16:28:29 +02:00

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);
});
});