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

105 lines
3.4 KiB
TypeScript

import * as fs from "node:fs";
import { describe, expect, it } from "vitest";
import { scopeHome, snapshotEnv } from "./helpers.js";
import { writeConsentFlag, _resetConsentCacheForTest } from "../src/consent.js";
import {
FIRST_RUN_NOTICE,
TELEMETRY_OPT_OUT_COMMAND,
TELEMETRY_DETAILS_URL,
hasShownFirstRunNotice,
markFirstRunNoticeShown,
resetFirstRunNotice,
shouldShowFirstRunNotice,
} from "../src/notice.js";
import { resetLocalTelemetryState } from "../src/uninstall-reset.js";
import { configFilePath } from "../src/paths.js";
describe("first-run notice", () => {
scopeHome();
const restoreEnv = () => snapshotEnv(["DO_NOT_TRACK", "ARGENT_TELEMETRY"]);
function readConfig(): {
telemetry?: { enabled?: boolean };
notices?: { first_run_shown?: boolean };
} {
return JSON.parse(fs.readFileSync(configFilePath(), "utf8"));
}
it("has not been shown on a fresh install (no config file)", () => {
expect(hasShownFirstRunNotice()).toBe(false);
});
it("reads true once the marker is persisted", () => {
markFirstRunNoticeShown();
expect(hasShownFirstRunNotice()).toBe(true);
});
it("preserves the telemetry consent flag when marking the notice shown", () => {
writeConsentFlag(false);
markFirstRunNoticeShown();
const config = readConfig();
expect(config.telemetry?.enabled).toBe(false);
expect(config.notices?.first_run_shown).toBe(true);
});
it("should show when telemetry is enabled and the notice is unseen", () => {
const restore = restoreEnv();
try {
delete process.env.DO_NOT_TRACK;
delete process.env.ARGENT_TELEMETRY;
_resetConsentCacheForTest();
expect(shouldShowFirstRunNotice()).toBe(true);
markFirstRunNoticeShown();
expect(shouldShowFirstRunNotice()).toBe(false);
} finally {
restore();
}
});
it("should not show when telemetry is disabled, and does not mark it shown", () => {
const restore = restoreEnv();
try {
process.env.ARGENT_TELEMETRY = "0";
_resetConsentCacheForTest();
expect(shouldShowFirstRunNotice()).toBe(false);
expect(hasShownFirstRunNotice()).toBe(false);
} finally {
restore();
}
});
describe("resetFirstRunNotice", () => {
it("clears the marker so the notice shows again on reinstall", () => {
markFirstRunNoticeShown();
expect(hasShownFirstRunNotice()).toBe(true);
resetFirstRunNotice();
expect(hasShownFirstRunNotice()).toBe(false);
});
it("leaves a persisted opt-out untouched so it survives a reinstall", () => {
writeConsentFlag(false);
markFirstRunNoticeShown();
resetFirstRunNotice();
const config = readConfig();
expect(config.notices?.first_run_shown).toBeUndefined();
expect(config.telemetry?.enabled).toBe(false);
});
it("is a no-op when nothing was recorded (creates no config file)", () => {
resetFirstRunNotice();
expect(fs.existsSync(configFilePath())).toBe(false);
});
it("is run by resetLocalTelemetryState() so uninstall resets the marker", async () => {
markFirstRunNoticeShown();
await resetLocalTelemetryState();
expect(hasShownFirstRunNotice()).toBe(false);
});
});
it("exposes the notice copy with the opt-out command and details URL", () => {
expect(FIRST_RUN_NOTICE).toContain(TELEMETRY_OPT_OUT_COMMAND);
expect(FIRST_RUN_NOTICE).toContain(TELEMETRY_DETAILS_URL);
});
});