Skip to content

feat(cli): classify identity persistence on every telemetry event - #3065

Merged
WaterrrForever merged 2 commits into
mainfrom
feat/identity-persistence-telemetry
Aug 6, 2026
Merged

feat(cli): classify identity persistence on every telemetry event#3065
WaterrrForever merged 2 commits into
mainfrom
feat/identity-persistence-telemetry

Conversation

@WaterrrForever

Copy link
Copy Markdown
Collaborator

Summary

Implements the CLI half of the identity-churn remediation discussed on the leadership dashboard: since Jul 30, ephemeral/isolated-HOME workloads have been minting a fresh anonymousId on every run — one machine produced 2,956 rotating render identities (94.4% seen on exactly one render command), inflating acquisition and diluting per-install skills penetration while its render events look like real product usage. Similar high-churn candidates exist in at least three other locations, so a fingerprint denylist is remediation, not policy. The durable fix is to let PostHog tell trustworthy identities apart, per the review guidance on PR-adjacent threads: emit identity_persistence = durable | process_only | unknown, the config/state write outcome, and a per-run id.

What every event now carries

  • identity_persistence — can this process's anonymousId be trusted to survive to the next run?
    • durable: the id was loaded from a preexisting config file — it has already survived a process boundary.
    • unknown: minted this run and the write landed. An ephemeral HOME looks identical to a genuine first run from inside one process, so this is deliberately never promoted to durable.
    • process_only: minted this run and the write failed (read-only mount, full disk) — the id dies with the process.
    • The verdict is sticky per process: a fresh install re-reading its own just-written file cannot self-promote to durable.
  • config_write_outcomeok | ok_unmirrored | failed for the identity-establishing write (ok_unmirrored: config.json landed, install-state mirror didn't). Absent when the id came from disk.
  • invocation_id — random uuid per CLI process. Groups one invocation's events even when the install identity is untrustworthy; unlike run_id it needs no HYPERFRAMES_RUN_ID plumbing.

How the churn workloads land in this taxonomy

Every run of an ephemeral-HOME workload is a fresh mint → unknown (or process_only when the FS is read-only), never durable. A legit new user is unknown on run 1 and durable from run 2 on. Install-grain metrics can then count durable identities only, and a daily churn monitor can alert on the unknown share.

Test plan

  • 5 new tests in config.test.ts: fresh-mint→unknown/ok, preexisting→durable/no-outcome, failed-write→process_only/failed, no self-promotion after readConfigFresh re-reads the process's own write, corrupt-config recovery classified by write outcome.
  • Full packages/cli suite: 2,468 passed; build, oxlint, oxfmt clean.

Follow-ups (not in this PR)

  • PostHog: switch install-grain tiles to durable-identity counting once this ships and propagates; daily churn monitor on the unknown share.
  • The render-path-only churn observation (same fingerprint's transcribe keeps a stable id while render rotates) suggests the workload isolates HOME for render invocations specifically; invocation_id + config_write_outcome should make that mechanism visible in the data.

Install-grain metrics currently trust every anonymousId equally, but
ephemeral/isolated-HOME workloads mint a fresh id per run — one machine
produced 2,956 rotating render identities since Jul 30 (94.4% seen on a
single render command), inflating acquisition and diluting per-install
penetration while looking like real product usage.

Every event now carries:

- identity_persistence: durable (id loaded from a preexisting config —
  proven to survive a process boundary) | unknown (minted+persisted this
  run; an ephemeral HOME is indistinguishable from a genuine first run
  from inside one process) | process_only (persist failed). Sticky per
  process so a fresh install re-reading its own write cannot self-promote.
- config_write_outcome: ok | ok_unmirrored | failed for the identity-
  establishing write; absent when the id came from disk.
- invocation_id: random uuid per CLI process, so one invocation's events
  group even when the install identity is untrustworthy (unlike run_id,
  which needs an orchestrator to set HYPERFRAMES_RUN_ID).

Install metrics can then count only durable identities, and a daily
churn monitor can alert on the unknown share.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: identity-persistence telemetry

This is a faithful implementation of the telemetry spec James laid out in the thread — three-way identity classification, config write outcome, always-on invocation id. Traced every code path; the design is clean.

What I verified

Stickiness invariant — the anti-self-promotion guard. The core insight: classifyIdentity() uses a first-write-wins guard (if (identityPersistence !== undefined) return). Traced all three call sites through readConfig():

Path Classification Correct?
No config file → mintAndCacheConfig() unknown or process_only (by write outcome)
Existing file parses → materializeConfig durable
Existing file corrupt → catch recovery mint unknown or process_only (by write outcome)

The self-promotion attack vector — readConfigFresh() clearing the cache, re-entering readConfig(), finding the just-written file, hitting the existing-file path — is blocked by the guard. Test on line 751 covers this explicitly. Solid.

writeOutcomeOf mapping. ConfigWriteResultIdentityWriteOutcome:

  • ok: false"failed"
  • ok: true, mirrored: undefined"ok" ✅ (undefined === false is false)
  • ok: true, mirrored: false"ok_unmirrored"

getIdentityPersistence() forces classification. Calls readConfig() before returning, so it's impossible to read the property before classification has run. The ?? "unknown" fallback is unreachable in practice (every readConfig() exit calls classifyIdentity), but correct as a defensive default.

invocationId is simple and correct — ??= lazy init with randomUUID(), no state to leak across processes.

Test coverage. 5 tests cover all three persistence classes, the self-promotion guard, and corrupt-config recovery. Each test uses vi.resetModules() to get fresh module-level state — proper isolation for sticky-per-process semantics. The ok_unmirrored write outcome isn't directly tested (requires mocking partial mirror failure), but the writeOutcomeOf logic is trivial enough that the other paths cover it.

Mock additions. client.test.ts and client.postureRefresh.test.ts both add getIdentityPersistence: () => "durable" and getIdentityWriteOutcome: () => undefined to their config mocks — sensible defaults that don't distort existing test behavior ("durable" + no write outcome = the happy-path classification for an established install).

SSOT check

  • Classification logic lives in exactly one place (classifyIdentity). Three call sites each provide the correct value for their path — no duplicated decisions.
  • writeOutcomeOf is the single translator from write-result shape to telemetry value.
  • identity_persistence and config_write_outcome are emitted from one location in trackEvent — no scattered property injection.

Observations (non-blocking)

  1. Corrupt-config catch path doesn't cache. The catch block at line 720-724 returns without setting cachedConfig, meaning a subsequent readConfig() call re-reads and re-parses the (now-recovered) file. The stickiness guard prevents re-classification, so this is safe — but it's a minor perf asymmetry vs. the other paths that all cache.

  2. readConfig() called 4× per event emission. trackEvent hits readConfig() via shouldTrack(), readConfig().predecessorFound, readConfig().stateFileCorrupt, and then getIdentityPersistence() / getIdentityWriteOutcome() each call it again. All cache hits after the first, so negligible cost — just noting the pattern.

CI is green across the board. No blocking concerns. Clean implementation of the spec.

— Miga

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on one finding. Terminal-green at c244d647e: all 8 required contexts pass (Build, Test, Test: runtime contract, Typecheck, regression, Semantic PR title, Render on windows-latest, Tests on windows-latest), enumerated from the branch ruleset with cancelled runs filtered out. The blocker is in the classifier, not CI.

Miga covered the stickiness invariant and the SSOT shape, so I will skip those. One claim from that review I have to contradict, though: the three readConfig() exit paths do not all classify correctly.

Blocking: the durable branch can classify an id that was minted this run

materializeConfig mints a replacement id whenever the parsed config does not carry a usable one:

// config.ts:674
anonymousId: parsed.anonymousId || randomUUID(),

and readConfig's existing-file branch classifies unconditionally right after it:

// config.ts:693, then :698
const config = materializeConfig(parsed);
...
classifyIdentity("durable");

So a config.json that exists and parses but carries no usable anonymousId reports identity_persistence: "durable" for an id that was minted this process and has survived nothing.

It is worse than a one-run mislabel, because that id usually never reaches disk. The only write on this branch is the bucket-seed backfill at :703-710; when bucketSeed is already present, from config.json or from install-state via guardedFields at :666, the branch falls through to cachedConfig = config at :712 with no write at all. That install then mints a fresh id on every run, persists none of them, and reports durable every time. It is the exact churn signature this PR exists to expose, wearing the one label that is supposed to mean "already survived a process boundary."

On reachability, being straight about it: the CLI cannot produce this config itself, since anonymousId is required on HyperframesConfig and writeConfigWithResult stringifies the whole object. So the population is hand-edited, provisioned, or image-baked configs, plus anonymousId: "". I cannot size it from here. What makes me want it closed before merge rather than after is that it fails silently in the one direction the field is meant to be trustworthy in, and the PR's own stated next step is switching install-grain tiles to durable-only counting. Once that lands, a mislabeled install is indistinguishable from a real one in the tiles, permanently. Nothing downstream would ever surface it.

The fix is local to that one line, using a helper already in the file:

classifyIdentity(parseNonEmptyString(parsed.anonymousId) !== undefined ? "durable" : "unknown");

Worth a sixth test next to the five you added: preexisting config carrying a bucketSeed and no anonymousId, expect unknown. That is precisely the case the current "loaded from a preexisting config is durable" test cannot catch, since it supplies anonymousId: "prior-id".

Non-blocking

The corrupt-recovery swap is behavior-identical, in case it reads as a change. writeConfig(config) becoming writeConfigWithResult(config) at :721 looks like it could have dropped a warning, but writeConfig is just writeConfigWithResult(config).ok (:756-757) and neither one warns. The mint path's warnSeedBackfillFailed was always the only warning here. Nothing lost.

The contract audit on the two new config.js exports comes out clean. Four telemetry tests mock ./config.js: client.test.ts, client.postureRefresh.test.ts, canary.test.ts, events.test.ts. Only the first two run the real trackEvent (events.test.ts mocks ./client.js outright, canary.test.ts never calls it), and those two are exactly the ones this PR updates. Nothing half-landed.

getIdentityPersistence()'s ?? "unknown" fallback. writeConfigWithResult populates cachedConfig at :776, so a process that writes config before its first readConfig() leaves the verdict unclassified and the getter answers unknown for what may well be a durable install. That is the safe direction and I would leave it as is, but noting it so nobody later "fixes" it by making the fallback durable.

The taxonomy itself is the right shape: three-way with unknown as the honest middle, sticky per process, never promotable from inside a single process. Happy to re-review as soon as the durable branch checks that the id actually came off disk.

— Rames Jusso

Comment thread packages/cli/src/telemetry/config.ts Outdated
// The id was loaded from a file that predates this process — the one
// case where cross-run persistence is already proven. Sticky, so a
// fresh-install process re-reading its own write cannot self-promote.
classifyIdentity("durable");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This classifies durable unconditionally, but materializeConfig two lines up mints a replacement id when parsed.anonymousId is missing or empty (:674), and on this branch that id is only written back if bucketSeed also happens to be missing (:703-710). With a seed present the branch falls through to cachedConfig = config at :712 with no write, so such an install mints a fresh id every run, persists none, and reports durable every time.

classifyIdentity(parseNonEmptyString(parsed.anonymousId) !== undefined ? "durable" : "unknown");

See the review summary for reachability and the suggested sixth test.

… durable

Review finding: materializeConfig mints a replacement anonymousId when a
hand-edited/image-baked config lacks one. That replacement only reaches
disk when the bucket-seed backfill happens to write; with a seed present
the read path performs no write at all, so the install re-mints a fresh
id every run while the unconditional durable branch stamped each of them
with the one label durable-only counting is allowed to trust.

durable now requires parseNonEmptyString(parsed.anonymousId): a minted
replacement classifies like a fresh mint — by the backfill write outcome
when that path runs (unknown/process_only), and process_only on the
no-write path where the id provably dies with the process. Two tests pin
both shapes.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The finding is closed, and the fix is better than what I asked for.

I had suggested a single predicate on the durable branch. Splitting it by whether the bucket-seed backfill actually writes (config.ts:713-714 vs :724) is the more correct call, and I had not thought it through: on the backfill path the write persists the whole config, replacement id included, so that install really is a fresh mint in all but name and classifying it by write outcome puts it in exactly the right bucket. process_only on the no-write path at :724 is likewise stronger and more accurate than the unknown I proposed, since nothing writes on that path at all. idFromDisk uses parseNonEmptyString, which matches materializeConfig's own falsy handling for strings, so the two predicates agree on the cases that matter.

Both new tests pin the two branches rather than the one I asked for, and the seed-present case is the one that would have churned forever.

Two doc-level notes, neither one a code change

The process_only definition no longer covers all of its own uses. config.ts:554-555 still reads "minted this run and the write failed (read-only mount, full disk)", but :724 now assigns process_only when no write was attempted at all. The label is right in both cases, since both mean the id dies with this process; the definition just needs to say so.

Worth naming the split explicitly while you are in there, because it is free signal: config_write_outcome distinguishes them downstream. process_only + failed means a write was attempted and failed. process_only + absent means no write was attempted, which is the hand-edited / image-baked shape this commit added. Whoever builds the churn monitor will want to separate an unwritable FS from a config that never carried an id. The same sentence is missing from client.ts:118, which still describes the outcome as absent only when "the identity came from disk", and from the config_write_outcome bullet in the PR body.

"guaranteed" is marginally stronger than the code supports on the no-write branch. A later writeConfig in the same process persists the replacement id — incrementCommandCount at :808 is the routine one — so such an install self-heals to durable from run 2. The verdict is sticky and errs toward under-claiming durability, which is the direction I want, so leave the behavior alone. It is only the word.

Merge is yours.

— Rames Jusso

@WaterrrForever
WaterrrForever merged commit 3b65321 into main Aug 6, 2026
49 of 51 checks passed
@WaterrrForever
WaterrrForever deleted the feat/identity-persistence-telemetry branch August 6, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants