feat(sync): opt-in git attribution spans on sync push --attribution - #848
feat(sync): opt-in git attribution spans on sync push --attribution#848Enclavet wants to merge 1 commit into
sync push --attribution#848Conversation
Expose the yield session-to-commit correlation through codeburn sync so backends can join AI usage to git activity without local git hooks. - yield: export normalizeRemoteUrl (host/org/repo; credentials, ports, and .git stripped) and computeAttributionRecords, which reuses the exact repo-grouping + tightest-window attribution from computeYield (extracted into a shared buildRepoGroups) and joins in the normalized origin remote and session prLinks. - otlp: two new span types sharing the session traceId — codeburn.session.attribution (git.repo, git.pr_links, git.commit_count) and codeburn.commit (git.sha, git.in_main, git.was_reverted). Resource attribute codeburn.attribution_methodology=timestamp-window marks the attribution as inferred. - push: generic send core reused by usage and attribution batches. Dedup keys encode mutable state (inMain/wasReverted), so a state transition re-sends the updated fact while identical states dedupe via the existing sent-ledger. - cli: opt-in --attribution flag on sync push (dry-run aware); commits in repos with no network remote are never sent. AI-Origin: human
|
Intentionally made this opt-in. As the remote repo name and pr-links might be sensitive. |
iamtoruk
left a comment
There was a problem hiding this comment.
Thanks — the core of this is strong work, and the review went deep because this is the product's most privacy-sensitive surface. What held up very well under adversarial testing: the credential stripping survived 48 hostile remote forms (userinfo tokens, GitLab subgroups, scp-forms with colons in usernames, query-string tokens — zero leaks, no ReDoS), --dry-run is provably silent on the wire (zero /v1/traces POSTs against a live collector), flag-off behavior is byte-identical to main, the yield.ts extraction is behavior-preserving across 4,186 real sessions, and reusing the #660 plumbing (backoff, assertHttps, ledger) was exactly right.
Two egress bugs block the merge — both demonstrated end-to-end against a live mock collector:
1. The cwd fallback egresses unrelated (possibly confidential) repos. buildRepoGroups does identity = projectIdentity ?? cwdIdentity (src/yield.ts:390-392, 574). When a session's project path no longer resolves to a work tree (deleted/renamed dir, or sessions run outside a repo), attribution inherits whatever repo the user's shell is in at push time. Reproduced: pushing from inside a private repo emitted repo: "github.com/secret-org/nda-client-repo" plus its commit SHAs, attributed to a session that never touched that repo. In local yield this fallback is a harmless heuristic; on the sync path it's both a privacy leak and false attribution. Suggested fix: on the attribution path, drop the group (or at minimum repo + commits) whenever identity came from the cwd fallback rather than the project's own path.
2. Windows drive-letter paths defeat the "local repos are never sent" guarantee. The scp-like branch matches C: as a host (src/yield.ts:159-160): "C:/Users/alice/private/repo" → repo: "c/Users/alice/private/repo", and because repo is non-null, commits are sent — directly contradicting the doc line "Commits in repos with no network remote are never sent," with the user's local filesystem path as the emitted identity. Suggested fix: reject single-character hosts, or match /^[a-zA-Z]:[\\/]/ before the scp-like branch.
3. Tests for the above. The new test file is genuinely good (live collector, wire assertions), but it covers only 3 basic normalize cases — none of the adversarial forms, no Windows path, no --dry-run no-network assertion, no flag-off assertion. All the findings above would have been caught by that corpus.
Should-fix (non-blocking but please consider in the same pass):
git.pr_linksfrom host-emitted journal entries passes only a truthy-string check (src/parser.ts:1265upstream) — arbitrary strings of arbitrary length reach the endpoint. A shape check (URL, allowlisted hosts) + per-session cap would close it. Relatedly, PR links are sent even whenrepois null (deliberate and defensible, but the docs' "never sent" sentence reads more absolute than reality — worth reconciling).- Attribution items have no
MAX_PER_PUSH-style cap; a first--since all --attributionpush on a long history has no valve. - A CHANGELOG
## Unreleasedentry — a privacy-relevant opt-in flag belongs in release notes. - The PR body's attribute table under-declares vs the wire:
codeburn.device_id,codeburn.attribution_methodology, and commit timestamps (span start times) also ride along. Your docs/sync/README.md discloses the timestamps honestly — the PR body should match it.
Nice-to-have: case-insensitive .git strip and slash collapse for join-key stability (…/Repo.GIT and doubled slashes currently split one repo into two keys); document that only origin is read.
Happy to re-review promptly — the shape of this feature is right and the plumbing reuse makes it an easy yes once the egress edges are closed.
What
Adds an opt-in
--attributionflag tocodeburn sync pushthat sends the session→commit correlationcodeburn yieldalready computes locally, so a telemetry backend can join AI usage to git outcomes (merged / never merged / reverted) — without teams having to install per-developer git hooks.codeburn sync push --attribution codeburn sync push --attribution --dry-run # counts only, sends nothingWhy
codeburn synctoday answers how much AI was used (tokens, cost, model, project). It cannot answer where that AI output landed: which commits came out of a session, whether they shipped to main, or whether they were later reverted. Teams that want outcome metrics (AI-to-merge ratio, defect/revert rates on AI-assisted code) currently have to bolt on commit-trailer git hooks — a per-developer install with its own drift and maintenance burden.But codeburn already has the hard part.
codeburn yieldresolves each session's project to a canonical repo (monorepo subdirs and worktrees collapse viagit-common-dir), attributes commit SHAs to sessions by tightest timestamp window, and computesinMain/wasRevertedper commit. That analysis just never left the local CLI report. This PR exposes it through the existing sync channel.What gets sent (only with the flag)
Two new span types, sharing the session's traceId with the existing usage spans (
deriveTraceId(sessionId)), so receivers correlate cost and attribution with no extra key:codeburn.session.attribution— one per session with joinable evidence:ai.session_idabc123…ai.projectmy-appgit.repogithub.com/acme/widget(normalizedoriginremote)git.pr_links["https://github.com/acme/widget/pull/12"]git.commit_count2The span's start/end times are the session window itself.
codeburn.commit— one per commit attributed to a session:git.sha4f2a…git.in_maintruegit.was_revertedfalsegit.repo,ai.session_id,ai.projectA resource attribute
codeburn.attribution_methodology: timestamp-windowmarks the attribution as inferred (same self-declared heuristic ascodeburn yield), so backends can label it honestly versus declared sources like commit trailers.Design decisions
git remote get-url originis normalized tohost/org/repo: scp-like (git@host:org/repo.git),ssh://(user + port dropped), andhttps://(embedded credentials stripped — a token in an https remote must never leave the machine) all collapse to the same key. Local-only repos andfile://remotes have no server-side identity: their commits are never sent. A session in such a repo still emits a record when it carries PR links (the PR URL embeds the repo).inMain/wasReverted(and the session key hashes repo + PR links + commit states). Identical facts dedupe via the sent-ledger exactly like usage spans; a state transition (commit merges to main, or gets reverted) mints a new key and the updated fact is re-sent on the next push. Receivers should upsert by(git.repo, git.sha).$summary; a separateAttribution: N facts syncedline is printed.--attribution, behavior is byte-identical to today. With it, what leaves the machine is: normalized repo remote, commit SHAs + timestamps, PR URLs, and the merged/reverted booleans. Never code, diffs, paths, prompts, or bash commands. Documented indocs/sync/README.md.Implementation
src/yield.ts: the repo-grouping loop insidecomputeYieldis extracted intobuildRepoGroups(verbatim; grouping semantics unchanged) and shared with a newcomputeAttributionRecords(projects, range, cwd). Also exportsnormalizeRemoteUrl.computeAttributionRecordstakes already-parsed projects, sosync pushdoesn't re-parse.src/sync/otlp.ts:flattenAttributionRecords, dedup key builders,buildAttributionOtlpPayload,batchAttributionItems.src/sync/push.ts: the send loop is generalized intosendBatchesCore(the publicsendBatchessignature and behavior are unchanged); addscollectUnsentAttribution+sendAttributionBatches.src/sync/cli.ts: the--attributionflag, dry-run reporting, and summary output.Testing
tests/sync-attribution.test.ts): remote normalization (ssh/scp/https/credentials/local), record computation against real temp git repos (remote join, tightest-window ownership, no-remote handling, empty-session omission), dedup key state transitions, OTLP span shape, and the send+ledger pipeline against a mock OTLP server (success ledgering, 5xx not ledgered).yield*.test.ts,sync*.test.ts) —computeYieldbehavior and thesendBatchescontract are untouched.git reverted commit) pushed through the full pipeline to a live local HTTP server. Verified: correctin_main/was_revertedflags from real git, credential stripped from anhttps://user:token@…remote with a full-payload scan, idempotent re-collect after ledgering, and correct 2-fact re-send after merging the feature branch (state transition).Compatibility
sync push, payload shape, discovery doc, or auth.tscclean; no new dependencies.Caveats (by design)
codeburn yielddocuments. The methodology resource attribute exists so dashboards can label inferred vs. declared attribution.wasRevertedsignal only detects standardgit revertbodies ("This reverts commit <sha>"), matching yield's existing detection.Possible follow-ups (not in this PR)
sync.attribution: trueconfig option so scheduled pushes don't need the flag.originwhen both exist (origin may point at a fork).