Skip to content

feat: record adoption dimensions on every telemetry event - #326

Merged
thecodedrift merged 3 commits into
mainfrom
feat/telemetry-adoption-dimensions
Sep 9, 2026
Merged

feat: record adoption dimensions on every telemetry event#326
thecodedrift merged 3 commits into
mainfrom
feat/telemetry-adoption-dimensions

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Why

cli_run counts invocations. It cannot count workspaces, and nearly every adoption metric worth reporting is per-workspace.

The finest identity we carry today is cli, the anonymous UUID in $XDG_CONFIG_HOME/taskless/anonymous_id. It is per config directory, so one developer across five repositories is one cli, and a fresh CI container regenerates the file every job. Both distortions push the same way — they inflate breadth and deflate depth — and neither can be repaired after the fact, because no property stored on past events separates a CI run from a human one.

Retention cannot be backfilled either. A four-week number needs four weeks of a property that already exists, which is why this change is scoped to the dimensions and defers the analysis built on them.

What changes

Six super-properties on every identify and every capture, resolved once per process:

property value
workspaceId SHA-256 of the git top level, falling back to cwd
repositoryId SHA-256 of {host}/{owner}/{repo}, host-agnostic, [unknown] when unresolvable
envOS process.platform
ci whether CI holds a positive value
ciProvider the provider, [unknown] on CI, [none] off it
languageStack languages evidenced by manifests at the workspace root

Plus ruleCount on cli_check_completed, so a scan that loaded no rules is distinguishable from one that loaded rules and found nothing.

No command changes behaviour, no output changes shape.

Four decisions worth reviewing

workspaceId anchors on the git top level, not cwd. check from packages/cli and check from the repository root are one workspace, and they only report the same id if the path resolves upward first. Anchoring on cwd would make every subdirectory its own workspace and inflate the count without bound. A test asserts that equality directly.

repositoryId does NOT reuse canonicalizeGitHubUrl. That function throws UNSUPPORTED_REMOTE_HOST deliberately — it is the capability boundary on remote rule generation — so teaching it other hosts would soften a refusal doing real work elsewhere. A second, host-agnostic parser sits beside it. A GitLab or self-hosted repository gets a real repositoryId with ghOwner: [unknown], which is the case a GitHub-shaped implementation gets wrong silently, so it has its own test.

ghOwner stays unhashed; repositoryId is hashed. Different reasons. An owner is public identity and the value is load-bearing precisely when legible: excluding a known owner from external-adoption counts needs the name. A repository name can be an unannounced product. Neither hash is a secret and the spec says so, since a remote URL is reversible by anyone who can enumerate candidates. workspaceId is the one where hashing genuinely protects — local paths carry usernames and are not enumerable.

languageStack is a root-only probe, not detectRepository. The scan is a recursive glob with manifest parsing; this runs on every invocation, including the agent fetches an agent makes repeatedly. It reads its mapping from the scan's own LANGUAGE_MARKERS so the two cannot disagree about which manifest means which language — they differ only in search scope. The cost is that a language confined to a sub-package is not seen, and a test pins that so it is not later "fixed" into a recursive walk.

One trap found while building it: LANGUAGE_MARKERS has no JavaScript or TypeScript entry (the scan derives those from package.json dependencies separately), so a probe built from the constant alone reports nothing for the stack this CLI is most used on.

Verification

  • pnpm typecheck, pnpm lint clean; pnpm test 1407 passed (was 1388).
  • Mutation-checked the two load-bearing assertions: making resolveWorkspaceRoot ignore the git top level fails the subdirectory-equality test; making canonicalRepositoryPath reject non-GitHub hosts fails 3 tests including the GitLab case.
  • Archive verified on a scratch commit, then reset. The analytics spec went 40 → 57 scenarios and 14 → 17 requirements, with comm reporting no scenario present before that is missing after, and both MODIFIED titles byte-identical. validate --strict passes on a delta that drops scenarios, so the diff is the only thing that catches it.

Deferred, with reasons in the proposal

cli_rule_verified (reverses the standing "verify rides on cli_run alone" decision), rule-resolution rate (needs stable finding identity — a materially larger privacy question), and the fact that cli_installed fires on init and so measures initialization rather than installation.

Refs #325

cli_run counts invocations and cannot count workspaces, which is what
nearly every adoption metric is actually about. The finest identity carried
today is the anonymous UUID in $XDG_CONFIG_HOME, which is per config
directory: one developer across five repositories is one id, and a fresh CI
container regenerates it every job. Both distortions inflate breadth and
deflate depth, and neither is measurable after the fact.

Adds six super-properties, resolved once per process and attached to
identify and to every capture:

  workspaceId    hash of the git top level, falling back to cwd
  repositoryId   hash of {host}/{owner}/{repo}, host-agnostic
  envOS          process.platform
  ci             whether CI holds a positive value
  ciProvider     the provider, [unknown] on CI, [none] off it
  languageStack  languages evidenced by root manifests

Plus ruleCount on cli_check_completed, so a scan that loaded no rules is
distinguishable from one that loaded rules and found nothing.

repositoryId does NOT reuse canonicalizeGitHubUrl. That function throws
UNSUPPORTED_REMOTE_HOST deliberately as the capability boundary on remote
rule generation, and teaching it other hosts would soften a refusal doing
real work elsewhere. A second, host-agnostic parser sits beside it, so a
GitLab or self-hosted repository is counted as the codebase it is.

ghOwner stays unhashed: an owner is public identity and the value is
load-bearing precisely when legible. repositoryId is hashed because a
repository name can be an unannounced product. Neither hash is a secret and
the spec says so, since a remote URL is reversible by anyone who can
enumerate candidates. workspaceId is the one where hashing protects, local
paths carrying usernames and not being enumerable.

languageStack comes from a bounded root-only probe rather than
detectRepository, which is a recursive glob with manifest parsing and would
run on every invocation including agent fetches. It reads its mapping from
the scan's own LANGUAGE_MARKERS so the two cannot disagree about which
manifest means which language; they differ only in search scope. The cost
is that a language confined to a sub-package is not seen, which a test pins
so it is not "fixed" into a recursive walk.

Every resolution falls back to a sentinel rather than failing, and the
telemetry opt-out still short-circuits before any of it runs.
Single-PR delivery shape, so the spec, the implementation and the archive
land together.

Verified before archiving, on a scratch commit that was then reset: the
standing analytics spec went from 40 scenarios to 57 and from 14
requirements to 17, with `comm` reporting NO scenario present before that
is missing after. Both MODIFIED requirements kept their titles byte for
byte, including the escaped underscore in `CLI events use cli\_ prefix` —
a renamed title applies nothing at all and `validate --strict` passes
either way, so the diff is the only thing that catches it.
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 5m 45s —— View job


Code Review

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no prior review threads, reviews, or top-level comments for this PR (only the @claude /review trigger itself), so this is the first review of this PR — every finding below is [New].

  • Read .prior-review.json
  • Fetch PR diff (gh pr diff 326) and metadata
  • Read changed files in full, plus surrounding context (check.ts, git-remote.ts, telemetry.ts, detect/scan.ts, telemetry.test.ts, rules/engines.ts, rules/dispatch.ts, commands/index.ts, telemetry-run.ts)
  • 8-angle candidate pass + verification
  • Post findings

Overall this is a well-scoped, well-documented change — the reasoning in the PR body for each design decision (host-agnostic repository parser kept separate from canonicalizeGitHubUrl, unhashed ghOwner vs hashed repositoryId, root-only languageStack) holds up against the code. Findings below are all [New], ranked most-severe first.


1. canonicalRepositoryPath can collapse two distinct repositories into one repositoryId (packages/cli/src/util/git-remote.ts:311-320)
The function keeps only the first and last path segments (owner = segments[0], repository = segments.at(-1)), discarding everything in between with the stated rationale that a renamed subgroup shouldn't change the identity. But this also means gitlab.com/acme/team1/api and gitlab.com/acme/team2/api — two genuinely different repositories that happen to share the same owner and leaf name — both hash to gitlab.com/acme/api. That directly undermines the property's stated purpose ("how many distinct codebases"), by undercounting in exactly the self-hosted/subgroup case the PR calls out as the one a GitHub-shaped implementation gets wrong. Worth at least a comment acknowledging the trade-off explicitly (it's currently only reasoned about in one direction), since today nothing hints that two different repos can collide.

2. New sequential git spawns added to every telemetry-emitting command invocation (packages/cli/src/telemetry.ts:166-202)
resolveScaffoldVersion, resolveRepositoryContext (git remote get-url origin), and the new resolveAdoptionDimensions (git rev-parse --show-toplevel + git config --get-regexp via resolveRepositoryPath) are all independent of each other, yet are awaited one after another. This PR adds two more sequential child-process spawns to what was already a sequential chain, and getTelemetry() runs on essentially every CLI invocation (see cli_run in index.ts). These are prime candidates for Promise.all, and resolveWorkspaceRoot/resolveRepositoryId inside resolveAdoptionDimensions itself (adoption-dimensions.ts:1078-1086) could also run concurrently rather than sequentially.

3. The new adoption dimensions are unit-tested in isolation but never verified as actually wired into identify/capture (packages/cli/test/telemetry.test.ts)
adoption-dimensions.test.ts thoroughly covers resolveWorkspaceId, resolveRepositoryId, canonicalRepositoryPath, CI detection, and resolveLanguageStack — but telemetry.test.ts (the file that mocks posthog-node and asserts on identify/capture property payloads, the same pattern already used for ghOwner, cliVersion, scaffoldVersion) has zero references to workspaceId, repositoryId, envOS, ci, ciProvider, or languageStack. The PR's headline claim — "six super-properties on every identify and every capture" — is exactly the ...dimensions spread at telemetry.ts:218 and :243, and that spread has no test coverage; a typo or dropped spread there would pass the full suite.

4. resolveAdoptionDimensions's "no cwd" behavior contradicts its own comment (packages/cli/src/telemetry.ts:200-202)
The comment says: "No cwd is treated as no workspace, matching resolveScaffoldVersion and ghOwner above." But the code is resolveAdoptionDimensions(cwd ?? process.cwd()) — when cwd is absent it does not short-circuit to a sentinel the way scaffoldVersion (→ 0) and ghOwner (→ [unknown]) do; it actually resolves real git/filesystem state from process.cwd(). Today every real call site passes a cwd (resolveCwd() in telemetry-run.ts always returns a string), so this is currently unreachable in production, but the comment is actively misleading about what the fallback does, and it's a latent trap for the next caller that omits cwd.

5. Duplicated JSDoc block above LANGUAGE_MARKERS (packages/cli/src/detect/scan.ts:230-245)
The diff leaves the original explanatory comment ("Languages inferred from the presence of a manifest or marker file…") in place and stacks a second new JSDoc block directly above the declaration ("Exported so telemetry's root-only languageStack probe…"). Most tooling only surfaces the comment immediately preceding the symbol, so the first block is now effectively orphaned. Worth merging into one doc comment.

6. canonicalRepositoryPath's remote-parsing preamble is copy-pasted from canonicalOwnerUrl (packages/cli/src/util/git-remote.ts:161-188 vs :282-323)
The scp-like-SSH regex, the URL-based branch, and the host lowercasing/www. stripping are duplicated verbatim between the two functions (only the final segment-selection differs: one owner segment vs. owner+repo). A shared parseRemoteHostAndPath(raw): { host, path } | null helper would let both functions build their own output from one parse, consistent with the repo's own reuse conventions.

7. (Minor) check.ts:287 adds a third read of the vale rules directory per check invocation — hasValeRules(cwd) already runs once for the noRuleFiles gate (check.ts ~line 215) and again inside runEngines's dispatch (dispatch.ts:181), and now listRuleIds(cwd, "vale") reads it a third time just for ruleCount. Low cost (a single readdir), but avoidable if valeRuleIds were threaded through from an earlier call.

Branch: feat/telemetry-adoption-dimensions

Five findings from the review on #326, all verified against the source
before acting.

repositoryId no longer collapses nested group paths. Keeping only the
first and last segments made `acme/team1/api` and `acme/team2/api` one
identity, silently undercounting distinct codebases in exactly the
nested-group case the parser exists to serve. The original reasoning
(a renamed group should not split one repository into two) was sound in
one direction and missed the other; a rename is the milder failure and
the visible one, and it is the same class of event as a GitHub transfer,
which already changes the identity here.

The dimensions are now tested where they are ATTACHED, not only where
they are resolved. The wiring is two `...dimensions` spreads and had no
coverage, so dropping one would have left every resolver test passing
while no event carried a single new property. Confirmed by doing exactly
that: removing the capture spread fails two of the new tests.

resolveAdoptionDimensions handles an absent cwd itself instead of
defaulting to process.cwd(), which contradicted the comment claiming it
behaved like resolveScaffoldVersion and ghOwner — it resolved live git
state instead. Only the three cwd-dependent dimensions sentinel; envOS,
ci and ciProvider are properties of the process and stay real.

scaffoldVersion, repositoryContext and the dimensions now resolve
concurrently. They are independent, each spawns a subprocess or reads the
filesystem, and getTelemetry runs on essentially every invocation.

The JSDoc above LANGUAGE_MARKERS is one block again; the export note had
been stacked above the original, orphaning it from the symbol.

Declined: extracting a shared remote parser from canonicalOwnerUrl, which
carries an explicit warning that it is a verbatim port compared with ===
and must stay byte-for-byte identical. And threading valeRuleIds down to
save one readdir, which would couple the noRuleFiles gate's error
semantics to a counting concern.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 5m 45s — Code Review…"
#326 (comment)

All seven findings verified against the source before acting. Five fixed in cf5bfa8, two declined with reasons.

1. repositoryId collision — fixed. You were right that I reasoned in one direction only. The whole remote path is now kept, so acme/team1/api and acme/team2/api stay distinct. A renamed group starting a new id is the milder failure and the visible one, and it is the same class of event as a GitHub transfer, which already changes the identity here and was accepted. Pinned by a test asserting the two do not collapse.

3. Untested wiring — fixed, and this was the important one. telemetry.test.ts now asserts every dimension reaches both identify and capture, that an event's own properties survive the spread, and that values resolve rather than keys merely existing. Confirmed by removing the capture spread: two of the new tests fail. You were exactly right that the headline claim had no coverage.

4. Comment contradicted the code — fixed. resolveAdoptionDimensions now handles an absent cwd itself instead of defaulting to process.cwd(). Only the three cwd-dependent dimensions sentinel; envOS, ci and ciProvider are properties of the process, so sentinelling them would discard a known answer for the sake of looking consistent.

2. Sequential spawns — fixed. scaffoldVersion, repositoryContext and the dimensions now resolve concurrently, as do resolveWorkspaceRoot and resolveRepositoryId inside. languageStack stays sequential since it probes the workspace root and cannot start until that root is known.

5. Orphaned JSDoc — fixed. One block again.

6. Shared remote parser — declined. canonicalOwnerUrl carries an explicit warning that it is a verbatim port of the server's implementation, compared with ===, and must stay byte-for-byte identical. Refactoring its body to serve a second caller trades a real invariant for modest deduplication.

7. Third readdir — declined, on maintainability rather than cost. Threading valeRuleIds down from the noRuleFiles gate would couple that gate's deliberately narrow error semantics (only absence is swallowed, so an unreadable directory fails loudly instead of silently disabling Vale) to a telemetry counting concern.

Also tightened the spec's {host}/{owner}/{repo} wording to {host}/{owner}/…/{repo}, which no longer described the nested case after the fix.

1412 tests pass; typecheck and lint clean.

— AI Coding Agent

@thecodedrift
thecodedrift merged commit 3df3fe8 into main Sep 9, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the feat/telemetry-adoption-dimensions branch September 9, 2026 20:12
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.

1 participant