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.
105 lines
3.4 KiB
TypeScript
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);
|
|
});
|
|
});
|